diff --git a/.github/workflows/ultramodern-workspace-gates.yml b/.github/workflows/ultramodern-workspace-gates.yml index 10e992da6..66a4bb0a2 100644 --- a/.github/workflows/ultramodern-workspace-gates.yml +++ b/.github/workflows/ultramodern-workspace-gates.yml @@ -74,7 +74,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "26.5.0" + node-version: "26.7.0" - name: Setup mise uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 @@ -128,7 +128,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "26.5.0" + node-version: "26.7.0" - name: Setup mise uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 @@ -169,6 +169,17 @@ jobs: working-directory: app run: mise exec -- pnpm --filter @app/shell-super-app test:e2e + - name: Upload browser failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: shell-browser-failure-diagnostics + path: | + app/apps/shell-super-app/test-results/**/trace.zip + app/apps/shell-super-app/test-results/**/error-context.md + if-no-files-found: ignore + retention-days: 3 + - name: Show bounded service diagnostics after failure if: failure() working-directory: app @@ -200,7 +211,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "26.5.0" + node-version: "26.7.0" - name: Setup mise uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 @@ -245,7 +256,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "26.5.0" + node-version: "26.7.0" - name: Setup mise uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 @@ -293,7 +304,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "26.5.0" + node-version: "26.7.0" - name: Setup mise uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 diff --git a/app/.mise.toml b/app/.mise.toml index 959880fc3..aa82777f4 100644 --- a/app/.mise.toml +++ b/app/.mise.toml @@ -2,5 +2,5 @@ pnpm = "npm:pnpm" [tools] -node = "26.5.0" +node = "26.7.0" pnpm = "11.25.0" diff --git a/app/.modernjs/release-cohort.json b/app/.modernjs/release-cohort.json index 56e219189..dfe84c16c 100644 --- a/app/.modernjs/release-cohort.json +++ b/app/.modernjs/release-cohort.json @@ -2,17 +2,20 @@ "aliases": { "@modern-js/adapter-rstest": "@bleedingdev/modern-js-adapter-rstest", "@modern-js/app-tools": "@bleedingdev/modern-js-app-tools", + "@modern-js/app-tools-extensions": "@bleedingdev/modern-js-app-tools-extensions", "@modern-js/bff-core": "@bleedingdev/modern-js-bff-core", + "@modern-js/bff-effect": "@bleedingdev/modern-js-bff-effect", "@modern-js/bff-runtime": "@bleedingdev/modern-js-bff-runtime", "@modern-js/builder": "@bleedingdev/modern-js-builder", "@modern-js/code-tools": "@bleedingdev/modern-js-code-tools", - "@modern-js/create": "@bleedingdev/modern-js-create", "@modern-js/create-request": "@bleedingdev/modern-js-create-request", + "@modern-js/i18n-runtime-extensions": "@bleedingdev/modern-js-i18n-runtime-extensions", "@modern-js/i18n-utils": "@bleedingdev/modern-js-i18n-utils", "@modern-js/image": "@bleedingdev/modern-js-image", "@modern-js/main-doc": "@bleedingdev/modern-js-main-doc", "@modern-js/plugin": "@bleedingdev/modern-js-plugin", "@modern-js/plugin-bff": "@bleedingdev/modern-js-plugin-bff", + "@modern-js/plugin-bff-extensions": "@bleedingdev/modern-js-plugin-bff-extensions", "@modern-js/plugin-data-loader": "@bleedingdev/modern-js-plugin-data-loader", "@modern-js/plugin-i18n": "@bleedingdev/modern-js-plugin-i18n", "@modern-js/plugin-polyfill": "@bleedingdev/modern-js-plugin-polyfill", @@ -22,6 +25,7 @@ "@modern-js/prod-server": "@bleedingdev/modern-js-prod-server", "@modern-js/render": "@bleedingdev/modern-js-render", "@modern-js/runtime": "@bleedingdev/modern-js-runtime", + "@modern-js/runtime-extensions": "@bleedingdev/modern-js-runtime-extensions", "@modern-js/runtime-utils": "@bleedingdev/modern-js-runtime-utils", "@modern-js/sandpack-react": "@bleedingdev/modern-js-sandpack-react", "@modern-js/server": "@bleedingdev/modern-js-server", @@ -31,178 +35,210 @@ "@modern-js/server-utils": "@bleedingdev/modern-js-server-utils", "@modern-js/tsconfig": "@bleedingdev/modern-js-tsconfig", "@modern-js/types": "@bleedingdev/modern-js-types", + "@modern-js/ultramodern-create": "@bleedingdev/modern-js-ultramodern-create", + "@modern-js/ultramodern-sandpack-profile": "@bleedingdev/modern-js-ultramodern-sandpack-profile", "@modern-js/utils": "@bleedingdev/modern-js-utils" }, "packages": [ { "sourceName": "@modern-js/adapter-rstest", "targetName": "@bleedingdev/modern-js-adapter-rstest", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/app-tools", "targetName": "@bleedingdev/modern-js-app-tools", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/app-tools-extensions", + "targetName": "@bleedingdev/modern-js-app-tools-extensions", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/bff-core", "targetName": "@bleedingdev/modern-js-bff-core", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/bff-effect", + "targetName": "@bleedingdev/modern-js-bff-effect", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/bff-runtime", "targetName": "@bleedingdev/modern-js-bff-runtime", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/builder", "targetName": "@bleedingdev/modern-js-builder", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/code-tools", "targetName": "@bleedingdev/modern-js-code-tools", - "version": "3.8.2-ultramodern.12" - }, - { - "sourceName": "@modern-js/create", - "targetName": "@bleedingdev/modern-js-create", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/create-request", "targetName": "@bleedingdev/modern-js-create-request", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/i18n-runtime-extensions", + "targetName": "@bleedingdev/modern-js-i18n-runtime-extensions", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/i18n-utils", "targetName": "@bleedingdev/modern-js-i18n-utils", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/image", "targetName": "@bleedingdev/modern-js-image", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/main-doc", "targetName": "@bleedingdev/modern-js-main-doc", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin", "targetName": "@bleedingdev/modern-js-plugin", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-bff", "targetName": "@bleedingdev/modern-js-plugin-bff", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/plugin-bff-extensions", + "targetName": "@bleedingdev/modern-js-plugin-bff-extensions", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-data-loader", "targetName": "@bleedingdev/modern-js-plugin-data-loader", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-i18n", "targetName": "@bleedingdev/modern-js-plugin-i18n", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-polyfill", "targetName": "@bleedingdev/modern-js-plugin-polyfill", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-ssg", "targetName": "@bleedingdev/modern-js-plugin-ssg", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-styled-components", "targetName": "@bleedingdev/modern-js-plugin-styled-components", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/plugin-tanstack", "targetName": "@bleedingdev/modern-js-plugin-tanstack", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/prod-server", "targetName": "@bleedingdev/modern-js-prod-server", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/render", "targetName": "@bleedingdev/modern-js-render", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/runtime", "targetName": "@bleedingdev/modern-js-runtime", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/runtime-extensions", + "targetName": "@bleedingdev/modern-js-runtime-extensions", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/runtime-utils", "targetName": "@bleedingdev/modern-js-runtime-utils", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/sandpack-react", "targetName": "@bleedingdev/modern-js-sandpack-react", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/server", "targetName": "@bleedingdev/modern-js-server", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/server-core", "targetName": "@bleedingdev/modern-js-server-core", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/server-runtime", "targetName": "@bleedingdev/modern-js-server-runtime", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/server-runtime-extensions", "targetName": "@bleedingdev/modern-js-server-runtime-extensions", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/server-utils", "targetName": "@bleedingdev/modern-js-server-utils", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/tsconfig", "targetName": "@bleedingdev/modern-js-tsconfig", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/types", "targetName": "@bleedingdev/modern-js-types", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/ultramodern-create", + "targetName": "@bleedingdev/modern-js-ultramodern-create", + "version": "3.9.0-ultramodern.4" + }, + { + "sourceName": "@modern-js/ultramodern-sandpack-profile", + "targetName": "@bleedingdev/modern-js-ultramodern-sandpack-profile", + "version": "3.9.0-ultramodern.4" }, { "sourceName": "@modern-js/utils", "targetName": "@bleedingdev/modern-js-utils", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" } ], "release": { "tag": "latest", - "version": "3.8.2-ultramodern.12" + "version": "3.9.0-ultramodern.4" }, "schema": "bleedingdev.ultramodern.release-cohort", "schemaVersion": 1, "source": { - "commit": "69f2b5648e13a057261f22bb36cb2d8ca2d5962f", + "commit": "ef99279246046685f1684c59ca145f2a6a3f9d53", "repository": "BleedingDev/ultramodern.js" } } diff --git a/app/.modernjs/ultramodern.json b/app/.modernjs/ultramodern.json index 84ea7e17d..c14327e01 100644 --- a/app/.modernjs/ultramodern.json +++ b/app/.modernjs/ultramodern.json @@ -3,7 +3,7 @@ "profile": "cloudflare-ssr-mf-effect-v1", "generator": { "package": "@modern-js/create", - "version": "3.8.1-ultramodern.2" + "version": "3.9.0-ultramodern.4" }, "workspace": { "packageScope": "app", @@ -12,13 +12,14 @@ "version": "11.25.0" }, "node": { - "version": "26.5.0", + "version": "26.7.0", "engineRange": ">=26" } }, "packageSource": { "strategy": "install", - "modernPackageVersion": "3.8.2-ultramodern.12", + "modernPackageVersion": "3.9.0-ultramodern.4", + "registry": "https://registry.npmjs.org/", "aliasScope": "bleedingdev", "aliasPackageNamePrefix": "modern-js-" }, @@ -216,7 +217,11 @@ "exposes": [ "./PageContacts" ], - "ssr": true + "ssr": true, + "dts": { + "compilerInstance": "effect-tsgo", + "tsConfigPath": "./tsconfig.mf-types.json" + } }, "backendFederation": { "role": "microvertical-server", @@ -264,9 +269,9 @@ "ssr": { "workerEntry": ".output/server/index.mjs", "workerManifest": ".output/server/modern-worker-manifest.json", + "effectBffBundle": ".output/worker/__modern_bff_effect.js", "routeManifest": ".output/server/route.json", "ssrBundle": ".output/worker/index.js", - "effectBffBundle": ".output/worker/__modern_bff_effect.js", "assetsBinding": "ASSETS" }, "zephyr": { @@ -304,8 +309,8 @@ "compatibility": { "contractVersion": "microvertical-server-effect-v1", "packageName": "@app/party-registry", - "effectVersion": "4.0.0-beta.107", - "moduleFederationVersion": "2.8.0" + "effectVersion": "4.0.0-rc.112", + "moduleFederationVersion": "2.9.0" }, "cache": { "cloudflareSnapshot": "immutable", @@ -556,9 +561,9 @@ "ssr": { "workerEntry": ".output/server/index.mjs", "workerManifest": ".output/server/modern-worker-manifest.json", + "effectBffBundle": ".output/worker/__modern_bff_effect.js", "routeManifest": ".output/server/route.json", "ssrBundle": ".output/worker/index.js", - "effectBffBundle": ".output/worker/__modern_bff_effect.js", "assetsBinding": "ASSETS" }, "zephyr": { @@ -608,7 +613,7 @@ ] }, "tooling": { - "command": "modern-js-create ultramodern", + "command": "ultramodern-create ultramodern", "wrappers": { "validate": "scripts/validate-ultramodern-workspace.mts", "typecheck": "scripts/ultramodern-typecheck.mts", diff --git a/app/AGENTS.md b/app/AGENTS.md index 49dbb7cc0..c5175994e 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -1,23 +1,14 @@ # OntOS application guardrails -Before changing files under `app/`, read [the application coding guide](./README.md). It owns -setup, generator commands, coding conventions, validation, and trigger-based links to focused -architecture. +Before changing files under `app/`, read [the application coding guide](./README.md). It owns setup, generator commands, coding conventions, validation, and trigger-based links to focused architecture. Never read an `.env` file. ## Before editing -1. Read only the specification explicitly named by the task or GitHub issue. A specification with - `status: done`, `status: complete`, or `status: superseded` is historical evidence; stop unless - the task explicitly requests provenance. -2. Use the routing table in `README.md`. Open only documents whose concern matches the changed - files or behavior, plus matching product contexts when semantics are relevant. Do not browse - `app/specs/`, `app/docs/`, or root `docs/` for general background. -3. Start every supported business artifact with its Codesmith generator. If the category has no - approved generator or governed gateway, stop and get that boundary approved. -4. Never import another deployment's private source, registration, data access, or executable - behavior. If the task appears to require that, stop and resolve the MicroVertical contract. +1. Read only the specification explicitly named by the task or GitHub issue. A specification with `status: done`, `status: complete`, or `status: superseded` is historical evidence; stop unless the task explicitly requests provenance. +2. Use the routing table in `README.md`. Open only documents whose concern matches the changed files or behavior, plus matching product contexts when semantics are relevant. Do not browse `app/specs/`, `app/docs/`, or root `docs/` for general background. +3. Start every supported business artifact with its Codesmith generator. If the category has no approved generator or governed gateway, stop and get that boundary approved. +4. Never import another deployment's private source, registration, data access, or executable behavior. If the task appears to require that, stop and resolve the MicroVertical contract. -All remaining coding and command rules are owned by `README.md` and the focused documents selected -by its routing table. +All remaining coding and command rules are owned by `README.md` and the focused documents selected by its routing table. diff --git a/app/DEVELOPMENT.md b/app/DEVELOPMENT.md index 69849d224..97f773655 100644 --- a/app/DEVELOPMENT.md +++ b/app/DEVELOPMENT.md @@ -2,28 +2,22 @@ ## Branches -`main` is the canonical development branch and the default base and pull-request target. Do not -start new work from `develop`; it exists only for the one-time transition back to `main` and may be -removed after that transition. +`main` is the canonical development branch and the default base and pull-request target. Do not start new work from `develop`; it exists only for the one-time transition back to `main` and may be removed after that transition. -Promote releases from `main` to the protected `stage` branch. Feature sandboxes start from the -current committed `main` workflow below. +Promote releases from `main` to the protected `stage` branch. Feature sandboxes start from the current committed `main` workflow below. ## Repository-managed tooling - `.mise.toml` and `package.json#packageManager` own the local Node and pnpm toolchain. - `package.json#scripts` owns command names and composition. - `.agents/skills-lock.json` owns tracked skill sources; `.codex/skills/` is generated local output. -- Read-only reference repositories are opt-in through - `mise exec -- pnpm agents:refs:install`. +- Read-only reference repositories are opt-in through `mise exec -- pnpm agents:refs:install`. Do not copy versions, current package inventory, or generated skill state into prose. ## Locki -[Locki](https://github.com/JanPokorny/locki) creates isolated development sandboxes backed by Git -worktrees and containers. Each feature gets its own branch, dependencies, services, database, and -AI session without changing the primary checkout. +[Locki](https://github.com/JanPokorny/locki) creates isolated development sandboxes backed by Git worktrees and containers. Each feature gets its own branch, dependencies, services, database, and AI session without changing the primary checkout. Install Locki globally; the current directory does not matter: @@ -37,8 +31,7 @@ Run the one-time setup to select the AI harness and editor: locki setup ``` -Do not copy the entire `~/.codex` directory when prompted; it can contain large Codex worktrees. -Authenticate the selected harness inside Locki when required. +Do not copy the entire `~/.codex` directory when prompted; it can contain large Codex worktrees. Authenticate the selected harness inside Locki when required. ## Feature sandbox workflow @@ -48,10 +41,7 @@ Create and prepare a sandbox from `main` while in the primary `app/` directory: mise exec -- pnpm sandbox:new -- customer-search ``` -Replace `customer-search` with the feature slug. The command creates the branch and worktree, -copies `app/.env`, installs dependencies, starts containers, runs Drizzle migrations, initializes -the local tenant, legal entity, user, and Party Registry MicroVertical, verifies the database, and opens -the configured AI harness. Record the printed sandbox ID. +Replace `customer-search` with the feature slug. The command creates the branch and worktree, copies `app/.env`, installs dependencies, starts containers, runs Drizzle migrations, initializes the local tenant, legal entity, user, and Party Registry MicroVertical, verifies the database, and opens the configured AI harness. Record the printed sandbox ID. Forward application ports from macOS to the sandbox: @@ -71,28 +61,19 @@ mise exec -- pnpm dev `pnpm dev` occupies that terminal until stopped. -Party Registry owns Contacts, counterparties, and engagement profiles in one MicroVertical. Start -the Shell and Party Registry processes before exercising engagement-profile writes; there is no -separate Contacts deployment or cross-MicroVertical validation call. `mise exec -- pnpm -env:local:ensure` materializes the shared local infrastructure values while preserving explicit -values and printing no secrets. +Party Registry owns Contacts, counterparties, and engagement profiles in one MicroVertical. Start the Shell and Party Registry processes before exercising engagement-profile writes; there is no separate Contacts deployment or cross-MicroVertical validation call. `mise exec -- pnpm env:local:ensure` materializes the shared local infrastructure values while preserving explicit values and printing no secrets. ### Fail-closed Action authorization checkpoint -Sandbox preparation creates the fixed development context and Tenant membership but does not -provision Action executor relationships. For authorization changes, keep one sandbox unchanged -and verify this order: +Sandbox preparation creates the fixed development context and Tenant membership but does not provision Action executor relationships. For authorization changes, keep one sandbox unchanged and verify this order: 1. invoke a representative Party Registry engagement mutation as `demo@test.com`; -2. confirm a localized error Toast and `403`, one rejected invocation/audit record, and no - business write or handler effect; +2. confirm a localized error Toast and `403`, one rejected invocation/audit record, and no business write or handler effect; 3. run `mise exec -- pnpm authorization:provision-current-actions` twice to prove idempotence; 4. retry the mutation and confirm normal success without a denial Toast; 5. confirm a Principal outside the fixed development Tenant remains denied. -The provisioning command discovers current Actions and grants executor relations only to the -fixed development Tenant membership set. It accepts no caller-supplied scope and never writes -stage from a development sandbox. +The provisioning command discovers current Actions and grants executor relations only to the fixed development Tenant membership set. It accepts no caller-supplied scope and never writes stage from a development sandbox. When the feature sandbox is no longer needed, stop its running processes and remove it: @@ -100,11 +81,9 @@ When the feature sandbox is no longer needed, stop its running processes and rem locki rm --match 1aixi9oo --branches ``` -Locki refuses removal when uncommitted changes exist. This removes the container, worktree, port -forwards, and sandbox branches. +Locki refuses removal when uncommitted changes exist. This removes the container, worktree, port forwards, and sandbox branches. -Delete the shared Locki VM only when its containers, images, volumes, and caches are no longer -needed: +Delete the shared Locki VM only when its containers, images, volumes, and caches are no longer needed: ```sh locki vm delete diff --git a/app/README.md b/app/README.md index 96f339e59..cb2bd131d 100644 --- a/app/README.md +++ b/app/README.md @@ -1,8 +1,6 @@ # OntOS application -> [!IMPORTANT] -> Read [Development](./DEVELOPMENT.md) before feature work. Use an isolated Locki sandbox and -> treat `app/` as the application root. +> [!IMPORTANT] Read [Development](./DEVELOPMENT.md) before feature work. Use an isolated Locki sandbox and treat `app/` as the application root. ## Read by trigger @@ -25,40 +23,25 @@ Read this guide, then only the rows that govern the task. | Frontend work, including Figma | [Frontend Architecture](docs/frontend/FRONTEND.md) | | ARES provider lookup and Party application | [ARES reference](docs/integrations/ares.md) and [Party Registry](docs/architecture/PARTY_REGISTRY.md) | -Read a specification only when the task or GitHub issue names it. A specification with -`status: done`, `status: complete`, or `status: superseded` is historical implementation evidence, -not current guidance. Do not browse `specs/` for background. +Read a specification only when the task or GitHub issue names it. A specification with `status: done`, `status: complete`, or `status: superseded` is historical implementation evidence, not current guidance. Do not browse `specs/` for background. ## Non-negotiable rules -- Use Effect for application behavior, I/O, resource management, concurrency, dependencies, BFF - contracts and clients, schemas, and expected failures. Pure synchronous transformations and - reusable presentation may stay plain TypeScript or React. -- Model expected failures as tagged Effect errors. Do not throw, reject a Promise, return an - untyped error object, or collapse an expected failure into a string. -- Preserve strict independently deployable MicroVertical seams. Never import another deployment's - private manifest, registration, table, handler, repository, route, migration, fixture, or test. -- Frontends call only the owning MicroVertical's generated Effect BFF client. Run an Effect at the - framework edge without erasing its typed failure channel. -- All public writes and governed reads pass through Core-owned operation lifecycles. Tenant/system - entrypoint scope and legal-entity scope are independent; invalid or indeterminate context fails - closed before private code resolves. -- Prefer direct values and typed references over stringly typed metadata. Reuse existing concepts - and files; add an abstraction only for a concrete reuse case. -- Business handlers receive owner-local transaction-scoped services, never a raw database - executor. -- Start supported business artifacts with Codesmith. If a category has no approved generator or - gateway, extend or approve that boundary before creating the artifact. +- Use Effect for application behavior, I/O, resource management, concurrency, dependencies, BFF contracts and clients, schemas, and expected failures. Pure synchronous transformations and reusable presentation may stay plain TypeScript or React. +- Model expected failures as tagged Effect errors. Do not throw, reject a Promise, return an untyped error object, or collapse an expected failure into a string. +- Preserve strict independently deployable MicroVertical seams. Never import another deployment's private manifest, registration, table, handler, repository, route, migration, fixture, or test. +- Frontends call only the owning MicroVertical's generated Effect BFF client. Run an Effect at the framework edge without erasing its typed failure channel. +- All public writes and governed reads pass through Core-owned operation lifecycles. Tenant/system entrypoint scope and legal-entity scope are independent; invalid or indeterminate context fails closed before private code resolves. +- Prefer direct values and typed references over stringly typed metadata. Reuse existing concepts and files; add an abstraction only for a concrete reuse case. +- Business handlers receive owner-local transaction-scoped services, never a raw database executor. +- Start supported business artifacts with Codesmith. If a category has no approved generator or gateway, extend or approve that boundary before creating the artifact. - Infrastructure and architecture files may be created directly when no generator applies. -- Keep third-party HTTP adapters private to their owner. Define provider schemas, typed failures, - request construction, resilience, diagnostics, and business mapping; use the generated Effect - `HttpClient` service as the test seam. +- Keep third-party HTTP adapters private to their owner. Define provider schemas, typed failures, request construction, resilience, diagnostics, and business mapping; use the generated Effect `HttpClient` service as the test seam. - Run pnpm commands from `app/` through `mise exec -- pnpm`. ## Codesmith -`package.json#scripts` is the command source of truth. Inspect supported flags with -`mise exec -- pnpm - )} + {jsonLd === undefined ? null : } )} diff --git a/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts b/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts index 835a449a1..3476fff34 100644 --- a/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts +++ b/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts @@ -1,4 +1,4 @@ -// @generated by @modern-js/create. +// @generated by @modern-js/ultramodern-create. // Author route metadata in colocated src/routes/**/route.meta.ts files. // This compatibility manifest is regenerated from route-owned metadata. @@ -169,10 +169,6 @@ export const ultramodernLocalisedUrls = { cs: '/contacts', en: '/contacts', }, - '/hledat': { - cs: '/hledat', - en: '/search', - }, '/login': { cs: '/login', en: '/login', @@ -189,8 +185,4 @@ export const ultramodernLocalisedUrls = { cs: '/hledat', en: '/search', }, - '/zdroje/:moduleId/:resourceType/:resourceId': { - cs: '/zdroje/:moduleId/:resourceType/:resourceId', - en: '/resources/:moduleId/:resourceType/:resourceId', - }, } as const; diff --git a/app/apps/shell-super-app/src/routes/use-shell-controls.ts b/app/apps/shell-super-app/src/routes/use-shell-controls.ts index eff4d1309..ad8d416e5 100644 --- a/app/apps/shell-super-app/src/routes/use-shell-controls.ts +++ b/app/apps/shell-super-app/src/routes/use-shell-controls.ts @@ -2,9 +2,10 @@ import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; import { useNavigate } from '@modern-js/plugin-tanstack/runtime'; import { Effect, Match, Schema } from 'effect'; import { useState } from 'react'; + +import { SwitchLegalEntityPayloadSchema, SwitchTenantPayloadSchema } from '../../shared/api.ts'; import { signOut, switchLegalEntity, switchTenant } from '../api/auth-client.ts'; import type { SwitchLegalEntityClientError, SwitchTenantClientError } from '../api/auth-client.ts'; -import { SwitchLegalEntityPayloadSchema, SwitchTenantPayloadSchema } from '../../shared/api.ts'; import { browserRuntime } from '../runtime/browser-effect-runtime.ts'; import type { AuthenticatedHomePageModel } from './[lang]/page.data.ts'; @@ -50,9 +51,7 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) const [legalEntitySwitchFailed, setLegalEntitySwitchFailed] = useState(false); const reload = () => - Effect.tryPromise(() => navigate({ reloadDocument: true, to: '.' })).pipe( - Effect.timeout('10 seconds'), - ); + Effect.tryPromise(() => navigate({ reloadDocument: true, to: '.' })).pipe(Effect.timeout('10 seconds')); const handleLogout = () => { if (logoutPending) { @@ -63,9 +62,9 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) void browserRuntime.runPromise( signOut({ locale: language }).pipe( Effect.andThen( - Effect.tryPromise(() => - navigate({ reloadDocument: true, to: `/${language}/login` }), - ).pipe(Effect.timeout('10 seconds')), + Effect.tryPromise(() => navigate({ reloadDocument: true, to: `/${language}/login` })).pipe( + Effect.timeout('10 seconds'), + ), ), Effect.matchEffect({ onFailure: (error) => @@ -113,17 +112,13 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) }; const handleLegalEntityChange = (legalEntityId: string) => { - if ( - model === undefined || - legalEntitySwitchPending || - legalEntityId === model.selectedLegalEntityId - ) { + if (model === undefined || legalEntitySwitchPending || legalEntityId === model.selectedLegalEntityId) { return; } runSwitch( - Schema.decodeUnknownEffect(SwitchLegalEntityPayloadSchema)({ legalEntityId }).pipe( - Effect.flatMap((payload) => switchLegalEntity(payload, { locale: language })), - ), + Schema.decodeEffect(SwitchLegalEntityPayloadSchema)({ + legalEntityId, + }).pipe(Effect.flatMap((payload) => switchLegalEntity(payload, { locale: language }))), legalEntitySwitchFailureState, setLegalEntitySwitchPending, setLegalEntitySwitchFailed, @@ -131,16 +126,11 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) }; const handleTenantChange = (tenantId: string) => { - if ( - model === undefined || - tenantSwitchPending || - tenantId.length === 0 || - tenantId === model.identity.tenantId - ) { + if (model === undefined || tenantSwitchPending || tenantId.length === 0 || tenantId === model.identity.tenantId) { return; } runSwitch( - Schema.decodeUnknownEffect(SwitchTenantPayloadSchema)({ tenantId }).pipe( + Schema.decodeEffect(SwitchTenantPayloadSchema)({ tenantId }).pipe( Effect.flatMap((payload) => switchTenant(payload, { locale: language })), ), tenantSwitchFailureState, @@ -153,7 +143,9 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) handleLegalEntityChange, handleLogout, handleSearch: (query: string) => { - void navigate({ to: `/${language}/search?q=${encodeURIComponent(query)}` }); + void navigate({ + to: `/${language}/search?q=${encodeURIComponent(query)}`, + }); }, handleTenantChange, legalEntitySwitchFailed, diff --git a/app/apps/shell-super-app/tests/e2e/auth-fixture.ts b/app/apps/shell-super-app/tests/e2e/auth-fixture.ts index eb3bcdb59..7beb383dd 100644 --- a/app/apps/shell-super-app/tests/e2e/auth-fixture.ts +++ b/app/apps/shell-super-app/tests/e2e/auth-fixture.ts @@ -1,18 +1,12 @@ -import { Crypto, Effect, Redacted, Schema } from 'effect'; -import { NodeCrypto } from '@effect/platform-node'; -import { deadlineInterceptor, v1 } from '@authzed/authzed-node'; -import { loadSpiceDbConfig } from '../../../../packages/core-runtime/src/permissions/config.ts'; -import { - toLegalEntityAccessObjectId, - toModuleAccessObjectId, -} from '../../../../packages/core-runtime/src/permissions/context-access.ts'; -import { acquirePoolResource, makeAuthDatabase } from '../../api/auth/db/client.ts'; -import { configureDatabasePool } from '../../../../packages/core-runtime/src/db/pool-configuration.ts'; -import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; +import { deadlineInterceptor, v1 } from '@authzed/authzed-node'; +import { NodeCrypto } from '@effect/platform-node'; import { betterAuth } from 'better-auth'; import { eq, inArray } from 'drizzle-orm'; +import { Crypto, Effect, Redacted, Schema } from 'effect'; import { Pool } from 'pg'; + +import { configureDatabasePool } from '../../../../packages/core-runtime/src/db/pool-configuration.ts'; import { coreRelations, dataAccessEvents, @@ -22,7 +16,14 @@ import { tenantModuleStates, tenants, } from '../../../../packages/core-runtime/src/db/schema.ts'; +import { loadSpiceDbConfig } from '../../../../packages/core-runtime/src/permissions/config.ts'; +import { + toLegalEntityAccessObjectId, + toModuleAccessObjectId, +} from '../../../../packages/core-runtime/src/permissions/context-access.ts'; +import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; import { loadAuthConfig } from '../../api/auth/config.ts'; +import { acquirePoolResource, makeAuthDatabase } from '../../api/auth/db/client.ts'; import { account, session, user } from '../../api/auth/db/schema.ts'; const contactsModuleId = 'party.registry'; @@ -31,7 +32,10 @@ const contactsModuleId = 'party.registry'; // otherwise keep a client checked out and hold `pool.end()` open past the acquisition // deadline. Keep the shared connection bound and shorten the statement bound below that // deadline. Never use query_timeout or a Promise race -- neither cancels server work. -const e2ePoolDeadlines = { connectionTimeoutMillis: 5000, statement_timeout: 10_000 } as const; +const e2ePoolDeadlines = { + connectionTimeoutMillis: 5000, + statement_timeout: 10_000, +} as const; class E2eAuthorizationFixtureError extends Schema.TaggedError()( 'E2eAuthorizationFixtureError', @@ -52,77 +56,75 @@ interface FixtureTenants { // Database module activation does not grant access. Own the complete authorization // chain for these disposable E2E identities instead of relying on bootstrap seeds. -const provisionContactsAccess = Effect.fn('provisionContactsAccess')( - function* provisionContactsAccessEffect(e2eTenants: FixtureTenants) { - const configuration = yield* loadSpiceDbConfig(); - if (!configuration.endpoint.startsWith('localhost:')) { - return yield* Effect.fail( - new E2eAuthorizationFixtureError({ - reason: 'E2E authorization fixtures require localhost SpiceDB', - }), - ); - } - const client = yield* Effect.acquireRelease( - Effect.sync(() => - v1.NewClient( - configuration.preSharedKey, - configuration.endpoint, - configuration.insecureLocal - ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED - : v1.ClientSecurity.SECURE, - undefined, - { interceptors: [deadlineInterceptor(5000)] }, - ), - ), - (acquired) => Effect.sync(() => acquired.close()), +const provisionContactsAccess = Effect.fn('provisionContactsAccess')(function* provisionContactsAccessEffect( + e2eTenants: FixtureTenants, +) { + const configuration = yield* loadSpiceDbConfig(); + if (!configuration.endpoint.startsWith('localhost:')) { + return yield* Effect.fail( + new E2eAuthorizationFixtureError({ + reason: 'E2E authorization fixtures require localhost SpiceDB', + }), ); - const relationships = yield* Effect.forEach( - Object.values(e2eTenants), - ({ legalEntityId, principalId, tenantId }) => - Effect.gen(function* makeContactsRelationships() { - const entityObject = toLegalEntityAccessObjectId(tenantId, legalEntityId); - const moduleObject = toModuleAccessObjectId(tenantId, legalEntityId, contactsModuleId); - if (entityObject === undefined || moduleObject === undefined) { - return yield* Effect.fail( - new E2eAuthorizationFixtureError({ - reason: 'Invalid E2E authorization object identifier', - }), - ); - } - return ( - [ - ['tenant', tenantId, 'member', 'principal', principalId], - ['legal_entity', entityObject, 'tenant', 'tenant', tenantId], - ['legal_entity', entityObject, 'member', 'principal', principalId], - ['module_access', moduleObject, 'legal_entity', 'legal_entity', entityObject], - ['module_access', moduleObject, 'accessor', 'principal', principalId], - ] as const - ).map(([resourceType, resourceId, relation, subjectType, subjectId]) => - v1.Relationship.create({ - relation, - resource: { objectId: resourceId, objectType: resourceType }, - subject: { object: { objectId: subjectId, objectType: subjectType } }, + } + const client = yield* Effect.acquireRelease( + Effect.sync(() => + v1.NewClient( + configuration.preSharedKey, + configuration.endpoint, + configuration.insecureLocal ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED : v1.ClientSecurity.SECURE, + undefined, + { interceptors: [deadlineInterceptor(5000)] }, + ), + ), + (acquired) => Effect.sync(() => acquired.close()), + ); + const relationships = yield* Effect.forEach( + Object.values(e2eTenants), + ({ legalEntityId, principalId, tenantId }) => + Effect.gen(function* makeContactsRelationships() { + const entityObject = toLegalEntityAccessObjectId(tenantId, legalEntityId); + const moduleObject = toModuleAccessObjectId(tenantId, legalEntityId, contactsModuleId); + if (entityObject === undefined || moduleObject === undefined) { + return yield* Effect.fail( + new E2eAuthorizationFixtureError({ + reason: 'Invalid E2E authorization object identifier', }), ); - }), - { concurrency: 'unbounded' }, - ); - const update = (operation: v1.RelationshipUpdate_Operation) => - Effect.tryPromise( - async () => - await client.promises.writeRelationships( - v1.WriteRelationshipsRequest.create({ - updates: relationships.flat().map((relationship) => ({ operation, relationship })), - }), - ), - ); - // Register first so even an indeterminate write acknowledgement is cleaned up. - yield* Effect.addFinalizer(() => - update(v1.RelationshipUpdate_Operation.DELETE).pipe(Effect.orDie), + } + return ( + [ + ['tenant', tenantId, 'member', 'principal', principalId], + ['legal_entity', entityObject, 'tenant', 'tenant', tenantId], + ['legal_entity', entityObject, 'member', 'principal', principalId], + ['module_access', moduleObject, 'legal_entity', 'legal_entity', entityObject], + ['module_access', moduleObject, 'accessor', 'principal', principalId], + ] as const + ).map(([resourceType, resourceId, relation, subjectType, subjectId]) => + v1.Relationship.create({ + relation, + resource: { objectId: resourceId, objectType: resourceType }, + subject: { + object: { objectId: subjectId, objectType: subjectType }, + }, + }), + ); + }), + { concurrency: 'unbounded' }, + ); + const update = (operation: v1.RelationshipUpdate_Operation) => + Effect.tryPromise( + async () => + await client.promises.writeRelationships( + v1.WriteRelationshipsRequest.create({ + updates: relationships.flat().map((relationship) => ({ operation, relationship })), + }), + ), ); - return yield* update(v1.RelationshipUpdate_Operation.TOUCH).pipe(Effect.uninterruptible); - }, -); + // Register first so even an indeterminate write acknowledgement is cleaned up. + yield* Effect.addFinalizer(() => update(v1.RelationshipUpdate_Operation.DELETE).pipe(Effect.orDie)); + return yield* update(v1.RelationshipUpdate_Operation.TOUCH).pipe(Effect.uninterruptible); +}); export const createAuthenticationFixture = Effect.fn('createAuthenticationFixture')( function* createAuthenticationFixtureEffect() { @@ -143,19 +145,14 @@ export const createAuthenticationFixture = Effect.fn('createAuthenticationFixtur first: makeTenant('E2E Alpha tenant'), second: makeTenant('E2E Zeta tenant'), }); - const { - baseUrl: baseURL, - connectionString, - secret, - } = yield* loadAuthConfig({ envPath: APP_ENV_PATH }); + const { baseUrl: baseURL, connectionString, secret } = yield* loadAuthConfig({ envPath: APP_ENV_PATH }); - const corePoolConfiguration = yield* configureDatabasePool( - Redacted.make(connectionString), - e2ePoolDeadlines, - ); + const corePoolConfiguration = yield* configureDatabasePool(Redacted.make(connectionString), e2ePoolDeadlines); const corePool = yield* acquirePoolResource(() => new Pool(corePoolConfiguration)); const coreDatabase = yield* makeTestDatabaseFromPool(corePool, coreRelations); - const { adapter, executor: authDatabase } = yield* makeAuthDatabase({ connectionString }); + const { adapter, executor: authDatabase } = yield* makeAuthDatabase({ + connectionString, + }); const authentication = betterAuth({ baseURL, database: adapter, @@ -167,56 +164,46 @@ export const createAuthenticationFixture = Effect.fn('createAuthenticationFixtur trustedOrigins: [baseURL, 'http://127.0.0.1:3020'], }); - const cleanup = Effect.fn('cleanupAuthenticationFixture')( - function* cleanupAuthenticationFixtureEffect() { - const tenantIds = Object.values(e2eTenants).map(({ tenantId }) => tenantId); - const principalIds = Object.values(e2eTenants).map(({ principalId }) => principalId); - // Authenticated shell reads write evidence asynchronously. Let those writes - // settle, then remove their E2E-owned rows before the referenced identities. - yield* Effect.sleep('250 millis'); - yield* coreDatabase - .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.principalId, principalIds)); - const existingUsers = yield* authDatabase - .select({ id: user.id }) - .from(user) - .where(eq(user.email, e2eCredentials.email)); + const cleanup = Effect.fn('cleanupAuthenticationFixture')(function* cleanupAuthenticationFixtureEffect() { + const tenantIds = Object.values(e2eTenants).map(({ tenantId }) => tenantId); + const principalIds = Object.values(e2eTenants).map(({ principalId }) => principalId); + // Authenticated shell reads write evidence asynchronously. Let those writes + // settle, then remove their E2E-owned rows before the referenced identities. + yield* Effect.sleep('250 millis'); + yield* coreDatabase.delete(dataAccessEvents).where(inArray(dataAccessEvents.principalId, principalIds)); + const existingUsers = yield* authDatabase + .select({ id: user.id }) + .from(user) + .where(eq(user.email, e2eCredentials.email)); - yield* Effect.all( - existingUsers.map((existingUser) => - Effect.gen(function* removeExistingAuthUser() { - yield* authDatabase.delete(session).where(eq(session.userId, existingUser.id)); - yield* authDatabase.delete(account).where(eq(account.userId, existingUser.id)); - yield* authDatabase.delete(user).where(eq(user.id, existingUser.id)); - }), - ), - { concurrency: 'unbounded', discard: true }, - ); - // A page read can finish its asynchronous evidence write while auth rows - // are being removed. Clear that final E2E-owned batch before deleting the - // binding referenced by the evidence foreign key. - yield* coreDatabase - .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.principalId, principalIds)); - yield* Effect.all( - existingUsers.map((existingUser) => - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), - ), - { concurrency: 'unbounded', discard: true }, - ); - yield* coreDatabase - .delete(principalAuthBindings) - .where(inArray(principalAuthBindings.principalId, principalIds)); - yield* coreDatabase - .delete(tenantModuleStates) - .where(inArray(tenantModuleStates.tenantId, tenantIds)); - yield* coreDatabase.delete(legalEntities).where(inArray(legalEntities.tenantId, tenantIds)); - yield* coreDatabase.delete(principals).where(inArray(principals.principalId, principalIds)); - yield* coreDatabase.delete(tenants).where(inArray(tenants.tenantId, tenantIds)); - }, - ); + yield* Effect.all( + existingUsers.map((existingUser) => + Effect.gen(function* removeExistingAuthUser() { + yield* authDatabase.delete(session).where(eq(session.userId, existingUser.id)); + yield* authDatabase.delete(account).where(eq(account.userId, existingUser.id)); + yield* authDatabase.delete(user).where(eq(user.id, existingUser.id)); + }), + ), + { concurrency: 'unbounded', discard: true }, + ); + // A page read can finish its asynchronous evidence write while auth rows + // are being removed. Clear that final E2E-owned batch before deleting the + // binding referenced by the evidence foreign key. + yield* coreDatabase.delete(dataAccessEvents).where(inArray(dataAccessEvents.principalId, principalIds)); + yield* Effect.all( + existingUsers.map((existingUser) => + coreDatabase + .delete(principalAuthBindings) + .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), + ), + { concurrency: 'unbounded', discard: true }, + ); + yield* coreDatabase.delete(principalAuthBindings).where(inArray(principalAuthBindings.principalId, principalIds)); + yield* coreDatabase.delete(tenantModuleStates).where(inArray(tenantModuleStates.tenantId, tenantIds)); + yield* coreDatabase.delete(legalEntities).where(inArray(legalEntities.tenantId, tenantIds)); + yield* coreDatabase.delete(principals).where(inArray(principals.principalId, principalIds)); + yield* coreDatabase.delete(tenants).where(inArray(tenants.tenantId, tenantIds)); + }); yield* Effect.addFinalizer(() => cleanup().pipe(Effect.orDie)); const createdUser = yield* Effect.tryPromise( @@ -300,10 +287,26 @@ export const createAuthenticationFixture = Effect.fn('createAuthenticationFixtur }, ]); yield* coreDatabase.insert(tenantModuleStates).values([ - { moduleKey: contactsModuleId, state: 'active', tenantId: e2eTenants.first.tenantId }, - { moduleKey: contactsModuleId, state: 'active', tenantId: e2eTenants.second.tenantId }, - { moduleKey: 'e2e-first-module', state: 'active', tenantId: e2eTenants.first.tenantId }, - { moduleKey: 'e2e-second-module', state: 'active', tenantId: e2eTenants.second.tenantId }, + { + moduleKey: contactsModuleId, + state: 'active', + tenantId: e2eTenants.first.tenantId, + }, + { + moduleKey: contactsModuleId, + state: 'active', + tenantId: e2eTenants.second.tenantId, + }, + { + moduleKey: 'e2e-first-module', + state: 'active', + tenantId: e2eTenants.first.tenantId, + }, + { + moduleKey: 'e2e-second-module', + state: 'active', + tenantId: e2eTenants.second.tenantId, + }, ]); yield* provisionContactsAccess(e2eTenants); return { credentials: e2eCredentials, tenants: e2eTenants }; diff --git a/app/apps/shell-super-app/tests/e2e/login.spec.ts b/app/apps/shell-super-app/tests/e2e/login.spec.ts index c38226a9c..4f579a3ee 100644 --- a/app/apps/shell-super-app/tests/e2e/login.spec.ts +++ b/app/apps/shell-super-app/tests/e2e/login.spec.ts @@ -1,6 +1,7 @@ -import { Effect, Predicate } from 'effect'; import { expect, test as base } from '@playwright/test'; import type { Page } from '@playwright/test'; +import { Effect, Predicate } from 'effect'; + import { shellAuthenticationApiContract } from '../../shared/api.ts'; import { createAuthenticationFixture } from './auth-fixture.ts'; import type { AuthenticationFixture } from './auth-fixture.ts'; @@ -11,9 +12,7 @@ const hydratedLoginForm = (page: Page) => page.locator('form[data-e2e-hydrated-l // installs React props and moves its matching content portal to document.body. const waitForInteractiveAccountMenu = async (page: Page) => { await page.waitForFunction(() => { - const trigger = document.querySelector( - 'button[data-scope="menu"][data-part="trigger"]', - ); + const trigger = document.querySelector('button[data-scope="menu"][data-part="trigger"]'); if (trigger === null) { return false; } @@ -60,16 +59,17 @@ const gotoHydratedLogin = async (page: Page, language: 'cs' | 'en') => { }); }; -const login = async ( - page: Page, - language: 'cs' | 'en', - credentials: AuthenticationFixture['credentials'], -) => { +const login = async (page: Page, language: 'cs' | 'en', credentials: AuthenticationFixture['credentials']) => { await gotoHydratedLogin(page, language); const form = hydratedLoginForm(page); const labels = language === 'en' - ? { login: /^Login\s*\*$/u, password: /^Password/u, submit: 'Login', url: /\/en\/?$/u } + ? { + login: /^Login\s*\*$/u, + password: /^Password/u, + submit: 'Login', + url: /\/en\/?$/u, + } : { login: /^Přihlašovací jméno\s*\*$/u, password: /^Heslo/u, @@ -95,10 +95,7 @@ const test = base.extend, { authentication: AuthenticationF async ({ browserName: _browserName }, use) => { await Effect.runPromise( Effect.gen(function* useAuthenticationFixture() { - const fixture = yield* Effect.timeout( - createAuthenticationFixture(), - workerFixtureAcquisitionTimeout, - ); + const fixture = yield* Effect.timeout(createAuthenticationFixture(), workerFixtureAcquisitionTimeout); yield* Effect.tryPromise(async () => await use(fixture)); }).pipe(Effect.scoped), ); @@ -124,9 +121,7 @@ test('renders the exact anonymous English and Czech home states', async ({ page await expectAnonymousHome('cs', 'Přihlásit se'); }); -test('keeps English and Czech login pages free of authenticated dashboard chrome', async ({ - page, -}) => { +test('keeps English and Czech login pages free of authenticated dashboard chrome', async ({ page }) => { const expectDashboardAbsent = async () => await Promise.all([ expect(page.locator('header[aria-label]')).toHaveCount(0), @@ -163,10 +158,7 @@ test('shows one generic error for invalid English credentials', async ({ authent ]), )); -test('logs a user in without any server-error response', async ({ - authentication, - page, -}, testInfo) => { +test('logs a user in without any server-error response', async ({ authentication, page }, testInfo) => { const { baseURL } = testInfo.project.use; if (!Predicate.isString(baseURL)) { throw new TypeError('The login E2E test requires a configured base URL'); @@ -177,11 +169,7 @@ test('logs a user in without any server-error response', async ({ page.on('response', (response) => { const responseURL = new URL(response.url()); - if ( - responseURL.origin === applicationOrigin && - response.status() >= 500 && - response.status() < 600 - ) { + if (responseURL.origin === applicationOrigin && response.status() >= 500 && response.status() < 600) { serverErrors.push( `${response.request().method()} ${responseURL.pathname}${responseURL.search} returned ${response.status()}`, ); @@ -210,10 +198,7 @@ test('logs a user in without any server-error response', async ({ expect(serverErrors, 'Login and the authenticated page must not return HTTP 5xx').toEqual([]); }); -test('loads localized English and Czech Contacts pages only after login', async ({ - authentication, - page, -}) => { +test('loads localized English and Czech Contacts pages only after login', async ({ authentication, page }) => { const pageErrors: string[] = []; page.on('pageerror', (error) => pageErrors.push(error.message)); @@ -245,8 +230,7 @@ test('loads localized English and Czech Contacts pages only after login', async await expect(page.getByText(content.empty)).toHaveCount(0); }; await expectContacts({ - description: - 'Party Registry uchovává kanonické strany, protistrany a jejich profily zapojení v jednom modulu.', + description: 'Party Registry uchovává kanonické strany, protistrany a jejich profily zapojení v jednom modulu.', empty: 'Zatím zde není žádný obsah.', heading: 'Kontakty', module: 'Modul', @@ -257,8 +241,7 @@ test('loads localized English and Czech Contacts pages only after login', async await page.goto('/en/contacts'); await expectContacts({ - description: - 'Party Registry keeps canonical Parties, Counterparties, and their engagement profiles in one module.', + description: 'Party Registry keeps canonical Parties, Counterparties, and their engagement profiles in one module.', empty: 'No content has been added yet.', heading: 'Contacts', module: 'Module', @@ -266,18 +249,15 @@ test('loads localized English and Czech Contacts pages only after login', async url: /\/en\/contacts\/?$/u, }); await expect(page.getByText('The module is temporarily unavailable. Try again.')).toHaveCount(0); - const dashboardSidebar = page.getByRole('complementary', { name: 'Dashboard sidebar' }); + const dashboardSidebar = page.getByRole('complementary', { + name: 'Dashboard sidebar', + }); await expect(dashboardSidebar).toBeVisible(); - const [sidebarBox, mainBox] = await Promise.all([ - dashboardSidebar.boundingBox(), - page.locator('main').boundingBox(), - ]); + const [sidebarBox, mainBox] = await Promise.all([dashboardSidebar.boundingBox(), page.locator('main').boundingBox()]); expect(sidebarBox?.width).toBe(256); expect(mainBox?.x).toBe(256); expect( - pageErrors.filter((message) => - message.includes('FederatedI18nBoundary must be used within ModernI18nProvider'), - ), + pageErrors.filter((message) => message.includes('FederatedI18nBoundary must be used within ModernI18nProvider')), ).toEqual([]); }); @@ -294,10 +274,7 @@ test('keeps authenticated Shell chrome on search and guarded direct-target route await expect(page.getByText(status)).toBeVisible(); }; await expectPersistentShell('/en/search', 'No authorized results found.'); - await expectPersistentShell( - '/en/modules/not-installed', - 'You do not have permission to open this module.', - ); + await expectPersistentShell('/en/modules/not-installed', 'You do not have permission to open this module.'); await expectPersistentShell( '/en/resources/not-installed/example/missing', 'You do not have permission to view this resource.', @@ -365,7 +342,9 @@ test('switches tenant by pointer, fully reloads, and persists the selected conte response.request().method() === 'POST', ); await Promise.all([ - page.waitForEvent('framenavigated', { predicate: (frame) => frame === page.mainFrame() }), + page.waitForEvent('framenavigated', { + predicate: (frame) => frame === page.mainFrame(), + }), page.getByRole('option', { name: authentication.tenants.second.name }).click(), ]); const switchResponse = await switchResponsePromise; @@ -409,7 +388,9 @@ test('retains Czech tenant context after one failed switch and supports keyboard await tenant.focus(); await page.keyboard.press('Enter'); - const secondTenantOption = page.getByRole('option', { name: authentication.tenants.second.name }); + const secondTenantOption = page.getByRole('option', { + name: authentication.tenants.second.name, + }); await expect(secondTenantOption).toBeVisible(); const tenantListbox = page.getByRole('listbox'); await tenantListbox.press('End'); @@ -417,7 +398,9 @@ test('retains Czech tenant context after one failed switch and supports keyboard expect(secondTenantOptionId).not.toBeNull(); await expect(tenantListbox).toHaveAttribute('aria-activedescendant', secondTenantOptionId ?? ''); await Promise.all([ - page.waitForEvent('framenavigated', { predicate: (frame) => frame === page.mainFrame() }), + page.waitForEvent('framenavigated', { + predicate: (frame) => frame === page.mainFrame(), + }), tenantListbox.press('Enter'), ]); await expect(page.getByRole('combobox', { name: 'Aktuální tenant' })).toContainText( @@ -425,29 +408,18 @@ test('retains Czech tenant context after one failed switch and supports keyboard ); }); -test('keeps keyboard logout operable after a Czech failure and succeeds on retry', async ({ - authentication, - page, -}) => { +test('keeps keyboard logout operable after a Czech failure and succeeds on retry', async ({ authentication, page }) => { let failLogout = true; await gotoHydratedLogin(page, 'cs') .then( - async () => - await hydratedLoginForm(page) - .locator('input[name="login"]') - .fill(authentication.credentials.email), + async () => await hydratedLoginForm(page).locator('input[name="login"]').fill(authentication.credentials.email), ) .then( async () => - await hydratedLoginForm(page) - .locator('input[name="password"]') - .fill(authentication.credentials.password), - ) - .then( - async () => - await hydratedLoginForm(page).getByRole('button', { name: 'Přihlásit se' }).click(), + await hydratedLoginForm(page).locator('input[name="password"]').fill(authentication.credentials.password), ) + .then(async () => await hydratedLoginForm(page).getByRole('button', { name: 'Přihlásit se' }).click()) .then(async () => await expect(page).toHaveURL(/\/cs\/?$/u)) .then( async () => @@ -464,10 +436,7 @@ test('keeps keyboard logout operable after a Czech failure and succeeds on retry .then(async () => await page.keyboard.press('Enter')) .then( async () => - await expect(page.getByRole('menuitem', { name: 'Odhlásit se' })).toHaveAttribute( - 'data-highlighted', - '', - ), + await expect(page.getByRole('menuitem', { name: 'Odhlásit se' })).toHaveAttribute('data-highlighted', ''), ) .then(async () => await page.getByRole('menuitem', { name: 'Odhlásit se' }).click()) .then( @@ -481,10 +450,7 @@ test('keeps keyboard logout operable after a Czech failure and succeeds on retry .then(async () => await page.keyboard.press('Enter')) .then( async () => - await expect(page.getByRole('menuitem', { name: 'Odhlásit se' })).toHaveAttribute( - 'data-highlighted', - '', - ), + await expect(page.getByRole('menuitem', { name: 'Odhlásit se' })).toHaveAttribute('data-highlighted', ''), ) .then(async () => await page.getByRole('menuitem', { name: 'Odhlásit se' }).click()) .then(async () => await expect(page).toHaveURL(/\/cs\/login\/?$/u)); @@ -494,11 +460,15 @@ test('keeps the login form keyboard- and mobile-usable', async ({ page }) => { await page.setViewportSize({ height: 667, width: 375 }); await gotoHydratedLogin(page, 'cs'); const form = hydratedLoginForm(page); - const loginInput = form.getByRole('textbox', { name: /^Přihlašovací jméno\s*\*$/u }); + const loginInput = form.getByRole('textbox', { + name: /^Přihlašovací jméno\s*\*$/u, + }); await expect(async () => { await loginInput.fill('hydration-probe'); await form.getByRole('button', { name: 'Přihlásit se' }).click(); - await expect(page.getByText('Zadejte heslo.')).toBeInViewport({ timeout: 1000 }); + await expect(page.getByText('Zadejte heslo.')).toBeInViewport({ + timeout: 1000, + }); }).toPass({ timeout: 5000 }); await loginInput.clear(); await loginInput.focus(); @@ -529,14 +499,14 @@ test('keeps the authenticated dashboard reachable without horizontal overflow at const tenant = page.getByRole('combobox', { name: 'Current tenant' }); await expect(tenant).toBeInViewport(); await tenant.click(); - const secondTenant = page.getByRole('option', { name: authentication.tenants.second.name }); + const secondTenant = page.getByRole('option', { + name: authentication.tenants.second.name, + }); await expect(secondTenant).toBeInViewport(); await secondTenant.click(); await expect(page.getByText('Tenant switching failed. Try again.')).toBeInViewport(); await expect(tenant).toContainText(authentication.tenants.first.name); - expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, - ), - ).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe( + true, + ); }); diff --git a/app/apps/shell-super-app/tests/e2e/worker-fixture-lifetime.spec.ts b/app/apps/shell-super-app/tests/e2e/worker-fixture-lifetime.spec.ts index 9b15b4166..0d679493b 100644 --- a/app/apps/shell-super-app/tests/e2e/worker-fixture-lifetime.spec.ts +++ b/app/apps/shell-super-app/tests/e2e/worker-fixture-lifetime.spec.ts @@ -1,5 +1,5 @@ -import { Cause, Effect } from 'effect'; import { expect, test as base } from '@playwright/test'; +import { Cause, Effect } from 'effect'; // Playwright must not abandon setup before Effect closes its scope. The stalled // acquisition must finalize before reporting its typed timeout and must never complete. @@ -34,8 +34,6 @@ const test = base.extend, { stalledAcquisition: readonly st ], }); -test('finishes installed finalizers before reporting a stalled acquisition', ({ - stalledAcquisition, -}) => { +test('finishes installed finalizers before reporting a stalled acquisition', ({ stalledAcquisition }) => { expect(stalledAcquisition).toEqual(['finalizer', 'reported timeout']); }); diff --git a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts index 4fea3405f..30850f4ef 100644 --- a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts @@ -1,15 +1,8 @@ -import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; -import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; -import { Effect, Layer, Predicate, Schema } from 'effect'; import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { and, eq, inArray, sql } from 'drizzle-orm'; -import { AuthDatabase, makeAuthDatabase } from '../../api/auth/db/client.ts'; -import { exportJWK, generateKeyPair, jwtVerify } from 'jose'; -import { Pool } from 'pg'; + import { PrincipalResolver, PrincipalResolverUnavailableError, @@ -24,6 +17,12 @@ import { makeTenantModuleStateService, } from '@app/core-runtime'; import type { InstalledModuleCatalog } from '@app/core-runtime'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { Effect, Layer, Predicate, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { exportJWK, generateKeyPair, jwtVerify } from 'jose'; +import { Pool } from 'pg'; + import { actionInvocations, auditEvents, @@ -35,18 +34,19 @@ import { tenantModuleStates, tenants, } from '../../../../packages/core-runtime/src/db/schema.ts'; +import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; +import { renderActionPrincipalServer } from '../../../../scripts/scaffolding/microvertical-action-boundary/scaffold.mts'; import { AuthConfig, loadAuthConfig } from '../../api/auth/config.ts'; +import { AuthDatabase, makeAuthDatabase } from '../../api/auth/db/client.ts'; +import { account, authRelations, session, user } from '../../api/auth/db/schema.ts'; import { parseGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; import { makeGatewayIssuerLayer } from '../../api/auth/gateway-issuer.ts'; import type { GatewayIssuerLayerOptions } from '../../api/auth/gateway-issuer.ts'; -import { account, authRelations, session, user } from '../../api/auth/db/schema.ts'; import { AuthenticationService, makeAuthenticationService } from '../../api/auth/service.ts'; import { makeShellAuthenticationApiRuntime } from '../../api/index.ts'; -import { renderActionPrincipalServer } from '../../../../scripts/scaffolding/microvertical-action-boundary/scaffold.mts'; -type AuthenticationRuntimeHandler = ReturnType< - ReturnType['createHandler'] ->; +type AuthenticationRuntimeHandler = ReturnType['createHandler']>; const email = 'better-auth-runtime@example.test'; const password = 'correct-horse-battery-staple'; const tenantId = '30000000-0000-4000-8000-000000000001'; @@ -57,15 +57,22 @@ const fixtureLegalEntityId = '35000000-0000-4000-8000-000000000001'; const fixtureAuthBindingId = '45000000-0000-4000-8000-000000000001'; const PrincipalIdSchema = Schema.String.pipe(Schema.brand('PrincipalId')); const IdentityResponseSchema = Schema.Struct({ - identity: Schema.Struct({ email: Schema.String, principalId: PrincipalIdSchema }), + identity: Schema.Struct({ + email: Schema.String, + principalId: PrincipalIdSchema, + }), }); const ProblemStatusSchema = Schema.Struct({ status: Schema.Number }); const SessionResponseSchema = Schema.Struct({ identity: Schema.optional(Schema.Struct({ principalId: Schema.optional(PrincipalIdSchema) })), }); -const RetryableProblemSchema = Schema.Struct({ retryable: Schema.optional(Schema.Boolean) }); +const RetryableProblemSchema = Schema.Struct({ + retryable: Schema.optional(Schema.Boolean), +}); const TokenResponseSchema = Schema.Struct({ token: Schema.String }); -const DefectProblemSchema = Schema.Struct({ detail: Schema.optional(Schema.String) }); +const DefectProblemSchema = Schema.Struct({ + detail: Schema.optional(Schema.String), +}); const legalEntitySelectionOptions = { contextAccess: { legalEntities: ({ legalEntityIds }: { readonly legalEntityIds: readonly string[] }) => @@ -78,7 +85,12 @@ const legalEntitySelectionOptions = { }, legalEntityContext: { listActiveForTenant: () => - Effect.succeed([{ legalEntityId: fixtureLegalEntityId, legalName: 'Fixture legal entity' }]), + Effect.succeed([ + { + legalEntityId: fixtureLegalEntityId, + legalName: 'Fixture legal entity', + }, + ]), validateSelection: (_tenantId: string, legalEntityId: string) => legalEntityId === fixtureLegalEntityId ? Effect.succeed({ legalEntityId, legalName: 'Fixture legal entity' }) @@ -118,9 +130,7 @@ const verifiedGatewayAssertion = Effect.fnUntraced(function* verifiedGatewayAsse }), ); return { - principal: yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)( - verified.payload['principal'], - ), + principal: yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)(verified.payload['principal']), token: assertion.token, }; }); @@ -146,7 +156,10 @@ const installedPageCatalog = (): InstalledModuleCatalog => buildInstalledModuleCatalog([ { contract: { - deployment: { appId: 'inventory-stock', buildMarker: 'integration-build' }, + deployment: { + appId: 'inventory-stock', + buildMarker: 'integration-build', + }, manifest: { activation: { defaultState: 'inactive', @@ -265,19 +278,14 @@ it.live( makeTenantModuleStateService({ executor: coreDatabase }), ); const handlers: AuthenticationRuntimeHandler[] = []; - const generatedFixtureRoot = yield* Effect.tryPromise(() => - mkdtemp(path.join(tmpdir(), 'ontos-auth-runtime-')), - ); + const generatedFixtureRoot = yield* Effect.tryPromise(() => mkdtemp(path.join(tmpdir(), 'ontos-auth-runtime-'))); const cleanup = Effect.fnUntraced(function* runIntegration2() { yield* purgeFixtureRows([ coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), ]); - const existingUsers = yield* authDatabase - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email)); + const existingUsers = yield* authDatabase.select({ id: user.id }).from(user).where(eq(user.email, email)); yield* Effect.all( existingUsers.map( Effect.fnUntraced(function* runIntegration3(existingUser) { @@ -293,17 +301,11 @@ it.live( ), ); yield* purgeFixtureRows([ - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.principalId, principalId)), + coreDatabase.delete(principalAuthBindings).where(eq(principalAuthBindings.principalId, principalId)), coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - coreDatabase - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, foreignTenantId)), + coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, foreignTenantId)), coreDatabase.delete(principals).where(eq(principals.principalId, principalId)), - coreDatabase - .delete(legalEntities) - .where(eq(legalEntities.legalEntityId, fixtureLegalEntityId)), + coreDatabase.delete(legalEntities).where(eq(legalEntities.legalEntityId, fixtureLegalEntityId)), coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), coreDatabase.delete(tenants).where(eq(tenants.tenantId, foreignTenantId)), ]); @@ -323,11 +325,7 @@ it.live( }, Effect.orDie), ); yield* cleanup(); - const betterAuthUserId = yield* authentication.createFixtureUser( - email, - 'Runtime fixture', - password, - ); + const betterAuthUserId = yield* authentication.createFixtureUser(email, 'Runtime fixture', password); yield* coreDatabase.insert(tenants).values({ defaultLocale: 'en', name: 'Authentication runtime tenant', @@ -376,9 +374,7 @@ it.live( const requestHeaders = new Headers({ origin: configuration.baseUrl, }); - const invalid = yield* Effect.flip( - authentication.signIn(email, 'wrong-password', requestHeaders), - ); + const invalid = yield* Effect.flip(authentication.signIn(email, 'wrong-password', requestHeaders)); expect(Predicate.isTagged(invalid, 'InvalidCredentialsError')).toBe(true); const anonymousRuntime = makeShellAuthenticationApiRuntime( authenticationLayer, @@ -399,7 +395,10 @@ it.live( unavailableHandler.handler( new Request(`${configuration.baseUrl}/auth/gateway-context`, { body: JSON.stringify({ audience: 'inventory-stock' }), - headers: { 'content-type': 'application/json', origin: configuration.baseUrl }, + headers: { + 'content-type': 'application/json', + origin: configuration.baseUrl, + }, method: 'POST', }), ), @@ -422,7 +421,10 @@ it.live( entrypointKey: 'testing.pages.page.customers', moduleId: 'testing.pages', }), - headers: { 'content-type': 'application/json', origin: configuration.baseUrl }, + headers: { + 'content-type': 'application/json', + origin: configuration.baseUrl, + }, method: 'POST', }), ), @@ -433,15 +435,16 @@ it.live( unavailableHandler.handler( new Request(`${configuration.baseUrl}/auth/sign-in`, { body: JSON.stringify({ email, password: 'wrong-password' }), - headers: { 'content-type': 'application/json', origin: configuration.baseUrl }, + headers: { + 'content-type': 'application/json', + origin: configuration.baseUrl, + }, method: 'POST', }), ), ); expect(invalidSignInResponse.status).toBe(401); - expect(headerValue(invalidSignInResponse.headers, 'content-type')).toMatch( - /^application\/problem\+json/u, - ); + expect(headerValue(invalidSignInResponse.headers, 'content-type')).toMatch(/^application\/problem\+json/u); const invalidSignInProblem = Schema.decodeUnknownSync(ProblemStatusSchema)( yield* Effect.tryPromise(() => invalidSignInResponse.json()), ); @@ -450,7 +453,10 @@ it.live( unavailableHandler.handler( new Request(`${configuration.baseUrl}/auth/sign-in`, { body: JSON.stringify({ email, password }), - headers: { 'content-type': 'application/json', origin: configuration.baseUrl }, + headers: { + 'content-type': 'application/json', + origin: configuration.baseUrl, + }, method: 'POST', }), ), @@ -496,9 +502,7 @@ it.live( contextAccessLayer, ).createHandler(); handlers.push(pageRuntime); - const exactPageResponse = yield* Effect.tryPromise(() => - pageRuntime.handler(exactPageRequest()), - ); + const exactPageResponse = yield* Effect.tryPromise(() => pageRuntime.handler(exactPageRequest())); expect(exactPageResponse.status).toBe(200); expect(yield* Effect.tryPromise(() => exactPageResponse.json())).toEqual({ appId: 'inventory-stock', @@ -574,14 +578,10 @@ it.live( }), ).createHandler(); handlers.push(deniedPageRuntime); - const deniedPageResponse = yield* Effect.tryPromise(() => - deniedPageRuntime.handler(exactPageRequest()), - ); + const deniedPageResponse = yield* Effect.tryPromise(() => deniedPageRuntime.handler(exactPageRequest())); expect(deniedPageResponse.status).toBe(403); expect( - Schema.decodeUnknownSync(ProblemStatusSchema)( - yield* Effect.tryPromise(() => deniedPageResponse.json()), - ).status, + Schema.decodeUnknownSync(ProblemStatusSchema)(yield* Effect.tryPromise(() => deniedPageResponse.json())).status, ).toBe(403); const currentSessionResponse = yield* Effect.tryPromise(() => unavailableHandler.handler( @@ -592,9 +592,8 @@ it.live( ); expect(currentSessionResponse.status).toBe(200); expect( - Schema.decodeUnknownSync(SessionResponseSchema)( - yield* Effect.tryPromise(() => currentSessionResponse.json()), - ).identity?.principalId, + Schema.decodeUnknownSync(SessionResponseSchema)(yield* Effect.tryPromise(() => currentSessionResponse.json())) + .identity?.principalId, ).toBe(principalId); const missingIdempotencyResponse = yield* Effect.tryPromise(() => unavailableHandler.handler( @@ -640,8 +639,7 @@ it.live( }) => Effect.succeed( tenantIds.map((key) => ({ - decision: - permission === 'manage_identity' ? ('denied' as const) : ('allowed' as const), + decision: permission === 'manage_identity' ? ('denied' as const) : ('allowed' as const), key, })), ), @@ -651,7 +649,10 @@ it.live( const deniedIdentityAdministrationResponse = yield* Effect.tryPromise(() => deniedIdentityAdministrationRuntime.handler( new Request(`${configuration.baseUrl}/auth/identity/principals`, { - body: JSON.stringify({ displayName: 'Denied managed identity', kind: 'service' }), + body: JSON.stringify({ + displayName: 'Denied managed identity', + kind: 'service', + }), headers: { 'content-type': 'application/json', cookie: headerValue(authenticatedHeaders, 'cookie'), @@ -779,9 +780,7 @@ it.live( ); expect(refreshedModulesResponse.status).toBe(200); expect( - refreshedModulesResponse.headers - .getSetCookie() - .some((header) => header.startsWith('refreshed-session=value')), + refreshedModulesResponse.headers.getSetCookie().some((header) => header.startsWith('refreshed-session=value')), ).toBe(true); const unavailableModuleStates = { getTenantModuleStates: () => @@ -830,18 +829,15 @@ it.live( ), ); expect(unavailableModulesResponse.status).toBe(503); - const unavailableModulesProblem = yield* Effect.tryPromise(() => - unavailableModulesResponse.text(), - ); + const unavailableModulesProblem = yield* Effect.tryPromise(() => unavailableModulesResponse.text()); expect(unavailableModulesProblem).not.toMatch(/SQL|30000000|40000000/u); const unavailablePageResponse = yield* Effect.tryPromise(() => unavailableModulesHandler.handler(exactPageRequest()), ); expect(unavailablePageResponse.status).toBe(503); expect( - Schema.decodeUnknownSync(ProblemStatusSchema)( - yield* Effect.tryPromise(() => unavailablePageResponse.json()), - ).status, + Schema.decodeUnknownSync(ProblemStatusSchema)(yield* Effect.tryPromise(() => unavailablePageResponse.json())) + .status, ).toBe(503); const unavailableGatewayResponse = yield* Effect.tryPromise(() => unavailableHandler.handler( @@ -857,17 +853,13 @@ it.live( ), ); expect(unavailableGatewayResponse.status).toBe(503); - expect(headerValue(unavailableGatewayResponse.headers, 'content-type')).toMatch( - /application\/problem\+json/u, - ); + expect(headerValue(unavailableGatewayResponse.headers, 'content-type')).toMatch(/application\/problem\+json/u); expect( Schema.decodeUnknownSync(RetryableProblemSchema)( yield* Effect.tryPromise(() => unavailableGatewayResponse.json()), ).retryable, ).toBe(true); - const pair = yield* Effect.tryPromise(() => - generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }), - ); + const pair = yield* Effect.tryPromise(() => generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true })); const privateJwk = yield* Effect.tryPromise(() => exportJWK(pair.privateKey)); const publicJwk = yield* Effect.tryPromise(() => exportJWK(pair.publicKey)); const issuerDependencies: GatewayIssuerLayerOptions = { @@ -909,10 +901,7 @@ it.live( }), ), ); - expect( - assertionResponse.status, - yield* Effect.tryPromise(() => assertionResponse.clone().text()), - ).toBe(200); + expect(assertionResponse.status, yield* Effect.tryPromise(() => assertionResponse.clone().text())).toBe(200); const { principal: verifiedPrincipal, token: assertionToken } = yield* verifiedGatewayAssertion( assertionResponse, pair.publicKey, @@ -924,7 +913,9 @@ it.live( expect(verifiedPrincipal.principalId).toBe(principalId); expect(verifiedPrincipal.tenantId).toBe(tenantId); yield* Effect.tryPromise(() => - mkdir(path.join(generatedFixtureRoot, 'node_modules', '@app'), { recursive: true }), + mkdir(path.join(generatedFixtureRoot, 'node_modules', '@app'), { + recursive: true, + }), ); yield* Effect.tryPromise(() => symlink( @@ -948,11 +939,7 @@ it.live( ), ); yield* Effect.tryPromise(() => - symlink( - path.join(appRoot, 'node_modules/effect'), - path.join(generatedFixtureRoot, 'node_modules/effect'), - 'dir', - ), + symlink(path.join(appRoot, 'node_modules/effect'), path.join(generatedFixtureRoot, 'node_modules/effect'), 'dir'), ); yield* Effect.tryPromise(() => symlink( @@ -963,15 +950,9 @@ it.live( ); const generatedVerifierPath = path.join(generatedFixtureRoot, 'action-principal.ts'); yield* Effect.tryPromise(() => - writeFile( - generatedVerifierPath, - renderActionPrincipalServer({ appId: 'inventory-stock' }), - 'utf-8', - ), - ); - const generatedVerifier = yield* Effect.tryPromise( - () => import(pathToFileURL(generatedVerifierPath).href), + writeFile(generatedVerifierPath, renderActionPrincipalServer({ appId: 'inventory-stock' }), 'utf-8'), ); + const generatedVerifier = yield* Effect.tryPromise(() => import(pathToFileURL(generatedVerifierPath).href)); type GeneratedVerifier = ( authorization: string, options: { @@ -983,9 +964,7 @@ it.live( }, ) => Effect.Effect; const verifyActionPrincipal = Schema.decodeUnknownSync( - Schema.declare((value): value is GeneratedVerifier => - Predicate.isFunction(value), - ), + Schema.declare((value): value is GeneratedVerifier => Predicate.isFunction(value)), )(generatedVerifier.verifyActionPrincipal); expect(Predicate.isFunction(verifyActionPrincipal)).toBe(true); const generatedPrincipal = Schema.decodeUnknownSync(TrustedPrincipalContextSchema)( @@ -1054,9 +1033,7 @@ it.live( ), ); expect(defectResponse.status).toBe(500); - expect(headerValue(defectResponse.headers, 'content-type')).toMatch( - /application\/problem\+json/u, - ); + expect(headerValue(defectResponse.headers, 'content-type')).toMatch(/application\/problem\+json/u); const defectProblem = Schema.decodeUnknownSync(DefectProblemSchema)( yield* Effect.tryPromise(() => defectResponse.json()), ); @@ -1068,12 +1045,13 @@ it.live( assertOptionalField(stillAuthenticated.identity, 'principalId', principalId); yield* coreDatabase .update(principalAuthBindings) - .set({ revokedAt: new Date('2026-09-01T00:00:00.000Z'), status: 'revoked' }) + .set({ + revokedAt: new Date('2026-09-01T00:00:00.000Z'), + status: 'revoked', + }) .where(eq(principalAuthBindings.providerSubjectId, betterAuthUserId)); yield* assertSessionForbidden( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(authenticationContextLayer)), + authentication.currentSession(authenticatedHeaders).pipe(Effect.provide(authenticationContextLayer)), ); const forbiddenModulesResponse = yield* Effect.tryPromise(() => unavailableHandler.handler( @@ -1084,9 +1062,7 @@ it.live( ); expect(forbiddenModulesResponse.status).toBe(401); expect(headerValue(forbiddenModulesResponse.headers, 'www-authenticate')).toMatch(/^Bearer/u); - expect(yield* Effect.tryPromise(() => forbiddenModulesResponse.text())).not.toMatch( - /30000000|40000000/u, - ); + expect(yield* Effect.tryPromise(() => forbiddenModulesResponse.text())).not.toMatch(/30000000|40000000/u); yield* coreDatabase .update(principalAuthBindings) .set({ revokedAt: null, status: 'active' }) @@ -1120,9 +1096,7 @@ it.live( ), ); expect(expiredModulesResponse.status).toBe(401); - expect(yield* Effect.tryPromise(() => expiredModulesResponse.text())).not.toMatch( - /30000000|40000000/u, - ); + expect(yield* Effect.tryPromise(() => expiredModulesResponse.text())).not.toMatch(/30000000|40000000/u); }), ); it.live( @@ -1139,7 +1113,13 @@ it.live( const secondAuthBindingId = '46000000-0000-4000-8000-000000000002'; const legalEntityByTenant = new Map([ [firstTenantId, { legalEntityId: firstLegalEntityId, legalName: 'First legal entity' }], - [secondTenantId, { legalEntityId: secondLegalEntityId, legalName: 'Second legal entity' }], + [ + secondTenantId, + { + legalEntityId: secondLegalEntityId, + legalName: 'Second legal entity', + }, + ], ]); const multiLegalEntitySelectionOptions = { contextAccess: legalEntitySelectionOptions.contextAccess, @@ -1156,10 +1136,7 @@ it.live( }, }, } as const; - const multiContextAccessLayer = Layer.succeed( - ContextAccess, - multiLegalEntitySelectionOptions.contextAccess, - ); + const multiContextAccessLayer = Layer.succeed(ContextAccess, multiLegalEntitySelectionOptions.contextAccess); const multiAuthenticationContextLayer = Layer.mergeAll( multiContextAccessLayer, Layer.succeed(LegalEntityContext, multiLegalEntitySelectionOptions.legalEntityContext), @@ -1199,13 +1176,8 @@ it.live( const fixtureTenants = [firstTenantId, secondTenantId]; // Ordered child-before-parent within the owned fixture rows. const cleanup = Effect.fnUntraced(function* runIntegration7() { - yield* coreDatabase - .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.tenantId, fixtureTenants)); - const existingUsers = yield* authDatabase - .select({ id: user.id }) - .from(user) - .where(eq(user.email, multiEmail)); + yield* coreDatabase.delete(dataAccessEvents).where(inArray(dataAccessEvents.tenantId, fixtureTenants)); + const existingUsers = yield* authDatabase.select({ id: user.id }).from(user).where(eq(user.email, multiEmail)); const existingUserIds = existingUsers.map(({ id }) => id); if (existingUserIds.length > 0) { yield* purgeFixtureRows([ @@ -1218,12 +1190,8 @@ it.live( ]); } yield* purgeFixtureRows([ - coreDatabase - .delete(tenantModuleStates) - .where(inArray(tenantModuleStates.tenantId, fixtureTenants)), - coreDatabase - .delete(principals) - .where(inArray(principals.principalId, [firstPrincipalId, secondPrincipalId])), + coreDatabase.delete(tenantModuleStates).where(inArray(tenantModuleStates.tenantId, fixtureTenants)), + coreDatabase.delete(principals).where(inArray(principals.principalId, [firstPrincipalId, secondPrincipalId])), coreDatabase .delete(legalEntities) .where(inArray(legalEntities.legalEntityId, [firstLegalEntityId, secondLegalEntityId])), @@ -1244,11 +1212,7 @@ it.live( }, Effect.orDie), ); yield* cleanup(); - const betterAuthUserId = yield* authentication.createFixtureUser( - multiEmail, - 'Multi tenant fixture', - password, - ); + const betterAuthUserId = yield* authentication.createFixtureUser(multiEmail, 'Multi tenant fixture', password); yield* coreDatabase.insert(tenants).values([ { defaultLocale: 'en', @@ -1325,11 +1289,7 @@ it.live( { moduleKey: 'first-module', state: 'active', tenantId: firstTenantId }, { moduleKey: 'second-module', state: 'active', tenantId: secondTenantId }, ]); - const signIn = yield* authentication.signIn( - multiEmail, - password, - new Headers({ origin: configuration.baseUrl }), - ); + const signIn = yield* authentication.signIn(multiEmail, password, new Headers({ origin: configuration.baseUrl })); expect(signIn.identity.tenantId).toBe(firstTenantId); expect(signIn.identity.principalId).toBe(firstPrincipalId); const authenticatedCookie = cookieHeader(signIn.setCookieHeaders); @@ -1357,9 +1317,7 @@ it.live( const initialSessions = yield* readActiveTenantIds(); assertOptionalField(initialSessions[0], 'activeTenantId', firstTenantId); - const pair = yield* Effect.tryPromise(() => - generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }), - ); + const pair = yield* Effect.tryPromise(() => generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true })); const privateJwk = yield* Effect.tryPromise(() => exportJWK(pair.privateKey)); const runtime = makeShellAuthenticationApiRuntime( Layer.succeed(AuthenticationService, authentication), @@ -1394,12 +1352,12 @@ it.live( ), ); expect(anonymousAvailableResponse.status).toBe(401); - expect(headerValue(anonymousAvailableResponse.headers, 'www-authenticate')).toMatch( - /^Bearer /u, - ); + expect(headerValue(anonymousAvailableResponse.headers, 'www-authenticate')).toMatch(/^Bearer /u); const availableResponse = yield* Effect.tryPromise(() => runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenants`, { headers: authenticatedHeaders }), + new Request(`${configuration.baseUrl}/auth/tenants`, { + headers: authenticatedHeaders, + }), ), ); expect(availableResponse.status).toBe(200); @@ -1412,9 +1370,7 @@ it.live( const availableTenants = yield* authentication .availableTenants(authenticatedHeaders) .pipe(Effect.provide(multiAuthenticationContextLayer)); - expect(JSON.stringify(availableTenants)).not.toMatch( - /principalId|sessionId|token|bindingId|password/u, - ); + expect(JSON.stringify(availableTenants)).not.toMatch(/principalId|sessionId|token|bindingId|password/u); const firstModules = yield* Effect.tryPromise(() => runtime.handler( new Request(`${configuration.baseUrl}/shell/composition`, { @@ -1437,9 +1393,7 @@ it.live( .update(principals) .set({ status: 'disabled' }) .where(eq(principals.principalId, secondPrincipalId)); - const inactiveTargetResponse = yield* Effect.tryPromise(() => - runtime.handler(tenantSwitchRequest(secondTenantId)), - ); + const inactiveTargetResponse = yield* Effect.tryPromise(() => runtime.handler(tenantSwitchRequest(secondTenantId))); expect(inactiveTargetResponse.status).toBe(403); const sessionsAfterInactiveSwitch = yield* readActiveTenantIds(); assertOptionalField(sessionsAfterInactiveSwitch[0], 'activeTenantId', firstTenantId); @@ -1455,7 +1409,9 @@ it.live( resolveBetterAuthUserForTenant: (userId, selectedTenantId) => selectedTenantId === secondTenantId ? Effect.fail( - new PrincipalResolverUnavailableError({ reason: 'Injected resolver outage' }), + new PrincipalResolverUnavailableError({ + reason: 'Injected resolver outage', + }), ) : resolver.resolveBetterAuthUserForTenant(userId, selectedTenantId), }), @@ -1532,9 +1488,7 @@ it.live( .from(session) .where(eq(session.userId, betterAuthUserId)); assertOptionalField(sessionsBeforeSwitch[0], 'activeLegalEntityId', firstLegalEntityId); - const switchResponse = yield* Effect.tryPromise(() => - runtime.handler(tenantSwitchRequest(secondTenantId)), - ); + const switchResponse = yield* Effect.tryPromise(() => runtime.handler(tenantSwitchRequest(secondTenantId))); expect(switchResponse.status).toBe(200); expect(yield* Effect.tryPromise(() => switchResponse.json())).toEqual({ selectedTenantId: secondTenantId, @@ -1581,10 +1535,7 @@ it.live( }), ), ); - const { principal: verifiedPrincipal } = yield* verifiedGatewayAssertion( - assertionResponse, - pair.publicKey, - ); + const { principal: verifiedPrincipal } = yield* verifiedGatewayAssertion(assertionResponse, pair.publicKey); expect(verifiedPrincipal.authBindingId).toBe(secondAuthBindingId); expect(optionalText(verifiedPrincipal.authContextRef)).toMatch(/^better-auth-session:/u); expect(verifiedPrincipal.authMethod).toBe('session'); @@ -1645,15 +1596,8 @@ it.live( /secret auth persistence defect|P0001/u, ); const sessionsAfterUnexpectedSwitchFailure = yield* readActiveTenantIds(); - assertOptionalField( - sessionsAfterUnexpectedSwitchFailure[0], - 'activeTenantId', - secondTenantId, - ); - yield* authDatabase - .update(session) - .set({ activeTenantId: null }) - .where(eq(session.userId, betterAuthUserId)); + assertOptionalField(sessionsAfterUnexpectedSwitchFailure[0], 'activeTenantId', secondTenantId); + yield* authDatabase.update(session).set({ activeTenantId: null }).where(eq(session.userId, betterAuthUserId)); const unexpectedLegacyUpgradeResponse = yield* Effect.tryPromise(() => runtime.handler( new Request(`${configuration.baseUrl}/auth/session`, { @@ -1684,12 +1628,13 @@ it.live( .pipe(Effect.provide(multiAuthenticationContextLayer)); yield* coreDatabase .update(principalAuthBindings) - .set({ revokedAt: new Date('2026-09-01T00:00:00.000Z'), status: 'revoked' }) + .set({ + revokedAt: new Date('2026-09-01T00:00:00.000Z'), + status: 'revoked', + }) .where(eq(principalAuthBindings.tenantId, secondTenantId)); yield* assertSessionForbidden( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(multiAuthenticationContextLayer)), + authentication.currentSession(authenticatedHeaders).pipe(Effect.provide(multiAuthenticationContextLayer)), ); yield* coreDatabase .update(principalAuthBindings) @@ -1703,14 +1648,10 @@ it.live( // resolver can still prove that an existing selected session rejects a genuinely missing row. yield* purgeFixtureRows([ coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, secondTenantId)), - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, secondTenantId)), + coreDatabase.delete(principalAuthBindings).where(eq(principalAuthBindings.tenantId, secondTenantId)), ]); yield* assertSessionForbidden( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(multiAuthenticationContextLayer)), + authentication.currentSession(authenticatedHeaders).pipe(Effect.provide(multiAuthenticationContextLayer)), ); }), ); diff --git a/app/apps/shell-super-app/tests/integration/generated-owner-fixture.ts b/app/apps/shell-super-app/tests/integration/generated-owner-fixture.ts index fb5da75cf..54cf9eb59 100644 --- a/app/apps/shell-super-app/tests/integration/generated-owner-fixture.ts +++ b/app/apps/shell-super-app/tests/integration/generated-owner-fixture.ts @@ -1,6 +1,8 @@ -import { Effect, FileSystem } from 'effect'; import { tmpdir } from 'node:os'; import path from 'node:path'; + +import { Effect, FileSystem } from 'effect'; + import { runScaffoldEffect } from '../../../../scripts/scaffolding/cli.mts'; import { MODULE_MANIFEST_RESOURCE_SLOT_END, @@ -25,7 +27,9 @@ const writeFixtureFile = Effect.fn('writeFixtureFile')(function* writeFixtureFil ) { const fileSystem = yield* FileSystem.FileSystem; const filePath = path.join(root, relativePath); - yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(filePath), { + recursive: true, + }); yield* fileSystem.writeFileString(filePath, content); }); @@ -37,11 +41,7 @@ const replaceRequired = (source: string, current: string, replacement: string): }; const createWorkspace = Effect.fn('createWorkspace')(function* createWorkspaceEffect(root: string) { - yield* writeFixtureFile( - root, - 'package.json', - json({ name: 'generated-owner-fixture', private: true }), - ); + yield* writeFixtureFile(root, 'package.json', json({ name: 'generated-owner-fixture', private: true })); yield* writeFixtureFile( root, `verticals/${GENERATED_OWNER.slug}/module-federation.config.ts`, @@ -50,7 +50,10 @@ const createWorkspace = Effect.fn('createWorkspace')(function* createWorkspaceEf yield* writeFixtureFile( root, `verticals/${GENERATED_OWNER.slug}/tsconfig.json`, - json({ compilerOptions: { composite: true }, include: ['api', 'shared', 'src'] }), + json({ + compilerOptions: { composite: true }, + include: ['api', 'shared', 'src'], + }), ); yield* writeFixtureFile( root, @@ -110,7 +113,10 @@ export default defineEffectBff({ api: isolationOwnerApi, layer }); domain: 'isolation', id: GENERATED_OWNER.appId, kind: 'vertical', - moduleFederation: { name: 'verticalIsolationOwner', role: 'remote' }, + moduleFederation: { + name: 'verticalIsolationOwner', + role: 'remote', + }, package: '@app/isolation-owner', path: `verticals/${GENERATED_OWNER.slug}`, }, @@ -119,48 +125,47 @@ export default defineEffectBff({ api: isolationOwnerApi, layer }); ); }); -const linkRuntimeDependencies = Effect.fn('linkRuntimeDependencies')( - function* linkRuntimeDependenciesEffect(root: string) { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(path.join(root, 'node_modules', '@app'), { recursive: true }); - yield* fileSystem.makeDirectory(path.join(root, 'node_modules', '@modern-js'), { - recursive: true, - }); - yield* Effect.all( - [ - fileSystem.symlink( - path.join(appRoot, 'packages/core-runtime'), - path.join(root, 'node_modules/@app/core-runtime'), - ), - fileSystem.symlink( - path.join(appRoot, 'packages/shared-contracts'), - path.join(root, 'node_modules/@app/shared-contracts'), - ), - fileSystem.symlink( - path.join(appRoot, 'packages/gateway-principal-verifier'), - path.join(root, 'node_modules/@app/gateway-principal-verifier'), - ), - fileSystem.symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/@modern-js/plugin-bff'), - path.join(root, 'node_modules/@modern-js/plugin-bff'), - ), - fileSystem.symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/drizzle-orm'), - path.join(root, 'node_modules/drizzle-orm'), - ), - fileSystem.symlink( - path.join(appRoot, 'node_modules/effect'), - path.join(root, 'node_modules/effect'), - ), - fileSystem.symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/jose'), - path.join(root, 'node_modules/jose'), - ), - ], - { concurrency: 'unbounded', discard: true }, - ); - }, -); +const linkRuntimeDependencies = Effect.fn('linkRuntimeDependencies')(function* linkRuntimeDependenciesEffect( + root: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(path.join(root, 'node_modules', '@app'), { + recursive: true, + }); + yield* fileSystem.makeDirectory(path.join(root, 'node_modules', '@modern-js'), { + recursive: true, + }); + yield* Effect.all( + [ + fileSystem.symlink( + path.join(appRoot, 'packages/core-runtime'), + path.join(root, 'node_modules/@app/core-runtime'), + ), + fileSystem.symlink( + path.join(appRoot, 'packages/shared-contracts'), + path.join(root, 'node_modules/@app/shared-contracts'), + ), + fileSystem.symlink( + path.join(appRoot, 'packages/gateway-principal-verifier'), + path.join(root, 'node_modules/@app/gateway-principal-verifier'), + ), + fileSystem.symlink( + path.join(appRoot, 'apps/shell-super-app/node_modules/@modern-js/plugin-bff'), + path.join(root, 'node_modules/@modern-js/plugin-bff'), + ), + fileSystem.symlink( + path.join(appRoot, 'apps/shell-super-app/node_modules/drizzle-orm'), + path.join(root, 'node_modules/drizzle-orm'), + ), + fileSystem.symlink(path.join(appRoot, 'node_modules/effect'), path.join(root, 'node_modules/effect')), + fileSystem.symlink( + path.join(appRoot, 'apps/shell-super-app/node_modules/jose'), + path.join(root, 'node_modules/jose'), + ), + ], + { concurrency: 'unbounded', discard: true }, + ); +}); const addResourceType = Effect.fn('addResourceType')(function* addResourceTypeEffect(root: string) { const fileSystem = yield* FileSystem.FileSystem; @@ -543,16 +548,8 @@ const adaptGeneratedOwner = Effect.fn('adaptGeneratedOwner')(function* adaptGene ); yield* Effect.all( [ - writeFixtureFile( - root, - `${verticalRoot}/src/isolation/instrumentation.ts`, - instrumentationSource, - ), - writeFixtureFile( - root, - `${verticalRoot}/src/isolation/owner-repository.ts`, - ownerRepositorySource(schemaName), - ), + writeFixtureFile(root, `${verticalRoot}/src/isolation/instrumentation.ts`, instrumentationSource), + writeFixtureFile(root, `${verticalRoot}/src/isolation/owner-repository.ts`, ownerRepositorySource(schemaName)), writeFixtureFile(root, `${verticalRoot}/src/api/resource-detail.read.ts`, detailReadSource), writeFixtureFile(root, `${verticalRoot}/src/api/resource-list.read.ts`, listReadSource), writeFixtureFile(root, `${verticalRoot}/src/search/records.provider.ts`, searchReadSource), diff --git a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts index 57dc30494..666ba4da8 100644 --- a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts +++ b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts @@ -1,27 +1,7 @@ -import { makeContextAccessDouble } from '../support/context-access-double.ts'; -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; -import { NodeServices } from '@effect/platform-node'; -import { - makeFaultInjectableCoreDatabase, - TestQueryHook, -} from '../../../../packages/core-runtime/tests/support/database-faults.ts'; -import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; -import { - Clock, - Config, - ConfigProvider, - Effect, - Layer, - Logger, - Predicate, - Redacted, - Schema, -} from 'effect'; import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; -import { v1 } from '@authzed/authzed-node'; + import { ContextAccess, GatewayAssertionRedemptionService, @@ -42,12 +22,18 @@ import type { TrustedPrincipalContext, VerticalRuntimeRegistration, } from '@app/core-runtime'; +import { v1 } from '@authzed/authzed-node'; +import { NodeServices } from '@effect/platform-node'; import { defineEffectBff, HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; import type { EffectRuntimeLayer } from '@modern-js/plugin-bff/effect-edge'; +import { Clock, Config, ConfigProvider, Effect, Layer, Logger, Predicate, Redacted, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; import { HttpApi } from 'effect/unstable/httpapi'; +import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; import { exportJWK, generateKeyPair } from 'jose'; import { Pool } from 'pg'; -import { GatewayPrincipalVerifierLive } from '../../../../packages/gateway-principal-verifier/src/server.ts'; + import { makeActionRepository } from '../../../../packages/core-runtime/src/actions/repository.ts'; import { makeActionRuntime } from '../../../../packages/core-runtime/src/actions/runtime.ts'; import { loadDatabaseConnectionPair } from '../../../../packages/core-runtime/src/db/config.ts'; @@ -73,18 +59,17 @@ import { toSpiceDbActionObjectId, } from '../../../../packages/core-runtime/src/permissions/service.ts'; import { makeReadRuntime } from '../../../../packages/core-runtime/src/reads/runtime.ts'; -import { deriveOntosModuleDeploymentContract } from '../../../../scripts/generate-ontos-module-contract.mts'; import { - issueGatewayContextAssertion, - makeGatewayIssuerLayer, -} from '../../api/auth/gateway-issuer.ts'; + makeFaultInjectableCoreDatabase, + TestQueryHook, +} from '../../../../packages/core-runtime/tests/support/database-faults.ts'; +import { GatewayPrincipalVerifierLive } from '../../../../packages/gateway-principal-verifier/src/server.ts'; +import { deriveOntosModuleDeploymentContract } from '../../../../scripts/generate-ontos-module-contract.mts'; import type { GatewayIssuerConfigValue } from '../../api/auth/gateway-issuer-config.ts'; -import { - ShellGovernedReads, - createShellGovernedReadsLayer, -} from '../../api/modules/shell-governed-reads.ts'; +import { issueGatewayContextAssertion, makeGatewayIssuerLayer } from '../../api/auth/gateway-issuer.ts'; import { ShellInstalledModuleCatalog } from '../../api/modules/installed-module-catalog.ts'; import { ShellCompositionFactoryLive } from '../../api/modules/shell-composition.ts'; +import { ShellGovernedReads, createShellGovernedReadsLayer } from '../../api/modules/shell-governed-reads.ts'; import { ResourceRefSchema, ShellProviderUnavailableError, @@ -92,14 +77,10 @@ import { makeShellSearch, } from '../../api/modules/shell-resources.ts'; import type { ShellResourceGateways } from '../../api/modules/shell-resources.ts'; +import { makeContextAccessDouble } from '../support/context-access-double.ts'; import { GENERATED_OWNER, createGeneratedOwnerFixture } from './generated-owner-fixture.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -126,8 +107,7 @@ type OwnerHttpHandler = ReturnType['createHan const disposeOwnerHandlers = (handlers: readonly OwnerHttpHandler[]) => Effect.forEach( handlers, - (handler) => - Effect.tryPromise(() => handler.dispose()).pipe(Effect.catchCause(() => Effect.void)), + (handler) => Effect.tryPromise(() => handler.dispose()).pipe(Effect.catchCause(() => Effect.void)), { concurrency: 'unbounded', discard: true }, ); const OwnerDetailSchema = Schema.Struct({ @@ -144,9 +124,7 @@ const OwnerTimelineSchema = Schema.Struct({ ), projectionLagging: Schema.Boolean, }); -const OwnerSearchSchema = Schema.Array( - Schema.Struct({ ref: ResourceRefSchema, title: Schema.String }), -); +const OwnerSearchSchema = Schema.Array(Schema.Struct({ ref: ResourceRefSchema, title: Schema.String })); interface GeneratedOwnerModules { // Generated source is imported from a temporary path, so TypeScript cannot retain the private // Action-registration symbols across the dynamic module boundary. Runtime checks below prove it. @@ -191,8 +169,8 @@ const OwnerCountsSchema = Schema.Struct({ const OwnerVerifierSchema = Schema.declare( (value): value is GeneratedOwnerModules['verifyActionPrincipal'] => Predicate.isFunction(value), ); -const EffectRuntimeLayerSchema = Schema.declare( - (value): value is EffectRuntimeLayer => Predicate.isObjectKeyword(value), +const EffectRuntimeLayerSchema = Schema.declare((value): value is EffectRuntimeLayer => + Predicate.isObjectKeyword(value), ); const isEffectRuntimeLayer = Schema.is(EffectRuntimeLayerSchema); const requiredValue = (value: Value | null | undefined, label: string): Value => { @@ -201,15 +179,9 @@ const requiredValue = (value: Value | null | undefined, label: string): V } return value; }; -const isOperationContextDenied = Schema.is( - Schema.Struct({ _tag: Schema.Literal('OperationContextDenied') }), -); -const isCreateRecordRejected = Schema.is( - Schema.Struct({ _tag: Schema.Literal('CreateRecordRejected') }), -); -const isActionHandlerExecutionError = Schema.is( - Schema.Struct({ _tag: Schema.Literal('ActionHandlerExecutionError') }), -); +const isOperationContextDenied = Schema.is(Schema.Struct({ _tag: Schema.Literal('OperationContextDenied') })); +const isCreateRecordRejected = Schema.is(Schema.Struct({ _tag: Schema.Literal('CreateRecordRejected') })); +const isActionHandlerExecutionError = Schema.is(Schema.Struct({ _tag: Schema.Literal('ActionHandlerExecutionError') })); const relationship = ( resourceType: string, resourceId: string, @@ -219,9 +191,15 @@ const relationship = ( ) => v1.Relationship.create({ relation, - resource: v1.ObjectReference.create({ objectId: resourceId, objectType: resourceType }), + resource: v1.ObjectReference.create({ + objectId: resourceId, + objectType: resourceType, + }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: subjectId, objectType: subjectType }), + object: v1.ObjectReference.create({ + objectId: subjectId, + objectType: subjectType, + }), }), }); const makeCatalog = (contract: OntosModuleDeploymentContract): InstalledModuleCatalog => @@ -268,19 +246,14 @@ const loadClientWiring = Effect.fnUntraced(function* loadClientWiring( detailClient: detailClient !== undefined && Predicate.isFunction( - Object.getOwnPropertyDescriptor(detailClient, 'executeResourceDetailWithAuthorization') - ?.value, + Object.getOwnPropertyDescriptor(detailClient, 'executeResourceDetailWithAuthorization')?.value, ), listClient: listClient !== undefined && - Predicate.isFunction( - Object.getOwnPropertyDescriptor(listClient, 'executeResourceListWithAuthorization')?.value, - ), + Predicate.isFunction(Object.getOwnPropertyDescriptor(listClient, 'executeResourceListWithAuthorization')?.value), searchClient: searchClient !== undefined && - Predicate.isFunction( - Object.getOwnPropertyDescriptor(searchClient, 'loadRecordsClientWithAuthorization')?.value, - ), + Predicate.isFunction(Object.getOwnPropertyDescriptor(searchClient, 'loadRecordsClientWithAuthorization')?.value), }; }); @@ -296,61 +269,44 @@ const loadGeneratedOwner = Effect.fnUntraced(function* runIntegration1( ); return yield* Schema.decodeUnknownEffect(DynamicModuleSchema)(importedModule); }); - const [ - detailApi, - detailServer, - listApi, - listServer, - searchApi, - searchServer, - verifier, - state, - registrationOwner, - ] = yield* Effect.all( - [ - load('shared/apis/resource-detail.ts'), - load('api/resource-detail-read-server.ts'), - load('shared/apis/resource-list.ts'), - load('api/resource-list-read-server.ts'), - load('shared/apis/records-search.ts'), - load('api/records-search-server.ts'), - load('api/auth/action-principal.ts'), - load('src/isolation/instrumentation.ts'), - load('vertical.registration.ts'), - ], - { concurrency: 'unbounded' }, - ); + const [detailApi, detailServer, listApi, listServer, searchApi, searchServer, verifier, state, registrationOwner] = + yield* Effect.all( + [ + load('shared/apis/resource-detail.ts'), + load('api/resource-detail-read-server.ts'), + load('shared/apis/resource-list.ts'), + load('api/resource-list-read-server.ts'), + load('shared/apis/records-search.ts'), + load('api/records-search-server.ts'), + load('api/auth/action-principal.ts'), + load('src/isolation/instrumentation.ts'), + load('vertical.registration.ts'), + ], + { concurrency: 'unbounded' }, + ); const registration = yield* Schema.decodeUnknownEffect(VerticalRuntimeRegistrationSchema)( registrationOwner['isolationOwnerRegistration'], ); const actions = getVerticalRuntimeActions(registration); const entrypoints = getVerticalRuntimeEntrypoints(registration); const wiring = yield* loadClientWiring(entrypoints); - const generatedAction = actions.find( - ({ descriptor }) => descriptor.actionKey === GENERATED_OWNER.actionKey, - ); + const generatedAction = actions.find(({ descriptor }) => descriptor.actionKey === GENERATED_OWNER.actionKey); if (generatedAction === undefined) { throw new TypeError('Generated Action is missing from the owner runtime registration'); } return { action: generatedAction, - counts: yield* Schema.decodeUnknownEffect(OwnerCountsSchema)( - state['generatedOwnerHandlerCounts'], - ), + counts: yield* Schema.decodeUnknownEffect(OwnerCountsSchema)(state['generatedOwnerHandlerCounts']), detail: makeOwnerHandler( yield* Schema.decodeUnknownEffect(OwnerApiSchema)(detailApi['ResourceDetailApi']), - yield* Schema.decodeUnknownEffect(OwnerGroupLayerSchema)( - detailServer['resourceDetailReadApiLive'], - ), + yield* Schema.decodeUnknownEffect(OwnerGroupLayerSchema)(detailServer['resourceDetailReadApiLive']), runtime, loggerLayer, configLayer, ), list: makeOwnerHandler( yield* Schema.decodeUnknownEffect(OwnerApiSchema)(listApi['ResourceListApi']), - yield* Schema.decodeUnknownEffect(OwnerGroupLayerSchema)( - listServer['resourceListReadApiLive'], - ), + yield* Schema.decodeUnknownEffect(OwnerGroupLayerSchema)(listServer['resourceListReadApiLive']), runtime, loggerLayer, configLayer, @@ -362,9 +318,7 @@ const loadGeneratedOwner = Effect.fnUntraced(function* runIntegration1( loggerLayer, configLayer, ), - verifyActionPrincipal: yield* Schema.decodeUnknownEffect(OwnerVerifierSchema)( - verifier['verifyActionPrincipal'], - ), + verifyActionPrincipal: yield* Schema.decodeUnknownEffect(OwnerVerifierSchema)(verifier['verifyActionPrincipal']), wiring, }; }); @@ -394,10 +348,7 @@ const decodeResponse = Effect.fnUntraced(function* runIntegration4< >(response: Response, schema: ResponseSchema) { return yield* Schema.decodeUnknownEffect(schema)(yield* Effect.tryPromise(() => response.json())); }); -const createOwnerSchema = Effect.fnUntraced(function* runIntegration5( - admin: Pool, - schemaName: string, -) { +const createOwnerSchema = Effect.fnUntraced(function* runIntegration5(admin: Pool, schemaName: string) { const tenantPredicate = `tenant_id = nullif(current_setting('ontos.tenant_id', true), '')::uuid`; const entityPredicate = `${tenantPredicate} and legal_entity_id = nullif(current_setting('ontos.legal_entity_id', true), '')::uuid`; // Dynamic identifiers are generated locally from UUID hex and never accept external input. @@ -423,16 +374,9 @@ const createOwnerSchema = Effect.fnUntraced(function* runIntegration5( ) `), ); - const configureTable = Effect.fnUntraced(function* runIntegration6( - table: string, - predicate: string, - ) { - yield* Effect.tryPromise(() => - admin.query(`alter table ${schemaName}.${table} enable row level security`), - ); - yield* Effect.tryPromise(() => - admin.query(`alter table ${schemaName}.${table} force row level security`), - ); + const configureTable = Effect.fnUntraced(function* runIntegration6(table: string, predicate: string) { + yield* Effect.tryPromise(() => admin.query(`alter table ${schemaName}.${table} enable row level security`)); + yield* Effect.tryPromise(() => admin.query(`alter table ${schemaName}.${table} force row level security`)); yield* Effect.tryPromise(() => admin.query( `create policy ${table}_select on ${schemaName}.${table} for select to ontos_runtime using (${predicate})`, @@ -455,19 +399,12 @@ const createOwnerSchema = Effect.fnUntraced(function* runIntegration5( ); }); yield* Effect.all( - [ - configureTable('tenant_records', tenantPredicate), - configureTable('entity_records', entityPredicate), - ], + [configureTable('tenant_records', tenantPredicate), configureTable('entity_records', entityPredicate)], { concurrency: 'unbounded' }, ); + yield* Effect.tryPromise(() => admin.query(`grant usage on schema ${schemaName} to ontos_runtime`)); yield* Effect.tryPromise(() => - admin.query(`grant usage on schema ${schemaName} to ontos_runtime`), - ); - yield* Effect.tryPromise(() => - admin.query( - `grant select, insert, update, delete on all tables in schema ${schemaName} to ontos_runtime`, - ), + admin.query(`grant select, insert, update, delete on all tables in schema ${schemaName} to ontos_runtime`), ); }); type CoreDatabaseService = Parameters[0]; @@ -519,9 +456,8 @@ const capturedLoggerLayer = (entries: string[]) => entries.push(JSON.stringify(Logger.formatStructured.log(options))); }), ]); -const ignoreOperationFailure = ( - operation: () => Effect.Effect, -): Effect.Effect => operation().pipe(Effect.ignore); +const ignoreOperationFailure = (operation: () => Effect.Effect): Effect.Effect => + operation().pipe(Effect.ignore); const principal = ( tenantId: string, legalEntityId: string, @@ -538,9 +474,9 @@ const principal = ( it.live( 'Codesmith composes the disposable owner Action and receiving read BFFs', Effect.fnUntraced(function* runIntegration7() { - const fixture = yield* createGeneratedOwnerFixture( - `generated_owner_${randomUUID().replaceAll('-', '')}`, - ).pipe(Effect.provide(NodeServices.layer)); + const fixture = yield* createGeneratedOwnerFixture(`generated_owner_${randomUUID().replaceAll('-', '')}`).pipe( + Effect.provide(NodeServices.layer), + ); const contract = yield* deriveOntosModuleDeploymentContract({ vertical: GENERATED_OWNER.slug, workspaceRoot: fixture.root, @@ -563,7 +499,12 @@ it.live( expect(makeCatalog(contract).getByModuleId(GENERATED_OWNER.moduleId)).toEqual(contract); expect(generated.action.descriptor.actionKey).toBe(GENERATED_OWNER.actionKey); expect(generated.action.descriptor.legalEntityScope).toBe('required'); - expect(generated.counts).toEqual({ action: 0, detail: 0, list: 0, search: 0 }); + expect(generated.counts).toEqual({ + action: 0, + detail: 0, + list: 0, + search: 0, + }); expect(generated.wiring).toEqual({ action: true, detailClient: true, @@ -608,9 +549,7 @@ it.live( (pool) => Effect.tryPromise(() => pool.end()).pipe(Effect.orDie), ); const runtimeDatabase = yield* makeFaultInjectableCoreDatabase(connections.runtime); - const fixture = yield* createGeneratedOwnerFixture(schemaName).pipe( - Effect.provide(NodeServices.layer), - ); + const fixture = yield* createGeneratedOwnerFixture(schemaName).pipe(Effect.provide(NodeServices.layer)); const contract = yield* deriveOntosModuleDeploymentContract({ vertical: GENERATED_OWNER.slug, workspaceRoot: fixture.root, @@ -621,28 +560,16 @@ it.live( const spiceAdmin = v1.NewClient( testSpiceDb.preSharedKey, testSpiceDb.endpoint, - testSpiceDb.insecureLocal - ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED - : v1.ClientSecurity.SECURE, + testSpiceDb.insecureLocal ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED : v1.ClientSecurity.SECURE, ); const permissionClient = createSpiceDbPermissionClient(testSpiceDb, SPICEDB_CHECK_TIMEOUT_MS); const contextAccess = makeContextAccess(permissionClient); const moduleStates = makeTenantModuleStateService(runtimeDatabase); const moduleStateGate = makeModuleStateGate(moduleStates); const moduleGateway = makeModuleEntrypointGateway(moduleStateGate); - const scopeResolver = makeOperationalScopeResolver( - makeOperationalScopeRepository(runtimeDatabase), - contextAccess, - ); - const readRuntime = makeReadRuntime( - runtimeDatabase, - moduleGateway, - scopeResolver, - contextAccess, - ); - const keyPair = yield* Effect.tryPromise(() => - generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }), - ); + const scopeResolver = makeOperationalScopeResolver(makeOperationalScopeRepository(runtimeDatabase), contextAccess); + const readRuntime = makeReadRuntime(runtimeDatabase, moduleGateway, scopeResolver, contextAccess); + const keyPair = yield* Effect.tryPromise(() => generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true })); const privateJwk = yield* Effect.tryPromise(() => exportJWK(keyPair.privateKey)); const publicJwk = yield* Effect.tryPromise(() => exportJWK(keyPair.publicKey)); const issuerConfiguration: GatewayIssuerConfigValue = { @@ -674,17 +601,10 @@ it.live( testClockLayer, ConfigProvider.layer(ConfigProvider.fromUnknown(verifierEnvironment)), ); - const generated = yield* loadGeneratedOwner( - fixture.verticalRoot, - readRuntime, - loggerLayer, - verifierConfigLayer, - ); + const generated = yield* loadGeneratedOwner(fixture.verticalRoot, readRuntime, loggerLayer, verifierConfigLayer); const handlers: OwnerHttpHandler[] = [generated.detail, generated.list, generated.search]; let assertionCount = 0; - const issueAuthorization = Effect.fnUntraced(function* runIntegration10( - principalContext: TrustedPrincipalContext, - ) { + const issueAuthorization = Effect.fnUntraced(function* runIntegration10(principalContext: TrustedPrincipalContext) { return yield* issueGatewayContextAssertion({ audience: GENERATED_OWNER.appId, principal: principalContext, @@ -708,9 +628,7 @@ it.live( }); const principalA1 = principal(tenantA, entityA1, principalA, bindingA); const principalB1 = principal(tenantB, entityB1, principalB, bindingB); - const issueProviderAuthorization = Effect.fnUntraced(function* runIntegration11( - context: TrustedPrincipalContext, - ) { + const issueProviderAuthorization = Effect.fnUntraced(function* runIntegration11(context: TrustedPrincipalContext) { return yield* issueAuthorization( withOptionalProperty( withOptionalProperty( @@ -751,42 +669,18 @@ it.live( const touchedObjects: readonly [string, string][] = [ ['tenant', tenantA], ['tenant', tenantB], - [ - 'legal_entity', - requiredValue(toLegalEntityAccessObjectId(tenantA, entityA1), 'Tenant A legal entity'), - ], - [ - 'legal_entity', - requiredValue(toLegalEntityAccessObjectId(tenantB, entityB1), 'Tenant B legal entity'), - ], + ['legal_entity', requiredValue(toLegalEntityAccessObjectId(tenantA, entityA1), 'Tenant A legal entity')], + ['legal_entity', requiredValue(toLegalEntityAccessObjectId(tenantB, entityB1), 'Tenant B legal entity')], [ 'module_access', - requiredValue( - toModuleAccessObjectId(tenantA, entityA1, GENERATED_OWNER.moduleId), - 'Tenant A module access', - ), + requiredValue(toModuleAccessObjectId(tenantA, entityA1, GENERATED_OWNER.moduleId), 'Tenant A module access'), ], [ 'module_access', - requiredValue( - toModuleAccessObjectId(tenantB, entityB1, GENERATED_OWNER.moduleId), - 'Tenant B module access', - ), - ], - [ - 'resource', - requiredValue( - toResourceAccessObjectId(tenantA, entityA1, resourceRef), - 'Tenant A resource', - ), - ], - [ - 'resource', - requiredValue( - toResourceAccessObjectId(tenantB, entityB1, resourceRef), - 'Tenant B resource', - ), + requiredValue(toModuleAccessObjectId(tenantB, entityB1, GENERATED_OWNER.moduleId), 'Tenant B module access'), ], + ['resource', requiredValue(toResourceAccessObjectId(tenantA, entityA1, resourceRef), 'Tenant A resource')], + ['resource', requiredValue(toResourceAccessObjectId(tenantB, entityB1, resourceRef), 'Tenant B resource')], ['action', toSpiceDbActionObjectId(GENERATED_OWNER.actionKey)], ]; yield* Effect.acquireRelease( @@ -813,88 +707,56 @@ it.live( const cleanupQueries = [ Effect.fnUntraced(function* runIntegration15() { return yield* Effect.tryPromise(() => - admin.query('delete from core.outbox_messages where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.outbox_messages where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration16() { return yield* Effect.tryPromise(() => - admin.query('delete from core.domain_events where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.domain_events where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration17() { return yield* Effect.tryPromise(() => - admin.query('delete from core.data_access_events where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.data_access_events where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration18() { return yield* Effect.tryPromise(() => - admin.query('delete from core.audit_events where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.audit_events where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration19() { return yield* Effect.tryPromise(() => - admin.query('delete from core.action_invocations where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.action_invocations where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration20() { return yield* Effect.tryPromise(() => - admin.query('delete from core.tenant_module_states where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.tenant_module_states where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration21() { return yield* Effect.tryPromise(() => - admin.query('delete from core.principal_auth_bindings where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.principal_auth_bindings where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration22() { return yield* Effect.tryPromise(() => - admin.query('delete from core.principals where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.principals where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration23() { return yield* Effect.tryPromise(() => - admin.query('delete from core.legal_entities where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.legal_entities where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration24() { return yield* Effect.tryPromise(() => - admin.query('delete from core.tenants where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]), + admin.query('delete from core.tenants where tenant_id in ($1, $2)', [tenantA, tenantB]), ); }), Effect.fnUntraced(function* runIntegration25() { - return yield* Effect.tryPromise(() => - admin.query(`drop schema if exists ${schemaName} cascade`), - ); + return yield* Effect.tryPromise(() => admin.query(`drop schema if exists ${schemaName} cascade`)); }), ]; yield* Effect.forEach(cleanupQueries, ignoreOperationFailure, { @@ -936,16 +798,7 @@ it.live( yield* Effect.tryPromise(() => admin.query( `insert into core.principal_auth_bindings (principal_auth_binding_id, tenant_id, principal_id, provider, subject_type, provider_subject_id, status) values ($1, $3, $5, 'better_auth', 'user', $7, 'active'), ($2, $4, $6, 'better_auth', 'user', $8, 'active')`, - [ - bindingA, - bindingB, - tenantA, - tenantB, - principalA, - principalB, - `user-${principalA}`, - `user-${principalB}`, - ], + [bindingA, bindingB, tenantA, tenantB, principalA, principalB, `user-${principalA}`, `user-${principalB}`], ), ); yield* Effect.tryPromise(() => @@ -966,14 +819,8 @@ it.live( [tenantA, entityA1, entityA2, tenantB, entityB1, entityB2, collidingResourceId], ), ); - const legalA = requiredValue( - toLegalEntityAccessObjectId(tenantA, entityA1), - 'Tenant A legal entity', - ); - const legalB = requiredValue( - toLegalEntityAccessObjectId(tenantB, entityB1), - 'Tenant B legal entity', - ); + const legalA = requiredValue(toLegalEntityAccessObjectId(tenantA, entityA1), 'Tenant A legal entity'); + const legalB = requiredValue(toLegalEntityAccessObjectId(tenantB, entityB1), 'Tenant B legal entity'); const moduleA = requiredValue( toModuleAccessObjectId(tenantA, entityA1, GENERATED_OWNER.moduleId), 'Tenant A module access', @@ -982,14 +829,8 @@ it.live( toModuleAccessObjectId(tenantB, entityB1, GENERATED_OWNER.moduleId), 'Tenant B module access', ); - const resourceA = requiredValue( - toResourceAccessObjectId(tenantA, entityA1, resourceRef), - 'Tenant A resource', - ); - const resourceB = requiredValue( - toResourceAccessObjectId(tenantB, entityB1, resourceRef), - 'Tenant B resource', - ); + const resourceA = requiredValue(toResourceAccessObjectId(tenantA, entityA1, resourceRef), 'Tenant A resource'); + const resourceB = requiredValue(toResourceAccessObjectId(tenantB, entityB1, resourceRef), 'Tenant B resource'); const actionId = toSpiceDbActionObjectId(GENERATED_OWNER.actionKey); const relationships = [ relationship('tenant', tenantA, 'member', 'principal', principalA), @@ -1142,11 +983,7 @@ it.live( catalog: Effect.succeed(catalog), contextAccess, issueAssertion: Effect.fnUntraced( - function* integrationEffect29({ - context, - }: { - readonly context: TrustedPrincipalContext; - }) { + function* integrationEffect29({ context }: { readonly context: TrustedPrincipalContext }) { return yield* issueProviderAuthorization(context); }, Effect.catchCause(() => Effect.fail(new ShellProviderUnavailableError())), @@ -1157,7 +994,11 @@ it.live( ); expect( yield* directShellSearch.search( - { ...principalA1, correlationId: randomUUID(), legalEntityId: entityA1 }, + { + ...principalA1, + correlationId: randomUUID(), + legalEntityId: entityA1, + }, 'searchable', ), ).toEqual({ @@ -1168,11 +1009,7 @@ it.live( gateway, { issueAssertion: Effect.fnUntraced( - function* integrationEffect30({ - context, - }: { - readonly context: TrustedPrincipalContext; - }) { + function* integrationEffect30({ context }: { readonly context: TrustedPrincipalContext }) { return yield* issueProviderAuthorization(context); }, Effect.catchCause(() => Effect.fail(new ShellProviderUnavailableError())), @@ -1185,7 +1022,9 @@ it.live( Layer.succeed(ReadRuntime, readRuntime), Layer.succeed(ContextAccess, contextAccess), Layer.succeed(TenantModuleStateService, moduleStates), - Layer.succeed(ShellInstalledModuleCatalog, { load: Effect.succeed(catalog) }), + Layer.succeed(ShellInstalledModuleCatalog, { + load: Effect.succeed(catalog), + }), ShellCompositionFactoryLive, ShellResourceServicesFactoryLive, ), @@ -1246,10 +1085,7 @@ it.live( expect(generated.counts).toEqual(beforeForgedShell); expect(assertionCount).toBe(9); yield* Effect.all( - [ - principal(tenantA, entityA2, principalA, bindingA), - principal(tenantB, entityB1, principalA, bindingA), - ].map( + [principal(tenantA, entityA2, principalA, bindingA), principal(tenantB, entityB1, principalA, bindingA)].map( Effect.fnUntraced(function* runIntegration31(forgedPrincipal) { const authorization = yield* issueAuthorization(forgedPrincipal); const response = yield* requestOwner( @@ -1261,9 +1097,7 @@ it.live( ); expect(response.status).toBe(403); const problem = JSON.stringify(yield* Effect.tryPromise(() => response.json())); - expect(problem).not.toMatch( - new RegExp([tenantA, tenantB, entityA2, entityB1].join('|'), 'u'), - ); + expect(problem).not.toMatch(new RegExp([tenantA, tenantB, entityA2, entityB1].join('|'), 'u')); expect(problem).not.toMatch(/postgres|spicedb|permission check|row-level/iu); }), ), @@ -1342,11 +1176,7 @@ it.live( loggerLayer, verifierConfigLayer, ); - handlers.push( - evidenceFailureOwner.detail, - evidenceFailureOwner.list, - evidenceFailureOwner.search, - ); + handlers.push(evidenceFailureOwner.detail, evidenceFailureOwner.list, evidenceFailureOwner.search); const evidenceFailureResponse = yield* requestOwner( evidenceFailureOwner.detail, '/reads/resource-detail', @@ -1355,9 +1185,9 @@ it.live( randomUUID(), ); expect(evidenceFailureResponse.status).toBe(503); - expect( - JSON.stringify(yield* Effect.tryPromise(() => evidenceFailureResponse.json())), - ).not.toMatch(/A1 searchable/u); + expect(JSON.stringify(yield* Effect.tryPromise(() => evidenceFailureResponse.json()))).not.toMatch( + /A1 searchable/u, + ); const actionRuntime = makeActionRuntime( runtimeDatabase, makeActionRepository(), @@ -1430,11 +1260,9 @@ it.live( }, ].map( Effect.fnUntraced(function* runIntegration33(payload) { - expect( - isCreateRecordRejected( - yield* Effect.flip(invokeAction(principalA1, payload, randomUUID())), - ), - ).toBe(true); + expect(isCreateRecordRejected(yield* Effect.flip(invokeAction(principalA1, payload, randomUUID())))).toBe( + true, + ); }), ), ); @@ -1477,9 +1305,7 @@ it.live( legal_entity_id: string; tenant_id: string; title: string; - }>( - `select tenant_id, legal_entity_id, title from ${schemaName}.entity_records order by title`, - ), + }>(`select tenant_id, legal_entity_id, title from ${schemaName}.entity_records order by title`), ); expect(ownerRows.rows.some(({ title }) => title === 'A1 action write')).toBe(true); expect(ownerRows.rows.some(({ title }) => title.startsWith('forbidden'))).toBe(false); @@ -1499,28 +1325,18 @@ it.live( const unscopedEntityRows = yield* Effect.tryPromise(() => runtimePool.query(`select * from ${schemaName}.entity_records`), ); - expect( - unscopedEntityRows.rowCount, - 'a reused pooled connection must not retain transaction-local scope', - ).toBe(0); + expect(unscopedEntityRows.rowCount, 'a reused pooled connection must not retain transaction-local scope').toBe(0); const unscopedTenantRows = yield* Effect.tryPromise(() => runtimePool.query(`select * from ${schemaName}.tenant_records`), ); expect(unscopedTenantRows.rowCount).toBe(0); - expect(capturedLogs.length > 0, 'the generated-owner path must capture runtime logs').toBe( - true, - ); + expect(capturedLogs.length > 0, 'the generated-owner path must capture runtime logs').toBe(true); const capturedLogText = capturedLogs.join('\n'); expect(capturedLogText).toMatch(/Unexpected Action execution defect/u); expect(capturedLogText).not.toMatch( - new RegExp( - [tenantB, entityA2, entityB1, entityB2, principalB, bindingB, deniedResourceId].join('|'), - 'u', - ), - ); - expect(capturedLogText).not.toMatch( - /postgres|spicedb|row-level|database operation scope|permission check/iu, + new RegExp([tenantB, entityA2, entityB1, entityB2, principalB, bindingB, deniedResourceId].join('|'), 'u'), ); + expect(capturedLogText).not.toMatch(/postgres|spicedb|row-level|database operation scope|permission check/iu); const generatedActionSource = yield* Effect.tryPromise(() => readFile(`${fixture.verticalRoot}/src/actions/create-record.action.ts`, 'utf-8'), ); diff --git a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts index b3622f266..367f601c7 100644 --- a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts @@ -1,12 +1,5 @@ -import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; -import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; -import { Context, Effect, Predicate } from 'effect'; import { randomUUID } from 'node:crypto'; -import { and, eq, inArray } from 'drizzle-orm'; -import { AuthDatabase, makeAuthDatabase } from '../../api/auth/db/client.ts'; -import { exportJWK, generateKeyPair, jwtVerify } from 'jose'; -import { Pool } from 'pg'; + import { ActionRuntime, ContextAccess, @@ -17,14 +10,18 @@ import { makePrincipalResolver, makeSupportRecoveryPrincipalContextResolver, } from '@app/core-runtime'; +import { and, eq, inArray } from 'drizzle-orm'; +import { Context, Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { exportJWK, generateKeyPair, jwtVerify } from 'jose'; +import { Pool } from 'pg'; + import { makeActionRepository } from '../../../../packages/core-runtime/src/actions/repository.ts'; import { makeActionRuntime } from '../../../../packages/core-runtime/src/actions/runtime.ts'; import { PrincipalManagementRepository, principalManagementRepositoryFromTransaction, } from '../../../../packages/core-runtime/src/auth/principal-management.ts'; -import { openActionRuntimeOptions } from '../../../../packages/core-runtime/tests/support/action-runtime-options.ts'; -import { createNonHumanPrincipalAction } from '../../../../packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts'; import { actionInvocations, auditEvents, @@ -34,19 +31,16 @@ import { principals, tenants, } from '../../../../packages/core-runtime/src/db/schema.ts'; +import { createNonHumanPrincipalAction } from '../../../../packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts'; +import { openActionRuntimeOptions } from '../../../../packages/core-runtime/tests/support/action-runtime-options.ts'; +import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { makeApiKeyService } from '../../api/auth/api-key-service.ts'; -import { - issueGatewayContextAssertion, - makeGatewayIssuerLayer, -} from '../../api/auth/gateway-issuer.ts'; import { AuthConfig, loadAuthConfig } from '../../api/auth/config.ts'; -import { - account, - apikey, - session, - supportImpersonationRecovery, - user, -} from '../../api/auth/db/schema.ts'; +import { AuthDatabase, makeAuthDatabase } from '../../api/auth/db/client.ts'; +import { account, apikey, session, supportImpersonationRecovery, user } from '../../api/auth/db/schema.ts'; +import { issueGatewayContextAssertion, makeGatewayIssuerLayer } from '../../api/auth/gateway-issuer.ts'; +import { makeIdentityLifecycleService } from '../../api/auth/identity-lifecycle.ts'; import { makeSupportAuthProvider, makeSupportImpersonationService, @@ -56,7 +50,6 @@ import { SupportImpersonationStoreService, } from '../../api/auth/impersonation-service.ts'; import { AuthenticationService, makeAuthenticationService } from '../../api/auth/service.ts'; -import { makeIdentityLifecycleService } from '../../api/auth/identity-lifecycle.ts'; const cookieHeader = (setCookieHeaders: readonly string[]): string => { const cookies = new Map(); @@ -89,14 +82,10 @@ it.live.each([ const authPersistence = yield* makeAuthDatabase(baseConfiguration); const authDatabase = authPersistence.executor; const coreDatabase = yield* makeTestDatabaseFromPool(corePool, coreRelations); - const principalManagementRepository = - principalManagementRepositoryFromTransaction(coreDatabase); + const principalManagementRepository = principalManagementRepositoryFromTransaction(coreDatabase); const providePrincipalManagementRepository = ( effect: Effect.Effect, - ) => - effect.pipe( - Effect.provideService(PrincipalManagementRepository, principalManagementRepository), - ); + ) => effect.pipe(Effect.provideService(PrincipalManagementRepository, principalManagementRepository)); const tenantId = randomUUID(); const originalPrincipalId = randomUUID(); const targetPrincipalId = randomUUID(); @@ -132,9 +121,7 @@ it.live.each([ Effect.succeed( tenantIds.map((key) => ({ decision: - permission === 'impersonate' && !supportPermissionAllowed - ? ('denied' as const) - : ('allowed' as const), + permission === 'impersonate' && !supportPermissionAllowed ? ('denied' as const) : ('allowed' as const), key, })), ), @@ -164,18 +151,10 @@ it.live.each([ let targetUserId = ''; let secondAdministratorUserId = ''; const cleanup = Effect.fnUntraced(function* runIntegration2() { - if ( - originalUserId.length > 0 || - targetUserId.length > 0 || - secondAdministratorUserId.length > 0 - ) { - const ids = [originalUserId, targetUserId, secondAdministratorUserId].filter( - (id) => id.length > 0, - ); + if (originalUserId.length > 0 || targetUserId.length > 0 || secondAdministratorUserId.length > 0) { + const ids = [originalUserId, targetUserId, secondAdministratorUserId].filter((id) => id.length > 0); yield* purgeFixtureRows([ - authDatabase - .delete(supportImpersonationRecovery) - .where(eq(supportImpersonationRecovery.tenantId, tenantId)), + authDatabase.delete(supportImpersonationRecovery).where(eq(supportImpersonationRecovery.tenantId, tenantId)), authDatabase.delete(apikey).where(inArray(apikey.referenceId, ids)), authDatabase.delete(session).where(inArray(session.userId, ids)), authDatabase.delete(account).where(inArray(account.userId, ids)), @@ -186,9 +165,7 @@ it.live.each([ coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, tenantId)), + coreDatabase.delete(principalAuthBindings).where(eq(principalAuthBindings.tenantId, tenantId)), coreDatabase.delete(principals).where(eq(principals.tenantId, tenantId)), coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), ]); @@ -199,16 +176,8 @@ it.live.each([ yield* cleanup(); }, Effect.orDie), ); - originalUserId = yield* fixtureAuthentication.createFixtureUser( - originalEmail, - 'Support original', - password, - ); - targetUserId = yield* fixtureAuthentication.createFixtureUser( - targetEmail, - 'Support target', - password, - ); + originalUserId = yield* fixtureAuthentication.createFixtureUser(originalEmail, 'Support original', password); + targetUserId = yield* fixtureAuthentication.createFixtureUser(targetEmail, 'Support target', password); secondAdministratorUserId = yield* fixtureAuthentication.createFixtureUser( secondAdministratorEmail, 'Second identity administrator', @@ -295,9 +264,7 @@ it.live.each([ Effect.provideService(AuthConfig, configuration), Effect.provideService(AuthDatabase, authPersistence), ); - const resolvedOriginal = yield* provideContextAccess( - authentication.resolveTenantContext(originalHeaders), - ); + const resolvedOriginal = yield* provideContextAccess(authentication.resolveTenantContext(originalHeaders)); expect(resolvedOriginal.state).toBe('authenticated'); if (resolvedOriginal.state !== 'authenticated') { throw new Error('The live original session did not resolve'); @@ -423,7 +390,10 @@ it.live.each([ tenantId, }, registration: createNonHumanPrincipalAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, }), ); yield* keys.setEnabled(verified.providerKeyId, false); @@ -433,7 +403,10 @@ it.live.each([ lifecycle.createNonHumanPrincipal({ correlationId: randomUUID(), idempotencyKey: randomUUID(), - payload: { displayName: 'Cross-admin integration', kind: 'integration' }, + payload: { + displayName: 'Cross-admin integration', + kind: 'integration', + }, principal: resolvedOriginal.principal, }), ); @@ -484,10 +457,7 @@ it.live.each([ Context.add(AuthConfig, configuration), Context.add(PrincipalResolver, resolver), Context.add(SupportRecoveryPrincipalContextResolver, supportRecoveryPrincipal), - Context.add( - SupportAuthProviderService, - makeSupportAuthProvider(configuration, authPersistence.adapter), - ), + Context.add(SupportAuthProviderService, makeSupportAuthProvider(configuration, authPersistence.adapter)), Context.add(SupportImpersonationStoreService, makeSupportImpersonationStore(authDatabase)), ), ); @@ -539,16 +509,12 @@ it.live.each([ const mismatchedImpersonationReason = yield* Effect.flip( provideContextAccess(authentication.resolveTenantContext(impersonatedHeaders)), ); - expect(Predicate.isTagged(mismatchedImpersonationReason, 'OntosIdentityForbiddenError')).toBe( - true, - ); + expect(Predicate.isTagged(mismatchedImpersonationReason, 'OntosIdentityForbiddenError')).toBe(true); yield* authDatabase .update(session) .set({ impersonationReason: 'Investigating a tenant support request' }) .where(eq(session.id, impersonationSession.id)); - const impersonated = yield* provideContextAccess( - authentication.resolveTenantContext(impersonatedHeaders), - ); + const impersonated = yield* provideContextAccess(authentication.resolveTenantContext(impersonatedHeaders)); expect(impersonated.state).toBe('authenticated'); if (impersonated.state === 'authenticated') { expect(impersonated.principal.authMethod).toBe('support_impersonation'); @@ -556,10 +522,16 @@ it.live.each([ expect(impersonated.principal.impersonatedByPrincipalId).toBe(originalPrincipalId); yield* providePrincipalManagementRepository( actionRuntime.runAction({ - payload: { displayName: 'Support evidence target', kind: 'integration' }, + payload: { + displayName: 'Support evidence target', + kind: 'integration', + }, principal: impersonated.principal, registration: createNonHumanPrincipalAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, }), ); } diff --git a/app/apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts b/app/apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts index 5dd8e7d9b..5dc889c03 100644 --- a/app/apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts @@ -1,4 +1,3 @@ -import { expect, it } from 'effect-rstest'; import { OntosModuleDeploymentContractSchema, defineAction, @@ -10,9 +9,11 @@ import { getVerticalRuntimeActions, getVerticalRuntimeOutboxWorkers, } from '@app/core-runtime'; +import { makeEffectHttpApiClient } from '@modern-js/plugin-bff/effect-client'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; -import { makeEffectHttpApiClient } from '@modern-js/plugin-bff/effect-client'; + import { deriveDeploymentAllowlist } from '../../api/modules/deployment-allowlist.ts'; import { makeInstalledModuleCatalogLoader } from '../../api/modules/installed-module-catalog.ts'; import type { ModuleContractFetch } from '../../api/modules/installed-module-catalog.ts'; @@ -35,15 +36,7 @@ const contract = ( defaultState: 'inactive', preservesHistoryWhenInactive: true, scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }, module: { description: `${moduleId} independently deployed module`, @@ -59,8 +52,7 @@ const contract = ( { expose: './Dashboard', key: `${moduleId}.dashboard`, - mfBoundaryId: - appId === 'property-registry' ? 'verticalPropertyRegistry' : 'verticalDocumentsCenter', + mfBoundaryId: appId === 'property-registry' ? 'verticalPropertyRegistry' : 'verticalDocumentsCenter', }, ], events: [], @@ -86,9 +78,7 @@ const PropertyApi = HttpApi.make('PropertyApi').add( HttpApiGroup.make('property').add(HttpApiEndpoint.get('listUnits', '/units')), ); const PropertyRegistryUnitIdSchema = Schema.String.pipe(Schema.brand('PropertyRegistryUnitId')); -const DocumentsCenterDocumentIdSchema = Schema.String.pipe( - Schema.brand('DocumentsCenterDocumentId'), -); +const DocumentsCenterDocumentIdSchema = Schema.String.pipe(Schema.brand('DocumentsCenterDocumentId')); const PropertyAction = defineAction( { accessEvidencePolicy: { @@ -101,7 +91,10 @@ const PropertyAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'property.registry.rename-unit', moduleKey: 'property.registry', role: 'action', @@ -127,7 +120,9 @@ const PropertyOutboxWorker = defineOutboxWorker( role: 'worker', }), leaseDurationMs: 30_000, - payloadSchema: Schema.Struct({ documentId: DocumentsCenterDocumentIdSchema }), + payloadSchema: Schema.Struct({ + documentId: DocumentsCenterDocumentIdSchema, + }), producerModuleKey: 'documents.center', retryPolicy: { initialBackoffMs: 1000, @@ -193,7 +188,9 @@ const makeContractFetch = requests.set(url, (requests.get(url) ?? 0) + 1); const encodedDocument = Schema.encodeUnknownSync(ContractDocumentJsonSchema)(document); return Promise.resolve( - new Response(encodedDocument, { headers: { 'content-type': 'application/json' } }), + new Response(encodedDocument, { + headers: { 'content-type': 'application/json' }, + }), ); }; it.effect( @@ -208,7 +205,12 @@ it.effect( propertyUrl, contract('property-registry', 'property.registry', { actions: propertySafeRuntime.actions, - api: [{ key: 'property.registry.api', operationKeys: ['property.listUnits'] }], + api: [ + { + key: 'property.registry.api', + operationKeys: ['property.listUnits'], + }, + ], components: [ { expose: './Dashboard', @@ -246,9 +248,7 @@ it.effect( expect(first).toBe(second); expect(requests.get(propertyUrl)).toBe(1); expect(requests.get(documentsUrl)).toBe(1); - expect(first.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe( - 'property.registry', - ); + expect(first.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe('property.registry'); expect(first.getByModuleId('property.registry')?.deployment.appId).toBe('property-registry'); expect(first.moduleIds).toEqual(['documents.center', 'property.registry']); const tenantStates = [ @@ -261,9 +261,7 @@ it.effect( .map(({ moduleKey }) => moduleKey), ).toEqual(['property.registry']); expect(getVerticalRuntimeActions(propertyRuntimeRegistration)[0]).toBe(PropertyAction); - expect(getVerticalRuntimeOutboxWorkers(propertyRuntimeRegistration)[0]).toBe( - PropertyOutboxWorker, - ); + expect(getVerticalRuntimeOutboxWorkers(propertyRuntimeRegistration)[0]).toBe(PropertyOutboxWorker); expect(Object.keys(propertyRuntimeRegistration)).toEqual(['moduleId']); let matchedSubscriptions: readonly object[] = []; yield* matchInstalledOutboxMessagesOnce(first, (input) => { diff --git a/app/apps/shell-super-app/tests/integration/module-federation-i18n-runtime.test.ts b/app/apps/shell-super-app/tests/integration/module-federation-i18n-runtime.test.ts index c61a63493..6d599e541 100644 --- a/app/apps/shell-super-app/tests/integration/module-federation-i18n-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/module-federation-i18n-runtime.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs'; import { createRequire, registerHooks } from 'node:module'; -import { describe, expect, it } from 'effect-rstest'; + import { Effect } from 'effect'; +import { describe, expect, it } from 'effect-rstest'; import * as Schema from 'effect/Schema'; const shellConfigUrl = new URL('../../module-federation.config.ts', import.meta.url); @@ -30,31 +31,25 @@ registerHooks({ }); describe('module-federation-i18n-runtime', () => { - it.effect( - 'Shell and Party Registry share the i18n runtime that owns the federated provider context', - () => - Effect.gen(function* sharesFederatedI18nRuntime() { - const [{ default: shellConfig }, { default: partyRegistryConfig }] = yield* Effect.promise( - () => Promise.all([import(shellConfigUrl.href), import(partyRegistryConfigUrl.href)]), - ); - const require = createRequire(shellConfigUrl); - const { version: i18nVersion } = Schema.decodeUnknownSync( - Schema.Struct({ version: Schema.String }), - )(require('@modern-js/plugin-i18n/package.json')); - const expectedSharedRuntime = { - import: '@modern-js/plugin-i18n/runtime/no-react-i18next', - requiredVersion: i18nVersion, - singleton: true, - strictVersion: true, - treeShaking: false, - }; + it.effect('Shell and Party Registry share the i18n runtime that owns the federated provider context', () => + Effect.gen(function* sharesFederatedI18nRuntime() { + const [{ default: shellConfig }, { default: partyRegistryConfig }] = yield* Effect.promise(() => + Promise.all([import(shellConfigUrl.href), import(partyRegistryConfigUrl.href)]), + ); + const require = createRequire(shellConfigUrl); + const { version: i18nVersion } = Schema.decodeUnknownSync(Schema.Struct({ version: Schema.String }))( + require('@modern-js/plugin-i18n/package.json'), + ); + const expectedSharedRuntime = { + import: '@modern-js/plugin-i18n/runtime/no-react-i18next', + requiredVersion: i18nVersion, + singleton: true, + strictVersion: true, + treeShaking: false, + }; - expect(shellConfig.shared?.['@modern-js/plugin-i18n/runtime']).toEqual( - expectedSharedRuntime, - ); - expect(partyRegistryConfig.shared?.['@modern-js/plugin-i18n/runtime']).toEqual( - expectedSharedRuntime, - ); - }), + expect(shellConfig.shared?.['@modern-js/plugin-i18n/runtime']).toEqual(expectedSharedRuntime); + expect(partyRegistryConfig.shared?.['@modern-js/plugin-i18n/runtime']).toEqual(expectedSharedRuntime); + }), ); }); diff --git a/app/apps/shell-super-app/tests/integration/stage-demo-bootstrap.test.ts b/app/apps/shell-super-app/tests/integration/stage-demo-bootstrap.test.ts index c11fcd271..72574f902 100644 --- a/app/apps/shell-super-app/tests/integration/stage-demo-bootstrap.test.ts +++ b/app/apps/shell-super-app/tests/integration/stage-demo-bootstrap.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it, rstest } from 'effect-rstest'; import { randomUUID } from 'node:crypto'; + import { memoryAdapter } from 'better-auth/adapters/memory'; import { verifyPassword } from 'better-auth/crypto'; import { eq } from 'drizzle-orm'; import { Cause, DateTime, Deferred, Effect, Exit, Fiber } from 'effect'; +import { describe, expect, it, rstest } from 'effect-rstest'; + import { loadAuthConfig } from '../../api/auth/config.ts'; import { AuthDatabase, makeAuthDatabase } from '../../api/auth/db/client.ts'; import { account, session, user } from '../../api/auth/db/schema.ts'; @@ -24,7 +26,11 @@ describe('stage-demo-bootstrap', () => { const replacementPassword = `replacement-${randomUUID()}`; const configuration = { accounts: [ - { email, password: initialPassword, principalDisplayName: 'Password reset fixture' }, + { + email, + password: initialPassword, + principalDisplayName: 'Password reset fixture', + }, { email: `unused-${randomUUID()}@example.test`, password: randomUUID(), @@ -36,10 +42,7 @@ describe('stage-demo-bootstrap', () => { databaseAdminUrl: baseConfiguration.connectionString, } as const; const cleanup = Effect.gen(function* cleanupPasswordFixture() { - const users = yield* database - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email)); + const users = yield* database.select({ id: user.id }).from(user).where(eq(user.email, email)); for (const existingUser of users) { yield* database.delete(session).where(eq(session.userId, existingUser.id)); yield* database.delete(account).where(eq(account.userId, existingUser.id)); @@ -48,14 +51,10 @@ describe('stage-demo-bootstrap', () => { }).pipe(Effect.orDie); yield* cleanup; yield* Effect.addFinalizer(() => cleanup); - const created = yield* ensureStageDemoAuthUser( - configuration, - configuration.accounts[0], - ).pipe(Effect.provideService(AuthDatabase, persistence)); - yield* database - .update(account) - .set({ password: randomUUID() }) - .where(eq(account.userId, created.userId)); + const created = yield* ensureStageDemoAuthUser(configuration, configuration.accounts[0]).pipe( + Effect.provideService(AuthDatabase, persistence), + ); + yield* database.update(account).set({ password: randomUUID() }).where(eq(account.userId, created.userId)); const sessionCreatedAt = yield* DateTime.nowAsDate; const sessionExpiresAt = DateTime.makeUnsafe(sessionCreatedAt).pipe( DateTime.add({ minutes: 1 }), @@ -73,17 +72,26 @@ describe('stage-demo-bootstrap', () => { ...configuration.accounts[0], password: replacementPassword, }).pipe(Effect.provideService(AuthDatabase, persistence)); - expect(replaced).toEqual({ status: 'password-reset', userId: created.userId }); + expect(replaced).toEqual({ + status: 'password-reset', + userId: created.userId, + }); const [credential] = yield* database .select({ password: account.password }) .from(account) .where(eq(account.userId, created.userId)); expect(credential?.password).toBeDefined(); const replacementMatches = yield* Effect.promise(() => - verifyPassword({ hash: credential?.password ?? '', password: replacementPassword }), + verifyPassword({ + hash: credential?.password ?? '', + password: replacementPassword, + }), ); const initialMatches = yield* Effect.promise(() => - verifyPassword({ hash: credential?.password ?? '', password: initialPassword }), + verifyPassword({ + hash: credential?.password ?? '', + password: initialPassword, + }), ); expect(replacementMatches).toBe(true); expect(initialMatches).toBe(false); @@ -135,16 +143,14 @@ describe('stage-demo-bootstrap', () => { adapter: (options) => { const adapter = sdkAdapter(options); const create = adapter.create.bind(adapter); - rstest - .spyOn(adapter, 'create') - .mockImplementation((input: Parameters[0]) => { - if (input.model === 'user') { - Deferred.doneUnsafe(sdkStarted, Effect.succeed(null)); - // oxlint-disable-next-line sonarjs/no-nested-functions -- SDK settlement continuation stays inside its adapter mock. - return sdkSettlement.promise.then(() => create(input)); - } - return create(input); - }); + rstest.spyOn(adapter, 'create').mockImplementation((input: Parameters[0]) => { + if (input.model === 'user') { + Deferred.doneUnsafe(sdkStarted, Effect.succeed(null)); + // oxlint-disable-next-line sonarjs/no-nested-functions -- SDK settlement continuation stays inside its adapter mock. + return sdkSettlement.promise.then(() => create(input)); + } + return create(input); + }); return adapter; }, executor: database.executor, @@ -161,7 +167,11 @@ describe('stage-demo-bootstrap', () => { const closedBeforeSettlement = databaseClosed; sdkSettlement.resolve(null); yield* Fiber.join(interruption); - return { closedBeforeSettlement, exit: yield* Fiber.await(bootstrap), pending }; + return { + closedBeforeSettlement, + exit: yield* Fiber.await(bootstrap), + pending, + }; }).pipe(Effect.ensuring(Effect.sync(() => sdkSettlement.resolve(null)))); expect(outcome.pending).toBe(true); expect(outcome.closedBeforeSettlement).toBe(false); diff --git a/app/apps/shell-super-app/tests/support/action-runtime-double.ts b/app/apps/shell-super-app/tests/support/action-runtime-double.ts index cdab8cd60..e9e4a3377 100644 --- a/app/apps/shell-super-app/tests/support/action-runtime-double.ts +++ b/app/apps/shell-super-app/tests/support/action-runtime-double.ts @@ -6,7 +6,10 @@ type JsonValue = Schema.Schema.Type; type TestActionOutcome = | { readonly error: ActionCoreError; readonly kind: 'core-failure' } | { readonly defect: Error | string; readonly kind: 'defect' } - | { readonly error: Readonly<{ readonly _tag: string }>; readonly kind: 'domain-failure' } + | { + readonly error: Readonly<{ readonly _tag: string }>; + readonly kind: 'domain-failure'; + } | { readonly kind: 'success'; readonly value: JsonValue }; export const actionCoreFailure = (error: ActionCoreError): TestActionOutcome => ({ @@ -19,9 +22,10 @@ export const actionDefect = (defect: Error | string): TestActionOutcome => ({ kind: 'defect', }); -export const actionDomainFailure = ( - error: Readonly<{ readonly _tag: string }>, -): TestActionOutcome => ({ error, kind: 'domain-failure' }); +export const actionDomainFailure = (error: Readonly<{ readonly _tag: string }>): TestActionOutcome => ({ + error, + kind: 'domain-failure', +}); export const actionSuccess = (value: JsonValue): TestActionOutcome => ({ kind: 'success', @@ -52,9 +56,7 @@ export const makeActionRuntimeDouble = (outcomes: readonly TestActionOutcome[]) ? Effect.fail(outcome.error) : Effect.die('Configured action domain failure does not match registration schema'); } - return Effect.sync(() => - Schema.decodeUnknownSync(input.registration.descriptor.resultSchema)(outcome.value), - ); + return Effect.sync(() => Schema.decodeUnknownSync(input.registration.descriptor.resultSchema)(outcome.value)); }, }; return { invocationCount: () => invocation, payloads, runtime }; diff --git a/app/apps/shell-super-app/tests/support/context-access-double.ts b/app/apps/shell-super-app/tests/support/context-access-double.ts index 7ad75cc03..22a4ed542 100644 --- a/app/apps/shell-super-app/tests/support/context-access-double.ts +++ b/app/apps/shell-super-app/tests/support/context-access-double.ts @@ -1,11 +1,8 @@ import type { ContextAccessService } from '@app/core-runtime'; import { Effect } from 'effect'; -export const makeContextAccessDouble = ( - decision: 'allowed' | 'unavailable', -): ContextAccessService => ({ - legalEntities: ({ legalEntityIds }) => - Effect.succeed(legalEntityIds.map((key) => ({ decision, key }))), +export const makeContextAccessDouble = (decision: 'allowed' | 'unavailable'): ContextAccessService => ({ + legalEntities: ({ legalEntityIds }) => Effect.succeed(legalEntityIds.map((key) => ({ decision, key }))), modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision, key }))), resources: ({ resources }) => Effect.succeed( diff --git a/app/apps/shell-super-app/tests/support/identity-service-doubles.ts b/app/apps/shell-super-app/tests/support/identity-service-doubles.ts index 295672bd9..9255c55d4 100644 --- a/app/apps/shell-super-app/tests/support/identity-service-doubles.ts +++ b/app/apps/shell-super-app/tests/support/identity-service-doubles.ts @@ -1,9 +1,9 @@ import type { PrincipalResolverService } from '@app/core-runtime'; import { Effect } from 'effect'; + import type { ApiKeyServiceContract } from '../../api/auth/api-key-service.ts'; -const unconfigured = (operation: string) => - Effect.die(`${operation} is not configured in this test`); +const unconfigured = (operation: string) => Effect.die(`${operation} is not configured in this test`); const apiKeyDefaults: ApiKeyServiceContract = { clearPendingCleanup: () => unconfigured('clearPendingCleanup'), @@ -26,9 +26,10 @@ const principalResolverDefaults: PrincipalResolverService = { verifySupportImpersonationStarted: () => unconfigured('verifySupportImpersonationStarted'), }; -export const makeApiKeyServiceDouble = ( - overrides: Partial = {}, -): ApiKeyServiceContract => ({ ...apiKeyDefaults, ...overrides }); +export const makeApiKeyServiceDouble = (overrides: Partial = {}): ApiKeyServiceContract => ({ + ...apiKeyDefaults, + ...overrides, +}); export const makePrincipalResolverDouble = ( overrides: Partial = {}, diff --git a/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts b/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts index 52cb7dfe2..87ea350bb 100644 --- a/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts +++ b/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts @@ -1,13 +1,10 @@ -import { rs } from 'effect-rstest'; import { Effect } from 'effect'; -import type { - SupportAuthProvider, - SupportImpersonationStore, -} from '../../api/auth/impersonation-service.ts'; +import { rs } from 'effect-rstest'; + +import type { SupportAuthProvider, SupportImpersonationStore } from '../../api/auth/impersonation-service.ts'; import type { AuthenticationServiceContract } from '../../api/auth/service.ts'; -const unconfiguredEffect = (operation: string) => - Effect.die(`${operation} is not configured in this test`); +const unconfiguredEffect = (operation: string) => Effect.die(`${operation} is not configured in this test`); const authenticationDefaults: AuthenticationServiceContract = { availableTenants: () => unconfiguredEffect('availableTenants'), createFixtureUser: () => unconfiguredEffect('createFixtureUser'), @@ -44,7 +41,10 @@ const storeDefaults: SupportImpersonationStore = { export const makeAuthenticationServiceDouble = ( overrides: Partial = {}, -): AuthenticationServiceContract => ({ ...authenticationDefaults, ...overrides }); +): AuthenticationServiceContract => ({ + ...authenticationDefaults, + ...overrides, +}); export const makeSupportAuthProviderDouble = ( overrides: Partial = {}, diff --git a/app/apps/shell-super-app/tests/support/localized-link-double.tsx b/app/apps/shell-super-app/tests/support/localized-link-double.tsx new file mode 100644 index 000000000..6cd25b705 --- /dev/null +++ b/app/apps/shell-super-app/tests/support/localized-link-double.tsx @@ -0,0 +1,73 @@ +import type { ComponentProps, ReactElement, ReactNode } from 'react'; + +import { ultramodernLocalisedUrls } from '../../src/routes/ultramodern-route-metadata.ts'; + +/** Props the localised framework link receives from the pages under test. */ +export type LocalizedLinkDoubleProps = Omit, 'href'> & { + readonly children?: ReactNode; + readonly href?: string | undefined; + readonly params?: Readonly>; + readonly to: string; +}; + +/** One canonical navigation target a page handed to the framework link. */ +export interface LocalizedLinkCall { + readonly href: string | undefined; + readonly params: Readonly> | undefined; + readonly to: string; +} + +/** + * Recording state supplied by a single test file. Each file owns its own + * array and language holder, so navigation evidence never leaks between + * suites. + */ +export interface LocalizedLinkRecording { + readonly calls: LocalizedLinkCall[]; + readonly language: { readonly current: string }; +} + +const localisedUrlPatterns = new Map>>( + Object.entries(ultramodernLocalisedUrls).map( + ([canonicalPattern, localisedPatterns]): readonly [string, Readonly>] => [ + canonicalPattern, + { cs: localisedPatterns.cs, en: localisedPatterns.en }, + ], + ), +); + +/** + * Resolves the destination the framework link would produce, using the + * application's own canonical-to-localised route map instead of a hand-written + * expectation, so the page is proven to hand over a language-agnostic target. + */ +const resolveLocalizedHref = ( + to: string, + params: Readonly> | undefined, + language: string, +): string => { + const canonicalPattern = to.replaceAll('$', ':'); + const localisedPattern = localisedUrlPatterns.get(canonicalPattern)?.[language] ?? canonicalPattern; + const segments = localisedPattern + .split('/') + .filter(Boolean) + .map((segment) => (segment.startsWith(':') ? encodeURIComponent(params?.[segment.slice(1)] ?? '') : segment)); + return `/${[language, ...segments].join('/')}`; +}; + +/** + * Stands in for the localised framework link: it records the canonical target + * the page handed over and renders the destination the framework would resolve + * for the file's current language. + */ +export const renderLocalizedLinkDouble = ( + { children, href, params, to, ...anchorProps }: LocalizedLinkDoubleProps, + recording: LocalizedLinkRecording, +): ReactElement => { + recording.calls.push({ href, params, to }); + return ( + + {children} + + ); +}; diff --git a/app/apps/shell-super-app/tests/unit/api-index.test.ts b/app/apps/shell-super-app/tests/unit/api-index.test.ts index beda26f30..7017916a4 100644 --- a/app/apps/shell-super-app/tests/unit/api-index.test.ts +++ b/app/apps/shell-super-app/tests/unit/api-index.test.ts @@ -1,5 +1,6 @@ import { HttpServerResponse } from '@modern-js/plugin-bff/effect-edge'; import { expect, test } from 'effect-rstest'; + import { noStoreResponse } from '../../api/index.ts'; test('marks freshly issued API-key responses as non-cacheable', () => { diff --git a/app/apps/shell-super-app/tests/unit/auth-boundary.test.ts b/app/apps/shell-super-app/tests/unit/auth-boundary.test.ts index e34ac4f04..c15f04ad6 100644 --- a/app/apps/shell-super-app/tests/unit/auth-boundary.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-boundary.test.ts @@ -1,17 +1,15 @@ import fs from 'node:fs'; -import { expect, test } from 'effect-rstest'; + import { Schema } from 'effect'; +import { expect, test } from 'effect-rstest'; const workspaceRoot = new URL('../../../../', import.meta.url); const readJson = >( relativePath: string, schema: JsonSchema, ): JsonSchema['Type'] => - Schema.decodeUnknownSync(schema)( - JSON.parse(fs.readFileSync(new URL(relativePath, workspaceRoot), 'utf-8')), - ); -const readText = (relativePath: string) => - fs.readFileSync(new URL(relativePath, workspaceRoot), 'utf-8'); + Schema.decodeUnknownSync(schema)(JSON.parse(fs.readFileSync(new URL(relativePath, workspaceRoot), 'utf-8'))); +const readText = (relativePath: string) => fs.readFileSync(new URL(relativePath, workspaceRoot), 'utf-8'); const TopologySchema = Schema.Struct({ shell: Schema.Struct({ @@ -56,15 +54,9 @@ test('keeps authentication in the existing Shell/Core ownership boundary', () => test('keeps the Contacts page in the Party Registry lazy browser allowlist', () => { const source = readText('apps/shell-super-app/src/api/vertical-clients.ts'); const shellConfig = readText('apps/shell-super-app/modern.config.ts'); - const lazyRemotes = [...source.matchAll(/import\('(?[^']+)'\)/gu)].map( - (match) => match.groups?.['remote'], - ); - const componentKeys = [...source.matchAll(/componentKey: '(?[^']+)'/gu)].map( - (match) => match.groups?.['key'], - ); + const lazyRemotes = [...source.matchAll(/import\('(?[^']+)'\)/gu)].map((match) => match.groups?.['remote']); + const componentKeys = [...source.matchAll(/componentKey: '(?[^']+)'/gu)].map((match) => match.groups?.['key']); expect(lazyRemotes).toEqual(['partyRegistry/PageContacts']); expect(componentKeys).toEqual(['party.registry.page-contacts']); - expect(shellConfig).toContain( - 'new rspack.NormalModuleReplacementPlugin(\n /^partyRegistry\\//u,', - ); + expect(shellConfig).toMatch(/new rspack\.NormalModuleReplacementPlugin\(\s*\/\^partyRegistry\\\/\/u,/u); }); diff --git a/app/apps/shell-super-app/tests/unit/auth-config.test.ts b/app/apps/shell-super-app/tests/unit/auth-config.test.ts index 24e2df607..e0db63767 100644 --- a/app/apps/shell-super-app/tests/unit/auth-config.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-config.test.ts @@ -1,10 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Predicate, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { parseAuthConfig } from '../../api/auth/config.ts'; -import { - GatewayIssuerConfigError, - parseGatewayIssuerConfig, -} from '../../api/auth/gateway-issuer-config.ts'; +import { GatewayIssuerConfigError, parseGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; const validEnvironment = { BETTER_AUTH_SECRET: 'a-secure-test-secret-with-more-than-32-characters', @@ -17,17 +15,19 @@ it.effect('parses trusted origins and derives local cookie security', () => Effect.gen(function* parsesOrigins() { const configuration = yield* parseAuthConfig(validEnvironment); expect(configuration.secureCookies).toBe(false); - expect(configuration.trustedOrigins).toEqual([ - 'http://localhost:3020', - 'https://preview.example.test', - ]); + expect(configuration.trustedOrigins).toEqual(['http://localhost:3020', 'https://preview.example.test']); }), ); it.effect('requires a strong secret and PostgreSQL URL in the typed error channel', () => Effect.gen(function* validatesCredentials() { const [secretError, databaseError] = yield* Effect.all( [ - Effect.flip(parseAuthConfig({ ...validEnvironment, BETTER_AUTH_SECRET: 'short' })), + Effect.flip( + parseAuthConfig({ + ...validEnvironment, + BETTER_AUTH_SECRET: 'short', + }), + ), Effect.flip( parseAuthConfig({ ...validEnvironment, diff --git a/app/apps/shell-super-app/tests/unit/auth-contract.test.ts b/app/apps/shell-super-app/tests/unit/auth-contract.test.ts index cb61ec884..82fb129ef 100644 --- a/app/apps/shell-super-app/tests/unit/auth-contract.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect, Schema, SchemaAST, Predicate, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { AuthenticationUnavailableProblemSchema, CurrentSessionSchema, @@ -43,20 +44,12 @@ const problemTag = (schema: Schema.Top): SchemaAST.LiteralValue => { }; it('publishes authentication, identity lifecycle, and gateway operations', () => { - const authenticationEndpoints = Object.keys( - ShellAuthenticationApi.groups.authentication.endpoints, - ).toSorted(); + const authenticationEndpoints = Object.keys(ShellAuthenticationApi.groups.authentication.endpoints).toSorted(); const gatewayEndpoints = Object.keys(ShellAuthenticationApi.groups.gatewayContext.endpoints); - const identityEndpoints = Object.keys( - ShellAuthenticationApi.groups.identity.endpoints, - ).toSorted(); - const legalEntityEndpoints = Object.keys( - ShellAuthenticationApi.groups.legalEntities.endpoints, - ).toSorted(); + const identityEndpoints = Object.keys(ShellAuthenticationApi.groups.identity.endpoints).toSorted(); + const legalEntityEndpoints = Object.keys(ShellAuthenticationApi.groups.legalEntities.endpoints).toSorted(); const tenantEndpoints = Object.keys(ShellAuthenticationApi.groups.tenants.endpoints).toSorted(); - const resourceEndpoints = Object.keys( - ShellAuthenticationApi.groups.resources.endpoints, - ).toSorted(); + const resourceEndpoints = Object.keys(ShellAuthenticationApi.groups.resources.endpoints).toSorted(); expect(authenticationEndpoints).toEqual(['currentSession', 'signIn', 'signOut']); expect(gatewayEndpoints).toEqual(['issueGatewayContext', 'issueApiKeyGatewayContext']); @@ -116,9 +109,7 @@ it('publishes authentication, identity lifecycle, and gateway operations', () => signInPath: '/shell-super-app-api/auth/sign-in', switchTenantPath: '/shell-super-app-api/auth/tenant/switch', }); - expect([...authenticationEndpoints, ...gatewayEndpoints].join(':')).not.toMatch( - /testing|actionKey/u, - ); + expect([...authenticationEndpoints, ...gatewayEndpoints].join(':')).not.toMatch(/testing|actionKey/u); }); it('preserves migrated Shell Problem Details wire shapes and ordered membership', () => { @@ -145,14 +136,10 @@ it('preserves migrated Shell Problem Details wire shapes and ordered membership' title: 'Rate limited', type: 'https://ontos.dev/problems/shell-rate-limited', } as const; - const decodedUnavailable = Schema.decodeUnknownSync(AuthenticationUnavailableProblemSchema)( - unavailable, - ); + const decodedUnavailable = Schema.decodeUnknownSync(AuthenticationUnavailableProblemSchema)(unavailable); expect(Schema.is(AuthenticationUnavailableProblemSchema)(decodedUnavailable)).toBe(true); expect(Struct.omit(decodedUnavailable, ['_tag'])).toEqual(Struct.omit(unavailable, ['_tag'])); - const decodedRetryable = Schema.decodeUnknownSync(TenantCapabilityUnavailableProblemSchema)( - retryable, - ); + const decodedRetryable = Schema.decodeUnknownSync(TenantCapabilityUnavailableProblemSchema)(retryable); expect(Schema.is(TenantCapabilityUnavailableProblemSchema)(decodedRetryable)).toBe(true); expect(Struct.omit(decodedRetryable, ['_tag'])).toEqual(Struct.omit(retryable, ['_tag'])); const decodedRateLimited = Schema.decodeUnknownSync(ShellRateLimitedProblemSchema)(rateLimited); @@ -163,9 +150,7 @@ it('preserves migrated Shell Problem Details wire shapes and ordered membership' onExcessProperty: 'error', })({ ...unavailable, retryable: true }), ).toThrow(); - expect( - [...ShellAuthenticationApi.groups.authentication.endpoints.signIn.error].map(problemTag), - ).toEqual([ + expect([...ShellAuthenticationApi.groups.authentication.endpoints.signIn.error].map(problemTag)).toEqual([ 'InvalidCredentialsProblem', 'OntosIdentityForbiddenProblem', 'AuthenticationUnavailableProblem', @@ -188,13 +173,18 @@ it.effect('decodes a missing identity idempotency header so handlers can return it.effect('publishes exact legal-entity endpoints with an ID-only switch payload', () => Effect.gen(function* testProgram2() { - const { availableLegalEntities, switchLegalEntity } = - ShellAuthenticationApi.groups.legalEntities.endpoints; - expect({ method: availableLegalEntities.method, path: availableLegalEntities.path }).toEqual({ + const { availableLegalEntities, switchLegalEntity } = ShellAuthenticationApi.groups.legalEntities.endpoints; + expect({ + method: availableLegalEntities.method, + path: availableLegalEntities.path, + }).toEqual({ method: 'GET', path: '/auth/legal-entities', }); - expect({ method: switchLegalEntity.method, path: switchLegalEntity.path }).toEqual({ + expect({ + method: switchLegalEntity.method, + path: switchLegalEntity.path, + }).toEqual({ method: 'POST', path: '/auth/legal-entity/switch', }); @@ -220,37 +210,41 @@ it.effect('publishes exact legal-entity endpoints with an ID-only switch payload }), ); -it.effect( - 'decodes an optional exact page entrypoint without accepting private routing fields', - () => - Effect.gen(function* testProgram3() { - expect( - yield* Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ - entrypointKey: 'contacts.core.page.customers', - importPath: 'must-not-pass', - moduleId: 'contacts.core', - routePath: '/contacts/customers', - }), - ).toEqual({ entrypointKey: 'contacts.core.page.customers', moduleId: 'contacts.core' }); - expect( - yield* Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ +it.effect('decodes an optional exact page entrypoint without accepting private routing fields', () => + Effect.gen(function* testProgram3() { + expect( + yield* Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ + entrypointKey: 'contacts.core.page.customers', + importPath: 'must-not-pass', + moduleId: 'contacts.core', + routePath: '/contacts/customers', + }), + ).toEqual({ + entrypointKey: 'contacts.core.page.customers', + moduleId: 'contacts.core', + }); + expect( + yield* Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ + moduleId: 'contacts.core', + }), + ).toEqual({ moduleId: 'contacts.core' }); + expect( + yield* Effect.flip( + Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ + entrypointKey: '../private-page', moduleId: 'contacts.core', }), - ).toEqual({ moduleId: 'contacts.core' }); - expect( - yield* Effect.flip( - Schema.decodeUnknownEffect(ResolveModuleTargetPayloadSchema)({ - entrypointKey: '../private-page', - moduleId: 'contacts.core', - }), - ), - ).toBeDefined(); - }), + ), + ).toBeDefined(); + }), ); it('publishes exact tenant methods, paths, and declared failure statuses', () => { const { availableTenants, switchTenant } = ShellAuthenticationApi.groups.tenants.endpoints; - expect({ method: availableTenants.method, path: availableTenants.path }).toEqual({ + expect({ + method: availableTenants.method, + path: availableTenants.path, + }).toEqual({ method: 'GET', path: '/auth/tenants', }); @@ -300,7 +294,9 @@ it.effect('validates tenant UUIDs and strips all non-contract fields', () => tenantId, }); const invalidPayload = yield* Effect.flip( - Schema.decodeUnknownEffect(SwitchTenantPayloadSchema)({ tenantId: 'not-a-uuid' }), + Schema.decodeUnknownEffect(SwitchTenantPayloadSchema)({ + tenantId: 'not-a-uuid', + }), ); expect(Predicate.isTagged(invalidPayload, 'SchemaError')).toBe(true); }), diff --git a/app/apps/shell-super-app/tests/unit/auth-db-client.test.ts b/app/apps/shell-super-app/tests/unit/auth-db-client.test.ts index 367f417cf..13325f4bd 100644 --- a/app/apps/shell-super-app/tests/unit/auth-db-client.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-db-client.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import type { PoolResource } from '../../api/auth/db/client.ts'; import { acquirePoolResource } from '../../api/auth/db/client.ts'; diff --git a/app/apps/shell-super-app/tests/unit/auth-schema.test.ts b/app/apps/shell-super-app/tests/unit/auth-schema.test.ts index 5724c2d17..d1c8d3398 100644 --- a/app/apps/shell-super-app/tests/unit/auth-schema.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-schema.test.ts @@ -1,5 +1,7 @@ -import { expect, test } from 'effect-rstest'; import { getColumns } from 'drizzle-orm'; +import { expect, test } from 'effect-rstest'; + +import { compareAuthCatalog, expectedAuthTableCatalog } from '../../api/auth/db/catalog.ts'; import { AUTH_SCHEMA_NAME, AUTH_TABLE_INVENTORY, @@ -8,7 +10,6 @@ import { supportImpersonationRecovery, user, } from '../../api/auth/db/schema.ts'; -import { compareAuthCatalog, expectedAuthTableCatalog } from '../../api/auth/db/catalog.ts'; test('owns the exact Better Auth model inside the auth schema', () => { expect(AUTH_SCHEMA_NAME).toBe('auth'); @@ -67,9 +68,7 @@ test('matches the generated API Key and Admin plugin persistence fields', () => 'permissions', 'metadata', ]); - expect(Object.keys(getColumns(user))).toEqual( - expect.arrayContaining(['role', 'banned', 'banReason', 'banExpires']), - ); + expect(Object.keys(getColumns(user))).toEqual(expect.arrayContaining(['role', 'banned', 'banReason', 'banExpires'])); expect(Object.keys(getColumns(session))).toEqual( expect.arrayContaining([ 'impersonatedBy', @@ -101,13 +100,7 @@ test('matches the generated API Key and Admin plugin persistence fields', () => test('reports missing and unexpected authentication tables', () => { expect( - compareAuthCatalog([ - 'auth.user', - 'auth.session', - 'auth.account', - 'auth.unexpected', - 'auth.unexpected', - ]), + compareAuthCatalog(['auth.user', 'auth.session', 'auth.account', 'auth.unexpected', 'auth.unexpected']), ).toEqual({ missing: ['auth.apikey', 'auth.support_impersonation_recovery', 'auth.verification'], unexpected: ['auth.unexpected'], diff --git a/app/apps/shell-super-app/tests/unit/browser-effect-runtime.test.ts b/app/apps/shell-super-app/tests/unit/browser-effect-runtime.test.ts index 7988b03c7..062e072bf 100644 --- a/app/apps/shell-super-app/tests/unit/browser-effect-runtime.test.ts +++ b/app/apps/shell-super-app/tests/unit/browser-effect-runtime.test.ts @@ -1,14 +1,12 @@ -import { expect, it } from 'effect-rstest'; import { Deferred, Effect, Exit, Fiber, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { browserRuntime } from '../../src/runtime/browser-effect-runtime.ts'; class ExpectedFailure extends Schema.TaggedError()('ExpectedFailure', {}) {} /** Forks on the real browser runtime, interrupting on scope close so a failed assertion leaks no fiber. */ -const forkOnBrowserRuntime = ( - program: Effect.Effect, - options?: Effect.RunOptions, -) => +const forkOnBrowserRuntime = (program: Effect.Effect, options?: Effect.RunOptions) => Effect.acquireRelease( Effect.sync(() => browserRuntime.runFork(program, options)), (fiber) => Fiber.interrupt(fiber), diff --git a/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts b/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts index 5a7f09ebf..781003a1b 100644 --- a/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts +++ b/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts @@ -1,9 +1,10 @@ -import { expect, it, rs } from 'effect-rstest'; import { Config, ConfigProvider, Effect } from 'effect'; +import { expect, it, rs } from 'effect-rstest'; + import { loadAuthConfig } from '../../api/auth/config.ts'; -import { loadGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; import { loadConfigurationProvider } from '../../api/auth/configuration-provider.ts'; import { loadEnvironmentFileProvider } from '../../api/auth/environment-file-provider.ts'; +import { loadGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; rs.mock('../../api/auth/environment-file-provider.ts', () => ({ loadEnvironmentFileProvider: rs.fn(() => Effect.succeed(ConfigProvider.fromEnvRecord({}))), @@ -12,14 +13,22 @@ rs.mock('../../api/auth/environment-file-provider.ts', () => ({ it.effect('explicit environment overrides file values and absent keys fall back to the file', () => Effect.gen(function* explicitEnvironment() { rs.mocked(loadEnvironmentFileProvider).mockReturnValue( - Effect.succeed(ConfigProvider.fromEnvRecord({ SECRET: 'file-secret', URL: 'file-url' })), + Effect.succeed( + ConfigProvider.fromEnvRecord({ + SECRET: 'file-secret', + URL: 'file-url', + }), + ), ); const result = yield* loadConfigurationProvider( { environment: { URL: 'explicit-url' }, envPath: 'fixture-path' }, () => 'unreadable', ).pipe( Effect.flatMap((provider) => - Config.all({ secret: Config.string('SECRET'), url: Config.string('URL') }).parse(provider), + Config.all({ + secret: Config.string('SECRET'), + url: Config.string('URL'), + }).parse(provider), ), ); expect(result).toEqual({ secret: 'file-secret', url: 'explicit-url' }); @@ -28,7 +37,9 @@ it.effect('explicit environment overrides file values and absent keys fall back ); const read = ( - options: { readonly environment?: Readonly>> } = {}, + options: { + readonly environment?: Readonly>>; + } = {}, ) => loadConfigurationProvider(options, () => 'unreadable').pipe( Effect.flatMap((provider) => Config.string('ONTOS_PROVIDER_TEST').parse(provider)), @@ -50,9 +61,7 @@ it.effect('an explicit empty environment does not fall through to process values it.effect('file loading failures retain the parser-specific typed error and safe reason', () => Effect.gen(function* fileFailure() { - rs.mocked(loadEnvironmentFileProvider).mockImplementation((_path, failure) => - Effect.fail(failure()), - ); + rs.mocked(loadEnvironmentFileProvider).mockImplementation((_path, failure) => Effect.fail(failure())); const authFailure = yield* Effect.flip(loadAuthConfig({ environment: {} })); const gatewayFailure = yield* Effect.flip(loadGatewayIssuerConfig({ environment: {} })); expect(authFailure.reason).toBe('Unable to load the root authentication environment'); diff --git a/app/apps/shell-super-app/tests/unit/deployment-allowlist.test.ts b/app/apps/shell-super-app/tests/unit/deployment-allowlist.test.ts index d99a2f0ee..905435715 100644 --- a/app/apps/shell-super-app/tests/unit/deployment-allowlist.test.ts +++ b/app/apps/shell-super-app/tests/unit/deployment-allowlist.test.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; - import { expect, it } from 'effect-rstest'; + import { deriveDeploymentAllowlist } from '../../api/modules/deployment-allowlist.ts'; import { createModuleDeploymentAllowlistBuildInput } from '../../module-deployment-allowlist.config.ts'; @@ -11,10 +11,7 @@ const topology = { ], }; -const overlay = ( - ontosModuleManifests: Readonly>, - environment = 'development', -) => ({ +const overlay = (ontosModuleManifests: Readonly>, environment = 'development') => ({ environment, ontosModuleManifests, schemaVersion: 1, @@ -32,10 +29,7 @@ it.effect('derives an immutable, topology-authorized and deterministically order overlay: overlay(validUrls), topology, }); - expect(allowlist.entries.map(({ appId }) => appId)).toEqual([ - 'documents-center', - 'property-registry', - ]); + expect(allowlist.entries.map(({ appId }) => appId)).toEqual(['documents-center', 'property-registry']); expect(Object.isFrozen(allowlist)).toBe(true); expect(Object.isFrozen(allowlist.entries)).toBe(true); }), @@ -44,19 +38,21 @@ it.effect('derives an immutable, topology-authorized and deterministically order it.effect.each([ ['missing topology entry', { 'property-registry': validUrls['property-registry'] }], ['unknown shell entry', { ...validUrls, 'shell-super-app': validUrls['property-registry'] }], + ['duplicate normalized URL', { ...validUrls, 'documents-center': validUrls['property-registry'] }], [ - 'duplicate normalized URL', - { ...validUrls, 'documents-center': validUrls['property-registry'] }, + 'credentials', + { + ...validUrls, + 'property-registry': 'http://user:secret@localhost:4101/.well-known/ontos-module-manifest.json', + }, ], [ - 'credentials', + 'fragment', { ...validUrls, - 'property-registry': - 'http://user:secret@localhost:4101/.well-known/ontos-module-manifest.json', + 'property-registry': `${validUrls['property-registry']}#private`, }, ], - ['fragment', { ...validUrls, 'property-registry': `${validUrls['property-registry']}#private` }], ['arbitrary path', { ...validUrls, 'property-registry': 'http://localhost:4101/private.json' }], ] as const)('rejects %s configuration without authorizing a fetch', ([_label, manifests]) => Effect.gen(function* testProgram2() { @@ -107,7 +103,9 @@ it('builds production discovery from deployment URL configuration, never the dev const productionTopology = { verticals: [ { - cloudflare: { publicUrlEnv: 'ULTRAMODERN_PUBLIC_URL_PROPERTY_REGISTRY' }, + cloudflare: { + publicUrlEnv: 'ULTRAMODERN_PUBLIC_URL_PROPERTY_REGISTRY', + }, id: 'property-registry', kind: 'vertical', }, @@ -119,9 +117,7 @@ it('builds production discovery from deployment URL configuration, never the dev 'property-registry': 'http://localhost:4101/.well-known/ontos-module-manifest.json', }), readEnvironment: (name) => - name === 'ULTRAMODERN_PUBLIC_URL_PROPERTY_REGISTRY' - ? 'https://property.example.test' - : undefined, + name === 'ULTRAMODERN_PUBLIC_URL_PROPERTY_REGISTRY' ? 'https://property.example.test' : undefined, topology: productionTopology, }); diff --git a/app/apps/shell-super-app/tests/unit/gateway-issuer.test.ts b/app/apps/shell-super-app/tests/unit/gateway-issuer.test.ts index 68429e33e..15ce453d3 100644 --- a/app/apps/shell-super-app/tests/unit/gateway-issuer.test.ts +++ b/app/apps/shell-super-app/tests/unit/gateway-issuer.test.ts @@ -1,22 +1,14 @@ -import { expect, rs, it } from 'effect-rstest'; import { Effect, Exit, Fiber, Predicate } from 'effect'; +import { expect, rs, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; import { decodeJwt, decodeProtectedHeader, exportJWK, generateKeyPair, jwtVerify } from 'jose'; + import { parseGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; import type { GatewayIssuerConfigValue } from '../../api/auth/gateway-issuer-config.ts'; -import { - GatewayIssuer, - issueGatewayContextAssertion, - makeGatewayIssuerLayer, -} from '../../api/auth/gateway-issuer.ts'; +import { GatewayIssuer, issueGatewayContextAssertion, makeGatewayIssuerLayer } from '../../api/auth/gateway-issuer.ts'; import type { GatewayIssuerLayerOptions } from '../../api/auth/gateway-issuer.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -97,8 +89,14 @@ it.effect('memoises configuration within the refresh window and issues signed as ); const [result] = yield* Effect.all( [ - issueGatewayContextAssertion({ audience: 'property-registry', principal }), - issueGatewayContextAssertion({ audience: 'property-registry', principal }), + issueGatewayContextAssertion({ + audience: 'property-registry', + principal, + }), + issueGatewayContextAssertion({ + audience: 'property-registry', + principal, + }), ], { concurrency: 2 }, ).pipe(Effect.provide(layer)); @@ -114,7 +112,11 @@ it.effect('memoises configuration within the refresh window and issues signed as ); expect(result.expiresAt).toBe(1_700_000_300); - expect(header).toEqual({ alg: 'EdDSA', kid: 'current-2026-08', typ: 'JWT' }); + expect(header).toEqual({ + alg: 'EdDSA', + kid: 'current-2026-08', + typ: 'JWT', + }); expect(claims).toEqual({ aud: 'property-registry', exp: 1_700_000_300, @@ -153,7 +155,10 @@ it.effect('shares cached configuration across concurrent valid issuances', () => const importKey = rs.spyOn(globalThis.crypto.subtle, 'importKey'); const results = yield* Effect.all( Array.from({ length: 8 }, () => - issueGatewayContextAssertion({ audience: 'property-registry', principal }), + issueGatewayContextAssertion({ + audience: 'property-registry', + principal, + }), ), { concurrency: 8 }, ).pipe(Effect.provide(layer)); @@ -197,13 +202,14 @@ it.effect('allows the next issuance after interrupting a pending key import', () }); const result = yield* Effect.gen(function* interruptedImport() { const gatewayIssuer = yield* GatewayIssuer; - const first = yield* gatewayIssuer - .issue({ audience: 'property-registry', principal }) - .pipe(Effect.forkChild); + const first = yield* gatewayIssuer.issue({ audience: 'property-registry', principal }).pipe(Effect.forkChild); yield* Effect.promise(() => started.promise); yield* Fiber.interrupt(first); expect(Exit.isFailure(yield* Fiber.await(first))).toBe(true); - return yield* gatewayIssuer.issue({ audience: 'property-registry', principal }); + return yield* gatewayIssuer.issue({ + audience: 'property-registry', + principal, + }); }).pipe(Effect.provide(makeGatewayIssuerLayer(dependencies(configuration)))); yield* Effect.promise(() => jwtVerify(result.token, publicKey, { @@ -218,10 +224,8 @@ it.effect('allows the next issuance after interrupting a pending key import', () it.effect('refreshes configuration after 30 seconds and replaces the rotated signing key', () => Effect.gen(function* testProgram6() { - const { configuration: initialConfiguration, publicKey: initialPublicKey } = - yield* makeConfiguration(); - const { configuration: generatedRotatedConfiguration, publicKey: rotatedPublicKey } = - yield* makeConfiguration(); + const { configuration: initialConfiguration, publicKey: initialPublicKey } = yield* makeConfiguration(); + const { configuration: generatedRotatedConfiguration, publicKey: rotatedPublicKey } = yield* makeConfiguration(); const rotatedConfiguration = { ...generatedRotatedConfiguration, privateJwk: { @@ -290,15 +294,16 @@ it.effect('does not cache configuration failures', () => dependencies(configuration, { loadConfig: Effect.suspend(() => { loadConfigCount += 1; - return loadConfigCount === 1 - ? parseGatewayIssuerConfig({}) - : Effect.succeed(configuration); + return loadConfigCount === 1 ? parseGatewayIssuerConfig({}) : Effect.succeed(configuration); }), }), ); const [configurationError, result] = yield* Effect.gen(function* gatewayFailureSequence() { const configurationFailure = yield* Effect.flip( - issueGatewayContextAssertion({ audience: 'property-registry', principal }), + issueGatewayContextAssertion({ + audience: 'property-registry', + principal, + }), ); const issuedResult = yield* issueGatewayContextAssertion({ audience: 'property-registry', @@ -320,12 +325,13 @@ it.effect('retries a failed key import on the next issuance', () => rs.restoreAllMocks(); }), ); - rs.spyOn(globalThis.crypto.subtle, 'importKey').mockRejectedValueOnce( - new Error('transient import failure'), - ); + rs.spyOn(globalThis.crypto.subtle, 'importKey').mockRejectedValueOnce(new Error('transient import failure')); const [error, result] = yield* Effect.gen(function* retryImport() { const failed = yield* Effect.flip( - issueGatewayContextAssertion({ audience: 'property-registry', principal }), + issueGatewayContextAssertion({ + audience: 'property-registry', + principal, + }), ); const issued = yield* issueGatewayContextAssertion({ audience: 'property-registry', @@ -350,17 +356,9 @@ it.effect('fails closed for unknown audiences and invalid Effect-managed time', const { configuration } = yield* makeConfiguration(); const audienceErrors = yield* Effect.all( [ + Effect.flip(issueGatewayContextAssertionWith({ audience: 'billing', principal }, dependencies(configuration))), Effect.flip( - issueGatewayContextAssertionWith( - { audience: 'billing', principal }, - dependencies(configuration), - ), - ), - Effect.flip( - issueGatewayContextAssertionWith( - { audience: 'property.registry', principal }, - dependencies(configuration), - ), + issueGatewayContextAssertionWith({ audience: 'property.registry', principal }, dependencies(configuration)), ), ].map((effect) => Effect.gen(function* testProgram10() { @@ -372,7 +370,9 @@ it.effect('fails closed for unknown audiences and invalid Effect-managed time', const timeError = yield* Effect.flip( issueGatewayContextAssertionWith( { audience: 'property-registry', principal }, - dependencies(configuration, { currentTimeSeconds: Effect.succeed(-1) }), + dependencies(configuration, { + currentTimeSeconds: Effect.succeed(-1), + }), ), ); @@ -432,7 +432,14 @@ it.effect('rejects missing configuration, HMAC keys, non-Ed25519 keys, and missi Effect.gen(function* testProgram13() { const invalidJwks = [ undefined, - { alg: 'HS256', d: 'secret', kid: 'hmac', kty: 'oct', use: 'sig', x: 'secret' }, + { + alg: 'HS256', + d: 'secret', + kid: 'hmac', + kty: 'oct', + use: 'sig', + x: 'secret', + }, { alg: 'EdDSA', crv: 'X25519', @@ -442,7 +449,14 @@ it.effect('rejects missing configuration, HMAC keys, non-Ed25519 keys, and missi use: 'sig', x: 'public', }, - { alg: 'EdDSA', crv: 'Ed25519', d: 'private', kty: 'OKP', use: 'sig', x: 'public' }, + { + alg: 'EdDSA', + crv: 'Ed25519', + d: 'private', + kty: 'OKP', + use: 'sig', + x: 'public', + }, ]; const errors = yield* Effect.all( @@ -465,8 +479,6 @@ it.effect('rejects missing configuration, HMAC keys, non-Ed25519 keys, and missi ), { concurrency: 'unbounded' }, ); - expect(errors.every((error) => Predicate.isTagged(error, 'GatewayIssuerConfigError'))).toBe( - true, - ); + expect(errors.every((error) => Predicate.isTagged(error, 'GatewayIssuerConfigError'))).toBe(true); }), ); diff --git a/app/apps/shell-super-app/tests/unit/identity-lifecycle.test.ts b/app/apps/shell-super-app/tests/unit/identity-lifecycle.test.ts index fc8ea0f66..7e398cddc 100644 --- a/app/apps/shell-super-app/tests/unit/identity-lifecycle.test.ts +++ b/app/apps/shell-super-app/tests/unit/identity-lifecycle.test.ts @@ -1,10 +1,7 @@ -import { expect, it } from 'effect-rstest'; -import { - ActionTransactionError, - IdentityTargetInvalidError, - PrincipalBindingMissingError, -} from '@app/core-runtime'; +import { ActionTransactionError, IdentityTargetInvalidError, PrincipalBindingMissingError } from '@app/core-runtime'; import { Effect, Redacted, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { ApiKeyProviderUnavailableError, ApiKeyStateInconsistentError, @@ -18,10 +15,7 @@ import { actionSuccess, makeActionRuntimeDouble, } from '../support/action-runtime-double.ts'; -import { - makeApiKeyServiceDouble, - makePrincipalResolverDouble, -} from '../support/identity-service-doubles.ts'; +import { makeApiKeyServiceDouble, makePrincipalResolverDouble } from '../support/identity-service-doubles.ts'; const principal = { authBindingId: '00000000-0000-4000-8000-000000000002', @@ -41,13 +35,19 @@ const issued = { }; const resolver = makePrincipalResolverDouble({ loadApiKeyBindingForAdministration: () => - Effect.succeed({ providerSubjectId: 'old-provider-key-id', status: 'active' }), + Effect.succeed({ + providerSubjectId: 'old-provider-key-id', + status: 'active', + }), }); const actionTransactionFailure = (reason: string) => new ActionTransactionError({ code: 'action_transaction_failed', reason }); const pendingMetadata = ( lifecycleOperationId: string, - scope: { readonly issuerPrincipalId?: string; readonly tenantId?: string } = {}, + scope: { + readonly issuerPrincipalId?: string; + readonly tenantId?: string; + } = {}, ) => JSON.stringify({ issuerPrincipalId: scope.issuerPrincipalId ?? principal.principalId, @@ -198,35 +198,33 @@ it('reconciles only expired pending leases in the trusted tenant and issuer scop expect(selected).toEqual(['abandoned-key']); }); -it.effect( - 'returns a secret only after bind succeeds and strips the private provider key identifier', - () => - Effect.gen(function* testProgram4() { - const service = makeIdentityLifecycleService( - makeActionRuntimeDouble([ - actionSuccess({ - authBindingId: '00000000-0000-4000-8000-000000000004', - status: 'active', - }), - ]).runtime, - makeApiKeyServiceDouble({ - clearPendingCleanup: () => Effect.void, - issue: () => Effect.succeed(issued), - pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), - setEnabled: () => Effect.succeed(issued), +it.effect('returns a secret only after bind succeeds and strips the private provider key identifier', () => + Effect.gen(function* testProgram4() { + const service = makeIdentityLifecycleService( + makeActionRuntimeDouble([ + actionSuccess({ + authBindingId: '00000000-0000-4000-8000-000000000004', + status: 'active', }), - resolver, - ); + ]).runtime, + makeApiKeyServiceDouble({ + clearPendingCleanup: () => Effect.void, + issue: () => Effect.succeed(issued), + pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), + setEnabled: () => Effect.succeed(issued), + }), + resolver, + ); - const result = yield* service.issue({ - correlationId: 'correlation-2', - idempotencyKey: 'issue-2', - principal, - requestHeaders: new Headers(), - }); - expect(result.secret).toBe('ontos-secret'); - expect(Object.hasOwn(result, 'providerKeyId')).toBe(false); - }), + const result = yield* service.issue({ + correlationId: 'correlation-2', + idempotencyKey: 'issue-2', + principal, + requestHeaders: new Headers(), + }); + expect(result.secret).toBe('ontos-secret'); + expect(Object.hasOwn(result, 'providerKeyId')).toBe(false); + }), ); it.effect('revokes the replacement before failing when closing the old Core binding fails', () => @@ -274,159 +272,153 @@ it.effect('revokes the replacement before failing when closing the old Core bind }), ); -it.effect( - 'returns the replacement secret when both old closure and replacement rollback are unavailable', - () => - Effect.gen(function* testProgram6() { - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ - authBindingId: '00000000-0000-4000-8000-000000000004', - status: 'active', - }), - actionCoreFailure(actionTransactionFailure('Core unavailable')), - actionCoreFailure(actionTransactionFailure('Core unavailable')), - ]); - const service = makeIdentityLifecycleService( - actionRuntime.runtime, - makeApiKeyServiceDouble({ - clearPendingCleanup: () => Effect.void, - issue: () => Effect.succeed(issued), - pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), - }), - resolver, - ); +it.effect('returns the replacement secret when both old closure and replacement rollback are unavailable', () => + Effect.gen(function* testProgram6() { + const actionRuntime = makeActionRuntimeDouble([ + actionSuccess({ + authBindingId: '00000000-0000-4000-8000-000000000004', + status: 'active', + }), + actionCoreFailure(actionTransactionFailure('Core unavailable')), + actionCoreFailure(actionTransactionFailure('Core unavailable')), + ]); + const service = makeIdentityLifecycleService( + actionRuntime.runtime, + makeApiKeyServiceDouble({ + clearPendingCleanup: () => Effect.void, + issue: () => Effect.succeed(issued), + pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), + }), + resolver, + ); - const result = yield* service.rotate({ - correlationId: 'correlation-4', - idempotencyKey: 'rotate-2', - oldAuthBindingId: '00000000-0000-4000-8000-000000000005', - principal, - reason: 'Scheduled credential rotation', - requestHeaders: new Headers(), - }); - expect(result.secret).toBe('ontos-secret'); - expect(result.cleanupPending).toBe(true); - expect(actionRuntime.invocationCount()).toBe(3); - }), + const result = yield* service.rotate({ + correlationId: 'correlation-4', + idempotencyKey: 'rotate-2', + oldAuthBindingId: '00000000-0000-4000-8000-000000000005', + principal, + reason: 'Scheduled credential rotation', + requestHeaders: new Headers(), + }); + expect(result.secret).toBe('ontos-secret'); + expect(result.cleanupPending).toBe(true); + expect(actionRuntime.invocationCount()).toBe(3); + }), ); -it.effect( - 'returns the replacement secret when old Core closure committed but provider state is unavailable', - () => - Effect.gen(function* testProgram7() { - let resolverCalls = 0; - const providerUnavailable = new ApiKeyProviderUnavailableError({ - code: 'api_key_provider_unavailable', - reason: 'The provider is unavailable', - }); - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ - authBindingId: '00000000-0000-4000-8000-000000000004', - status: 'active', - }), - actionSuccess({ previousStatus: 'active', status: 'revoked' }), - ]); - const service = makeIdentityLifecycleService( - actionRuntime.runtime, - makeApiKeyServiceDouble({ - clearPendingCleanup: () => Effect.void, - issue: () => Effect.succeed(issued), - metadata: () => Effect.fail(providerUnavailable), - pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), - setEnabled: (keyId, enabled) => - keyId === 'old-provider-key-id' && !enabled - ? Effect.fail(providerUnavailable) - : Effect.succeed({ ...issued, providerKeyId: keyId }), - }), - makePrincipalResolverDouble({ - loadApiKeyBindingForAdministration: () => { - resolverCalls += 1; - return Effect.succeed({ - providerSubjectId: 'old-provider-key-id', - status: resolverCalls === 1 ? 'active' : 'revoked', - }); - }, - }), - ); +it.effect('returns the replacement secret when old Core closure committed but provider state is unavailable', () => + Effect.gen(function* testProgram7() { + let resolverCalls = 0; + const providerUnavailable = new ApiKeyProviderUnavailableError({ + code: 'api_key_provider_unavailable', + reason: 'The provider is unavailable', + }); + const actionRuntime = makeActionRuntimeDouble([ + actionSuccess({ + authBindingId: '00000000-0000-4000-8000-000000000004', + status: 'active', + }), + actionSuccess({ previousStatus: 'active', status: 'revoked' }), + ]); + const service = makeIdentityLifecycleService( + actionRuntime.runtime, + makeApiKeyServiceDouble({ + clearPendingCleanup: () => Effect.void, + issue: () => Effect.succeed(issued), + metadata: () => Effect.fail(providerUnavailable), + pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), + setEnabled: (keyId, enabled) => + keyId === 'old-provider-key-id' && !enabled + ? Effect.fail(providerUnavailable) + : Effect.succeed({ ...issued, providerKeyId: keyId }), + }), + makePrincipalResolverDouble({ + loadApiKeyBindingForAdministration: () => { + resolverCalls += 1; + return Effect.succeed({ + providerSubjectId: 'old-provider-key-id', + status: resolverCalls === 1 ? 'active' : 'revoked', + }); + }, + }), + ); - const result = yield* service.rotate({ - correlationId: 'correlation-old-core-closed', - idempotencyKey: 'rotate-old-core-closed', - oldAuthBindingId: '00000000-0000-4000-8000-000000000005', - principal, - reason: 'Scheduled credential rotation', - requestHeaders: new Headers(), - }); + const result = yield* service.rotate({ + correlationId: 'correlation-old-core-closed', + idempotencyKey: 'rotate-old-core-closed', + oldAuthBindingId: '00000000-0000-4000-8000-000000000005', + principal, + reason: 'Scheduled credential rotation', + requestHeaders: new Headers(), + }); - expect(result.secret).toBe('ontos-secret'); - expect(result.cleanupPending).toBe(true); - expect(actionRuntime.invocationCount()).toBe(2); - expect(resolverCalls).toBe(2); - }), + expect(result.secret).toBe('ontos-secret'); + expect(result.cleanupPending).toBe(true); + expect(actionRuntime.invocationCount()).toBe(2); + expect(resolverCalls).toBe(2); + }), ); -it.effect( - 'does not return a replacement secret after rollback definitely revoked its Core binding', - () => - Effect.gen(function* testProgram8() { - let replacementReads = 0; - const providerUnavailable = new ApiKeyProviderUnavailableError({ - code: 'api_key_provider_unavailable', - reason: 'The provider is unavailable', - }); - const oldFailure = new IdentityTargetInvalidError({ - code: 'identity_target_invalid', - reason: 'The old binding could not be closed', - }); - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ - authBindingId: '00000000-0000-4000-8000-000000000004', - status: 'active', - }), - actionDomainFailure(oldFailure), - actionSuccess({ previousStatus: 'active', status: 'revoked' }), - ]); - const service = makeIdentityLifecycleService( - actionRuntime.runtime, - makeApiKeyServiceDouble({ - clearPendingCleanup: () => Effect.void, - issue: () => Effect.succeed(issued), - metadata: () => Effect.fail(providerUnavailable), - pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), - setEnabled: () => Effect.fail(providerUnavailable), - }), - makePrincipalResolverDouble({ - loadApiKeyBindingForAdministration: (input) => { - if (input.authBindingId === '00000000-0000-4000-8000-000000000004') { - replacementReads += 1; - return Effect.succeed({ - providerSubjectId: 'replacement-provider-key-id', - status: replacementReads === 1 ? 'active' : 'revoked', - }); - } +it.effect('does not return a replacement secret after rollback definitely revoked its Core binding', () => + Effect.gen(function* testProgram8() { + let replacementReads = 0; + const providerUnavailable = new ApiKeyProviderUnavailableError({ + code: 'api_key_provider_unavailable', + reason: 'The provider is unavailable', + }); + const oldFailure = new IdentityTargetInvalidError({ + code: 'identity_target_invalid', + reason: 'The old binding could not be closed', + }); + const actionRuntime = makeActionRuntimeDouble([ + actionSuccess({ + authBindingId: '00000000-0000-4000-8000-000000000004', + status: 'active', + }), + actionDomainFailure(oldFailure), + actionSuccess({ previousStatus: 'active', status: 'revoked' }), + ]); + const service = makeIdentityLifecycleService( + actionRuntime.runtime, + makeApiKeyServiceDouble({ + clearPendingCleanup: () => Effect.void, + issue: () => Effect.succeed(issued), + metadata: () => Effect.fail(providerUnavailable), + pendingCleanup: () => Effect.succeed({ hasMore: false, providerKeyIds: [] }), + setEnabled: () => Effect.fail(providerUnavailable), + }), + makePrincipalResolverDouble({ + loadApiKeyBindingForAdministration: (input) => { + if (input.authBindingId === '00000000-0000-4000-8000-000000000004') { + replacementReads += 1; return Effect.succeed({ - providerSubjectId: 'old-provider-key-id', - status: 'active', + providerSubjectId: 'replacement-provider-key-id', + status: replacementReads === 1 ? 'active' : 'revoked', }); - }, - }), - ); + } + return Effect.succeed({ + providerSubjectId: 'old-provider-key-id', + status: 'active', + }); + }, + }), + ); - const failure = yield* Effect.flip( - service.rotate({ - correlationId: 'correlation-definite-replacement-rollback', - idempotencyKey: 'definite-replacement-rollback', - oldAuthBindingId: '00000000-0000-4000-8000-000000000005', - principal, - reason: 'Scheduled credential rotation', - requestHeaders: new Headers(), - }), - ); + const failure = yield* Effect.flip( + service.rotate({ + correlationId: 'correlation-definite-replacement-rollback', + idempotencyKey: 'definite-replacement-rollback', + oldAuthBindingId: '00000000-0000-4000-8000-000000000005', + principal, + reason: 'Scheduled credential rotation', + requestHeaders: new Headers(), + }), + ); - expect(failure).toBe(oldFailure); - expect(actionRuntime.invocationCount()).toBe(3); - expect(replacementReads).toBe(2); - }), + expect(failure).toBe(oldFailure); + expect(actionRuntime.invocationCount()).toBe(3); + expect(replacementReads).toBe(2); + }), ); it.effect('cleans one bounded pending batch and requires a retry before issuing another key', () => @@ -441,7 +433,11 @@ it.effect('cleans one bounded pending batch and requires a retry before issuing issueCalls += 1; return Effect.succeed(issued); }, - pendingCleanup: () => Effect.succeed({ hasMore: true, providerKeyIds: ['bounded-orphan'] }), + pendingCleanup: () => + Effect.succeed({ + hasMore: true, + providerKeyIds: ['bounded-orphan'], + }), setEnabled: (keyId, enabled) => { if (!enabled) { disabled.push(keyId); @@ -480,7 +476,10 @@ it.effect('retries provider cleanup without repeating an already committed Core }), makePrincipalResolverDouble({ loadApiKeyBindingForAdministration: () => - Effect.succeed({ providerSubjectId: 'old-provider-key-id', status: 'revoked' }), + Effect.succeed({ + providerSubjectId: 'old-provider-key-id', + status: 'revoked', + }), }), ); @@ -498,91 +497,88 @@ it.effect('retries provider cleanup without repeating an already committed Core }), ); -it.effect( - 'preserves provider metadata failure after a safe Core disable instead of fabricating state', - () => - Effect.gen(function* testProgram11() { - const metadataFailure = new ApiKeyStateInconsistentError({ - code: 'api_key_state_inconsistent', - reason: 'The provider key row is missing', - }); - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ previousStatus: 'active', status: 'disabled' }), - ]); - const service = makeIdentityLifecycleService( - actionRuntime.runtime, - makeApiKeyServiceDouble({ - metadata: () => Effect.fail(metadataFailure), - setEnabled: () => - Effect.fail( - new ApiKeyProviderUnavailableError({ - code: 'api_key_provider_unavailable', - reason: 'The provider is unavailable', - }), - ), - }), - resolver, - ); - - const failure = yield* Effect.flip( - service.setStatus({ - authBindingId: '00000000-0000-4000-8000-000000000005', - correlationId: 'correlation-provider-metadata-failure', - expectedStatus: 'active', - idempotencyKey: 'disable-provider-metadata-failure', - newStatus: 'disabled', - principal, - reason: 'Disable a missing provider key', - }), - ); +it.effect('preserves provider metadata failure after a safe Core disable instead of fabricating state', () => + Effect.gen(function* testProgram11() { + const metadataFailure = new ApiKeyStateInconsistentError({ + code: 'api_key_state_inconsistent', + reason: 'The provider key row is missing', + }); + const actionRuntime = makeActionRuntimeDouble([actionSuccess({ previousStatus: 'active', status: 'disabled' })]); + const service = makeIdentityLifecycleService( + actionRuntime.runtime, + makeApiKeyServiceDouble({ + metadata: () => Effect.fail(metadataFailure), + setEnabled: () => + Effect.fail( + new ApiKeyProviderUnavailableError({ + code: 'api_key_provider_unavailable', + reason: 'The provider is unavailable', + }), + ), + }), + resolver, + ); - expect(failure).toBe(metadataFailure); - expect(actionRuntime.invocationCount()).toBe(1); - }), + const failure = yield* Effect.flip( + service.setStatus({ + authBindingId: '00000000-0000-4000-8000-000000000005', + correlationId: 'correlation-provider-metadata-failure', + expectedStatus: 'active', + idempotencyKey: 'disable-provider-metadata-failure', + newStatus: 'disabled', + principal, + reason: 'Disable a missing provider key', + }), + ); + + expect(failure).toBe(metadataFailure); + expect(actionRuntime.invocationCount()).toBe(1); + }), ); -it.effect( - 'reconciles a provider key left pending by failed bind compensation before retrying issue', - () => - Effect.gen(function* testProgram12() { - const disabled: string[] = []; - const cleared: string[] = []; - const service = makeIdentityLifecycleService( - makeActionRuntimeDouble([ - actionSuccess({ - authBindingId: '00000000-0000-4000-8000-000000000004', - status: 'active', - }), - ]).runtime, - makeApiKeyServiceDouble({ - clearPendingCleanup: (keyId) => { - cleared.push(keyId); - return Effect.void; - }, - issue: () => Effect.succeed(issued), - pendingCleanup: () => - Effect.succeed({ hasMore: false, providerKeyIds: ['orphan-provider-key-id'] }), - setEnabled: (keyId, enabled) => { - if (!enabled) { - disabled.push(keyId); - } - return Effect.succeed({ ...issued, providerKeyId: keyId }); - }, - }), - makePrincipalResolverDouble({ - resolveBetterAuthApiKey: () => Effect.fail(new PrincipalBindingMissingError()), +it.effect('reconciles a provider key left pending by failed bind compensation before retrying issue', () => + Effect.gen(function* testProgram12() { + const disabled: string[] = []; + const cleared: string[] = []; + const service = makeIdentityLifecycleService( + makeActionRuntimeDouble([ + actionSuccess({ + authBindingId: '00000000-0000-4000-8000-000000000004', + status: 'active', }), - ); + ]).runtime, + makeApiKeyServiceDouble({ + clearPendingCleanup: (keyId) => { + cleared.push(keyId); + return Effect.void; + }, + issue: () => Effect.succeed(issued), + pendingCleanup: () => + Effect.succeed({ + hasMore: false, + providerKeyIds: ['orphan-provider-key-id'], + }), + setEnabled: (keyId, enabled) => { + if (!enabled) { + disabled.push(keyId); + } + return Effect.succeed({ ...issued, providerKeyId: keyId }); + }, + }), + makePrincipalResolverDouble({ + resolveBetterAuthApiKey: () => Effect.fail(new PrincipalBindingMissingError()), + }), + ); - const result = yield* service.issue({ - correlationId: 'correlation-6', - idempotencyKey: 'issue-retry', - principal, - requestHeaders: new Headers(), - }); + const result = yield* service.issue({ + correlationId: 'correlation-6', + idempotencyKey: 'issue-retry', + principal, + requestHeaders: new Headers(), + }); - expect(result.secret).toBe('ontos-secret'); - expect(disabled).toEqual(['orphan-provider-key-id']); - expect(cleared).toEqual(['orphan-provider-key-id', 'private-provider-key-id']); - }), + expect(result.secret).toBe('ontos-secret'); + expect(disabled).toEqual(['orphan-provider-key-id']); + expect(cleared).toEqual(['orphan-provider-key-id', 'private-provider-key-id']); + }), ); diff --git a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts index 848040be4..672e445b2 100644 --- a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts +++ b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts @@ -1,6 +1,3 @@ -import { makeContextAccessDouble } from '../support/context-access-double.ts'; -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; import { ActionRuntime, ActionAlreadyCommitted, @@ -18,6 +15,12 @@ import type { } from '@app/core-runtime'; import { makeSignature } from 'better-auth/crypto'; import { Context, Effect, Match, Option } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + +import { PrincipalManagementRepository } from '../../../../packages/core-runtime/src/auth/principal-management.ts'; +import type { PrincipalManagementRepositoryService } from '../../../../packages/core-runtime/src/auth/principal-management.ts'; +import { AuthConfig } from '../../api/auth/config.ts'; import type { SupportAuthProvider, SupportImpersonationStore, @@ -29,17 +32,15 @@ import { SupportImpersonationCorrelationId, SupportImpersonationStoreService, } from '../../api/auth/impersonation-service.ts'; -import { AuthConfig } from '../../api/auth/config.ts'; import { AuthenticationService } from '../../api/auth/service.ts'; import type { AuthenticationServiceContract } from '../../api/auth/service.ts'; -import { PrincipalManagementRepository } from '../../../../packages/core-runtime/src/auth/principal-management.ts'; -import type { PrincipalManagementRepositoryService } from '../../../../packages/core-runtime/src/auth/principal-management.ts'; import { actionCoreFailure, actionDomainFailure, actionSuccess, makeActionRuntimeDouble, } from '../support/action-runtime-double.ts'; +import { makeContextAccessDouble } from '../support/context-access-double.ts'; import { makePrincipalResolverDouble } from '../support/identity-service-doubles.ts'; import { makeAuthenticationServiceDouble, @@ -70,8 +71,7 @@ const principalManagementRepository: PrincipalManagementRepositoryService = { changePrincipalStatus: () => unconfiguredPrincipalManagement('changePrincipalStatus'), createNonHumanPrincipal: () => unconfiguredPrincipalManagement('createNonHumanPrincipal'), setApiKeyBindingStatus: () => unconfiguredPrincipalManagement('setApiKeyBindingStatus'), - validateSupportImpersonation: () => - unconfiguredPrincipalManagement('validateSupportImpersonation'), + validateSupportImpersonation: () => unconfiguredPrincipalManagement('validateSupportImpersonation'), }; const providePrincipalManagementRepository = Effect.provideService( PrincipalManagementRepository, @@ -227,12 +227,7 @@ it.effect('preserves definite requested-checkpoint errors for their declared HTT requestHeaders: new Headers(), targetPrincipalId, }) - .pipe( - Effect.provideService( - SupportImpersonationCorrelationId, - `correlation-${failure._tag}`, - ), - ), + .pipe(Effect.provideService(SupportImpersonationCorrelationId, `correlation-${failure._tag}`)), ); expect(actual).toBe(failure); @@ -310,12 +305,7 @@ it.effect('removes the provider session and recovery when started evidence canno requestHeaders: new Headers(), targetPrincipalId, }) - .pipe( - Effect.provideService( - SupportImpersonationCorrelationId, - 'correlation-started-compensation', - ), - ), + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-started-compensation')), ); expect(failure).toBe(startedFailure); @@ -324,117 +314,111 @@ it.effect('removes the provider session and recovery when started evidence canno }), ); -it.effect( - 'persists stop recovery before provider restoration and returns restored cookies on evidence failure', - () => - Effect.gen(function* testProgram3() { - let recovery: SupportRecoveryRecord | undefined; - let resolverCalled = false; - const transactionFailure = new ActionTransactionError({ - code: 'action_transaction_failed', - reason: 'The stopped checkpoint transaction failed', - }); - const service = makeService({ - actionRuntime: makeActionRuntimeDouble([actionCoreFailure(transactionFailure)]).runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: provider(true), - resolver: makePrincipalResolverDouble({ - resolveBetterAuthUserForTenant: () => { - resolverCalled = true; - return Effect.die('disabled principal'); - }, - }), - store: makeSupportImpersonationStoreDouble({ - deleteSession: () => Effect.void, - insertRecovery: (value) => - Effect.sync(() => { - recovery = value; - }), - }), - supportRecoveryPrincipal, - }); - - const result = yield* service - .stop({ - idempotencyKey: 'stop-request-1', - requestHeaders: new Headers(), - }) - .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-1')); +it.effect('persists stop recovery before provider restoration and returns restored cookies on evidence failure', () => + Effect.gen(function* testProgram3() { + let recovery: SupportRecoveryRecord | undefined; + let resolverCalled = false; + const transactionFailure = new ActionTransactionError({ + code: 'action_transaction_failed', + reason: 'The stopped checkpoint transaction failed', + }); + const service = makeService({ + actionRuntime: makeActionRuntimeDouble([actionCoreFailure(transactionFailure)]).runtime, + authentication: makeAuthenticationServiceDouble(), + configuration, + provider: provider(true), + resolver: makePrincipalResolverDouble({ + resolveBetterAuthUserForTenant: () => { + resolverCalled = true; + return Effect.die('disabled principal'); + }, + }), + store: makeSupportImpersonationStoreDouble({ + deleteSession: () => Effect.void, + insertRecovery: (value) => + Effect.sync(() => { + recovery = value; + }), + }), + supportRecoveryPrincipal, + }); - expect(recovery).toEqual( - expect.objectContaining({ - impersonationSessionId, - originalAuthBindingId, - originalPrincipalId, - targetPrincipalId, - tenantId, - }), - ); - expect(result.checkpointPending).toBe(true); - expect(result.setCookieHeaders).toEqual(['session=restored; Path=/; HttpOnly']); - expect(resolverCalled).toBe(false); - }), -); + const result = yield* service + .stop({ + idempotencyKey: 'stop-request-1', + requestHeaders: new Headers(), + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-1')); -it.effect( - 'terminates the target session before retrying stopped evidence from the restored session', - () => - Effect.gen(function* testProgram4() { - const recovery = { - actionId: 'impersonation-action', - createdAt: new Date('2026-08-09T00:00:00.000Z'), + expect(recovery).toEqual( + expect.objectContaining({ impersonationSessionId, originalAuthBindingId, originalPrincipalId, - originalSessionId: restoredSessionId, - reason: 'Investigate support request', targetPrincipalId, tenantId, - }; - let recoveryDeleted = false; - let targetSessionActive = true; - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ checkpoint: 'stopped', recorded: true }), - ]); - const service = makeService({ - actionRuntime: actionRuntime.runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: provider(false), - resolver: makePrincipalResolverDouble(), - store: makeSupportImpersonationStoreDouble({ - deleteRecovery: () => - Effect.sync(() => { - recoveryDeleted = true; - }), - deleteSession: () => - Effect.sync(() => { - targetSessionActive = false; - }), - loadRecoveries: () => Effect.succeed([recovery]), - }), - supportRecoveryPrincipal, - }); + }), + ); + expect(result.checkpointPending).toBe(true); + expect(result.setCookieHeaders).toEqual(['session=restored; Path=/; HttpOnly']); + expect(resolverCalled).toBe(false); + }), +); - const result = yield* service - .stop({ - idempotencyKey: 'stop-request-2', - requestHeaders: new Headers(), - }) - .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-2')); +it.effect('terminates the target session before retrying stopped evidence from the restored session', () => + Effect.gen(function* testProgram4() { + const recovery = { + actionId: 'impersonation-action', + createdAt: new Date('2026-08-09T00:00:00.000Z'), + impersonationSessionId, + originalAuthBindingId, + originalPrincipalId, + originalSessionId: restoredSessionId, + reason: 'Investigate support request', + targetPrincipalId, + tenantId, + }; + let recoveryDeleted = false; + let targetSessionActive = true; + const actionRuntime = makeActionRuntimeDouble([actionSuccess({ checkpoint: 'stopped', recorded: true })]); + const service = makeService({ + actionRuntime: actionRuntime.runtime, + authentication: makeAuthenticationServiceDouble(), + configuration, + provider: provider(false), + resolver: makePrincipalResolverDouble(), + store: makeSupportImpersonationStoreDouble({ + deleteRecovery: () => + Effect.sync(() => { + recoveryDeleted = true; + }), + deleteSession: () => + Effect.sync(() => { + targetSessionActive = false; + }), + loadRecoveries: () => Effect.succeed([recovery]), + }), + supportRecoveryPrincipal, + }); - expect(actionRuntime.payloads[0]).toEqual({ - checkpoint: 'stopped', - originalPrincipalId, - reason: 'Investigate support request', - sessionRef: `better-auth-session:${impersonationSessionId}`, - targetPrincipalId, - }); - expect(result.checkpointPending).toBe(false); - expect(targetSessionActive).toBe(false); - expect(recoveryDeleted).toBe(true); - }), + const result = yield* service + .stop({ + idempotencyKey: 'stop-request-2', + requestHeaders: new Headers(), + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-2')); + + expect(actionRuntime.payloads[0]).toEqual({ + checkpoint: 'stopped', + originalPrincipalId, + reason: 'Investigate support request', + sessionRef: `better-auth-session:${impersonationSessionId}`, + targetPrincipalId, + }); + expect(result.checkpointPending).toBe(false); + expect(targetSessionActive).toBe(false); + expect(recoveryDeleted).toBe(true); + }), ); it.effect('completes every pending checkpoint correlated to the restored session', () => @@ -497,7 +481,9 @@ it.effect('completes every pending checkpoint correlated to the restored session .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-3')); expect(actionRuntime.payloads).toEqual([ - expect.objectContaining({ sessionRef: `better-auth-session:${impersonationSessionId}` }), + expect.objectContaining({ + sessionRef: `better-auth-session:${impersonationSessionId}`, + }), expect.objectContaining({ sessionRef: `better-auth-session:${secondImpersonationSessionId}`, }), @@ -507,96 +493,85 @@ it.effect('completes every pending checkpoint correlated to the restored session }), ); -it.effect( - 'persists and completes stopped evidence on the first stop after impersonation expiry', - () => - Effect.gen(function* testProgram6() { - const expiredToken = 'expired-impersonation-token'; - const signedToken = encodeURIComponent( - `${expiredToken}.${yield* Effect.promise(() => makeSignature(expiredToken, configuration.secret))}`, - ); - let persistedRecovery: SupportRecoveryRecord | undefined; - let deleteCalls = 0; - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ checkpoint: 'stopped', recorded: true }), - ]); - const service = makeService({ - actionRuntime: actionRuntime.runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: makeSupportAuthProviderDouble({ - getSession: () => Promise.resolve({ headers: new Headers(), response: null }), - }), - resolver: makePrincipalResolverDouble(), - store: makeSupportImpersonationStoreDouble({ - deleteRecovery: () => - Effect.sync(() => { - deleteCalls += 1; - }), - deleteSession: () => - Effect.sync(() => { - deleteCalls += 1; - }), - insertRecovery: (value) => - Effect.sync(() => { - persistedRecovery = value; - }), - loadExpiredRecovery: () => - Effect.succeed( - Option.some({ - actionId: 'expired-impersonation-action', - impersonationSessionId, - originalAuthBindingId, - originalPrincipalId, - originalSessionId: restoredSessionId, - reason: 'Investigate support request', - targetPrincipalId, - tenantId, - }), - ), - }), - supportRecoveryPrincipal, - }); - - const result = yield* service - .stop({ - idempotencyKey: 'first-expired-stop', - requestHeaders: new Headers({ - cookie: `better-auth.session_token=${signedToken}`, +it.effect('persists and completes stopped evidence on the first stop after impersonation expiry', () => + Effect.gen(function* testProgram6() { + const expiredToken = 'expired-impersonation-token'; + const signedToken = encodeURIComponent( + `${expiredToken}.${yield* Effect.promise(() => makeSignature(expiredToken, configuration.secret))}`, + ); + let persistedRecovery: SupportRecoveryRecord | undefined; + let deleteCalls = 0; + const actionRuntime = makeActionRuntimeDouble([actionSuccess({ checkpoint: 'stopped', recorded: true })]); + const service = makeService({ + actionRuntime: actionRuntime.runtime, + authentication: makeAuthenticationServiceDouble(), + configuration, + provider: makeSupportAuthProviderDouble({ + getSession: () => Promise.resolve({ headers: new Headers(), response: null }), + }), + resolver: makePrincipalResolverDouble(), + store: makeSupportImpersonationStoreDouble({ + deleteRecovery: () => + Effect.sync(() => { + deleteCalls += 1; }), - }) - .pipe( - Effect.provideService( - SupportImpersonationCorrelationId, - 'correlation-first-expired-stop', + deleteSession: () => + Effect.sync(() => { + deleteCalls += 1; + }), + insertRecovery: (value) => + Effect.sync(() => { + persistedRecovery = value; + }), + loadExpiredRecovery: () => + Effect.succeed( + Option.some({ + actionId: 'expired-impersonation-action', + impersonationSessionId, + originalAuthBindingId, + originalPrincipalId, + originalSessionId: restoredSessionId, + reason: 'Investigate support request', + targetPrincipalId, + tenantId, + }), ), - ); + }), + supportRecoveryPrincipal, + }); - expect(persistedRecovery).toEqual( - expect.objectContaining({ - actionId: 'expired-impersonation-action', - impersonationSessionId, - originalSessionId: restoredSessionId, + const result = yield* service + .stop({ + idempotencyKey: 'first-expired-stop', + requestHeaders: new Headers({ + cookie: `better-auth.session_token=${signedToken}`, }), - ); - expect(actionRuntime.payloads[0]).toEqual({ - checkpoint: 'stopped', - originalPrincipalId, - reason: 'Investigate support request', - sessionRef: `better-auth-session:${impersonationSessionId}`, - targetPrincipalId, - }); - expect(result.checkpointPending).toBe(false); - expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); - expect(deleteCalls).toBe(2); - }), + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-first-expired-stop')); + + expect(persistedRecovery).toEqual( + expect.objectContaining({ + actionId: 'expired-impersonation-action', + impersonationSessionId, + originalSessionId: restoredSessionId, + }), + ); + expect(actionRuntime.payloads[0]).toEqual({ + checkpoint: 'stopped', + originalPrincipalId, + reason: 'Investigate support request', + sessionRef: `better-auth-session:${impersonationSessionId}`, + targetPrincipalId, + }); + expect(result.checkpointPending).toBe(false); + expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); + expect(deleteCalls).toBe(2); + }), ); const makeLostResponseRecoveryService = (recovery: SupportRecoveryRecord, expiresAt: Date) => { let deleted = false; - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ checkpoint: 'stopped', recorded: true }), - ]); + const actionRuntime = makeActionRuntimeDouble([actionSuccess({ checkpoint: 'stopped', recorded: true })]); const service = makeService({ actionRuntime: actionRuntime.runtime, authentication: makeAuthenticationServiceDouble(), @@ -625,208 +600,183 @@ const makeLostResponseRecoveryService = (recovery: SupportRecoveryRecord, expire return { actionRuntime, deleted: () => deleted, service }; }; -it.effect( - 'restores the original session and stopped checkpoint after the provider response is lost', - () => - Effect.gen(function* testProgram7() { - const originalSessionToken = 'original-session-token'; - const adminValue = `${originalSessionToken}:true`; - const adminCookie = encodeURIComponent( - `${adminValue}.${yield* Effect.promise(() => makeSignature(adminValue, configuration.secret))}`, - ); - const requestHeaders = new Headers({ - cookie: `better-auth.admin_session=${adminCookie}; better-auth.session_token=deleted`, - }); - const recovery = { - actionId: 'impersonation-action', - createdAt: new Date('2026-08-09T00:00:00.000Z'), - impersonationSessionId, - originalAuthBindingId, - originalPrincipalId, - originalSessionId: restoredSessionId, - reason: 'Investigate support request', - targetPrincipalId, - tenantId, - }; - const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( - recovery, - new Date('2099-01-01T00:00:00.000Z'), - ); - - const result = yield* service - .stop({ - idempotencyKey: 'stop-response-loss', - requestHeaders, - }) - .pipe( - Effect.provideService(SupportImpersonationCorrelationId, 'correlation-response-loss'), - ); - - expect(result.active).toBe(false); - expect(result.checkpointPending).toBe(false); - expect(actionRuntime.invocationCount()).toBe(1); - expect(deleted()).toBe(true); - const restoredSessionCookie = result.setCookieHeaders.find((header) => - header.startsWith('better-auth.session_token='), - ); - expect(restoredSessionCookie).toBeDefined(); - expect(restoredSessionCookie?.includes('Max-Age=')).toBe(false); - const dontRememberCookie = result.setCookieHeaders.find((header) => - header.startsWith('better-auth.dont_remember='), - ); - expect(dontRememberCookie).toBeDefined(); - expect(dontRememberCookie?.includes('Max-Age=0')).toBe(false); - expect( - result.setCookieHeaders.some( - (header) => - header.startsWith('better-auth.admin_session=') && header.includes('Max-Age=0'), - ), - ).toBe(true); - }), -); +it.effect('restores the original session and stopped checkpoint after the provider response is lost', () => + Effect.gen(function* testProgram7() { + const originalSessionToken = 'original-session-token'; + const adminValue = `${originalSessionToken}:true`; + const adminCookie = encodeURIComponent( + `${adminValue}.${yield* Effect.promise(() => makeSignature(adminValue, configuration.secret))}`, + ); + const requestHeaders = new Headers({ + cookie: `better-auth.admin_session=${adminCookie}; better-auth.session_token=deleted`, + }); + const recovery = { + actionId: 'impersonation-action', + createdAt: new Date('2026-08-09T00:00:00.000Z'), + impersonationSessionId, + originalAuthBindingId, + originalPrincipalId, + originalSessionId: restoredSessionId, + reason: 'Investigate support request', + targetPrincipalId, + tenantId, + }; + const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( + recovery, + new Date('2099-01-01T00:00:00.000Z'), + ); -it.effect( - 'completes stopped recovery when a lost response leaves only an expired original session', - () => - Effect.gen(function* testProgram8() { - yield* TestClock.setTime(new Date('2026-09-08T00:00:00.000Z').getTime()); - const originalSessionToken = 'expired-original-session-token'; - const adminValue = `${originalSessionToken}:`; - const adminCookie = encodeURIComponent( - `${adminValue}.${yield* Effect.promise(() => makeSignature(adminValue, configuration.secret))}`, - ); - const recovery = { - actionId: 'expired-original-action', - createdAt: new Date('2026-08-09T00:00:00.000Z'), - impersonationSessionId, - originalAuthBindingId, - originalPrincipalId, - originalSessionId: restoredSessionId, - reason: 'Investigate support request', - targetPrincipalId, - tenantId, - }; - const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( - recovery, - new Date('2000-01-01T00:00:00.000Z'), - ); - - const result = yield* service - .stop({ - idempotencyKey: 'stop-expired-lost-response', - requestHeaders: new Headers({ - cookie: `better-auth.admin_session=${adminCookie}`, - }), - }) - .pipe( - Effect.provideService( - SupportImpersonationCorrelationId, - 'correlation-expired-lost-response', - ), - ); + const result = yield* service + .stop({ + idempotencyKey: 'stop-response-loss', + requestHeaders, + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-response-loss')); - expect(result.active).toBe(false); - expect(result.checkpointPending).toBe(false); - expect(actionRuntime.invocationCount()).toBe(1); - expect(deleted()).toBe(true); - expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); - }), + expect(result.active).toBe(false); + expect(result.checkpointPending).toBe(false); + expect(actionRuntime.invocationCount()).toBe(1); + expect(deleted()).toBe(true); + const restoredSessionCookie = result.setCookieHeaders.find((header) => + header.startsWith('better-auth.session_token='), + ); + expect(restoredSessionCookie).toBeDefined(); + expect(restoredSessionCookie?.includes('Max-Age=')).toBe(false); + const dontRememberCookie = result.setCookieHeaders.find((header) => + header.startsWith('better-auth.dont_remember='), + ); + expect(dontRememberCookie).toBeDefined(); + expect(dontRememberCookie?.includes('Max-Age=0')).toBe(false); + expect( + result.setCookieHeaders.some( + (header) => header.startsWith('better-auth.admin_session=') && header.includes('Max-Age=0'), + ), + ).toBe(true); + }), ); -it.effect( - 'clears a mismatched restored session and completes recovery from the recorded original', - () => - Effect.gen(function* testProgram9() { - let deleted = false; - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ checkpoint: 'stopped', recorded: true }), - ]); - const service = makeService({ - actionRuntime: actionRuntime.runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: makeSupportAuthProviderDouble({ - ...provider(true).api, - stopImpersonating: () => { - const headers = new Headers(); - headers.append('set-cookie', 'better-auth.session_token=unexpected; Path=/; HttpOnly'); - return Promise.resolve({ - headers, - response: { session: { id: 'unexpected-restored-session' } }, - }); - }, - }), - resolver: makePrincipalResolverDouble(), - store: makeSupportImpersonationStoreDouble({ - deleteRecovery: () => - Effect.sync(() => { - deleted = true; - }), - insertRecovery: () => Effect.void, +it.effect('completes stopped recovery when a lost response leaves only an expired original session', () => + Effect.gen(function* testProgram8() { + yield* TestClock.setTime(new Date('2026-09-08T00:00:00.000Z').getTime()); + const originalSessionToken = 'expired-original-session-token'; + const adminValue = `${originalSessionToken}:`; + const adminCookie = encodeURIComponent( + `${adminValue}.${yield* Effect.promise(() => makeSignature(adminValue, configuration.secret))}`, + ); + const recovery = { + actionId: 'expired-original-action', + createdAt: new Date('2026-08-09T00:00:00.000Z'), + impersonationSessionId, + originalAuthBindingId, + originalPrincipalId, + originalSessionId: restoredSessionId, + reason: 'Investigate support request', + targetPrincipalId, + tenantId, + }; + const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( + recovery, + new Date('2000-01-01T00:00:00.000Z'), + ); + + const result = yield* service + .stop({ + idempotencyKey: 'stop-expired-lost-response', + requestHeaders: new Headers({ + cookie: `better-auth.admin_session=${adminCookie}`, }), - supportRecoveryPrincipal, - }); + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-expired-lost-response')); - const result = yield* service - .stop({ - idempotencyKey: 'stop-mismatched-restore', - requestHeaders: new Headers(), - }) - .pipe( - Effect.provideService( - SupportImpersonationCorrelationId, - 'correlation-mismatched-restore', - ), - ); + expect(result.active).toBe(false); + expect(result.checkpointPending).toBe(false); + expect(actionRuntime.invocationCount()).toBe(1); + expect(deleted()).toBe(true); + expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); + }), +); - expect(result.checkpointPending).toBe(false); - expect(actionRuntime.invocationCount()).toBe(1); - expect(deleted).toBe(true); - expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); - expect(result.setCookieHeaders.some((header) => header.includes('unexpected'))).toBe(false); - }), +it.effect('clears a mismatched restored session and completes recovery from the recorded original', () => + Effect.gen(function* testProgram9() { + let deleted = false; + const actionRuntime = makeActionRuntimeDouble([actionSuccess({ checkpoint: 'stopped', recorded: true })]); + const service = makeService({ + actionRuntime: actionRuntime.runtime, + authentication: makeAuthenticationServiceDouble(), + configuration, + provider: makeSupportAuthProviderDouble({ + ...provider(true).api, + stopImpersonating: () => { + const headers = new Headers(); + headers.append('set-cookie', 'better-auth.session_token=unexpected; Path=/; HttpOnly'); + return Promise.resolve({ + headers, + response: { session: { id: 'unexpected-restored-session' } }, + }); + }, + }), + resolver: makePrincipalResolverDouble(), + store: makeSupportImpersonationStoreDouble({ + deleteRecovery: () => + Effect.sync(() => { + deleted = true; + }), + insertRecovery: () => Effect.void, + }), + supportRecoveryPrincipal, + }); + + const result = yield* service + .stop({ + idempotencyKey: 'stop-mismatched-restore', + requestHeaders: new Headers(), + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-mismatched-restore')); + + expect(result.checkpointPending).toBe(false); + expect(actionRuntime.invocationCount()).toBe(1); + expect(deleted).toBe(true); + expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); + expect(result.setCookieHeaders.some((header) => header.includes('unexpected'))).toBe(false); + }), ); -it.effect( - 'deletes the impersonation session and clears cookies when original restoration fails', - () => - Effect.gen(function* testProgram10() { - let deleteCalls = 0; - const checkpointFailure = new ActionPermissionDenied({ - code: 'action_permission_denied', - reason: 'The stopped checkpoint was denied', - }); - const service = makeService({ - actionRuntime: makeActionRuntimeDouble([actionCoreFailure(checkpointFailure)]).runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: makeSupportAuthProviderDouble({ - ...provider(true).api, - stopImpersonating: () => Promise.reject(new Error('admin session expired')), - }), - resolver: makePrincipalResolverDouble(), - store: makeSupportImpersonationStoreDouble({ - deleteSession: () => - Effect.sync(() => { - deleteCalls += 1; - }), - insertRecovery: () => Effect.void, - }), - supportRecoveryPrincipal, - }); +it.effect('deletes the impersonation session and clears cookies when original restoration fails', () => + Effect.gen(function* testProgram10() { + let deleteCalls = 0; + const checkpointFailure = new ActionPermissionDenied({ + code: 'action_permission_denied', + reason: 'The stopped checkpoint was denied', + }); + const service = makeService({ + actionRuntime: makeActionRuntimeDouble([actionCoreFailure(checkpointFailure)]).runtime, + authentication: makeAuthenticationServiceDouble(), + configuration, + provider: makeSupportAuthProviderDouble({ + ...provider(true).api, + stopImpersonating: () => Promise.reject(new Error('admin session expired')), + }), + resolver: makePrincipalResolverDouble(), + store: makeSupportImpersonationStoreDouble({ + deleteSession: () => + Effect.sync(() => { + deleteCalls += 1; + }), + insertRecovery: () => Effect.void, + }), + supportRecoveryPrincipal, + }); - const result = yield* service - .stop({ - idempotencyKey: 'stop-expired-original', - requestHeaders: new Headers(), - }) - .pipe( - Effect.provideService(SupportImpersonationCorrelationId, 'correlation-expired-original'), - ); - - expect(result.active).toBe(false); - expect(result.checkpointPending).toBe(true); - expect(deleteCalls).toBe(1); - expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); - }), + const result = yield* service + .stop({ + idempotencyKey: 'stop-expired-original', + requestHeaders: new Headers(), + }) + .pipe(Effect.provideService(SupportImpersonationCorrelationId, 'correlation-expired-original')); + + expect(result.active).toBe(false); + expect(result.checkpointPending).toBe(true); + expect(deleteCalls).toBe(1); + expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); + }), ); diff --git a/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts b/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts index b03fc931c..92e096dbc 100644 --- a/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts +++ b/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts @@ -1,6 +1,7 @@ -import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; -import { expect, it } from 'effect-rstest'; import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; import type { DeploymentAllowlist } from '../../api/modules/deployment-allowlist.ts'; import { installedModuleCatalog, @@ -12,19 +13,14 @@ const contract = (appId: string, moduleId: string) => makeModuleContractFixture({ appId, moduleId, - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }); const allowlist = (entries: DeploymentAllowlist['entries']): DeploymentAllowlist => - Object.freeze({ entries: Object.freeze([...entries]), revision: JSON.stringify(entries) }); + Object.freeze({ + entries: Object.freeze([...entries]), + revision: JSON.stringify(entries), + }); const response = (value: Value, init: ResponseInit = {}): Response => { const headers = { @@ -52,8 +48,14 @@ it.effect('loads two independent deployment contracts once and preserves both id ]); const loader = makeInstalledModuleCatalogLoader( allowlist([ - { appId: 'property-registry', contractUrl: [...documents.keys()][0] ?? '' }, - { appId: 'documents-center', contractUrl: [...documents.keys()][1] ?? '' }, + { + appId: 'property-registry', + contractUrl: [...documents.keys()][0] ?? '', + }, + { + appId: 'documents-center', + contractUrl: [...documents.keys()][1] ?? '', + }, ]), (url, init) => { const normalized = new Request(url).url; @@ -69,9 +71,7 @@ it.effect('loads two independent deployment contracts once and preserves both id expect(first).toBe(cached); expect(requests).toHaveLength(2); expect(first.moduleIds).toEqual(['documents.center', 'property.registry']); - expect(first.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe( - 'property.registry', - ); + expect(first.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe('property.registry'); expect(first.getByModuleId('property.registry')?.deployment.appId).toBe('property-registry'); }), ); @@ -90,9 +90,7 @@ it.effect('keeps a healthy deployment available on cold start when another is un }, ]), (url) => { - const appId = new Request(url).url.includes('property') - ? 'property-registry' - : 'documents-center'; + const appId = new Request(url).url.includes('property') ? 'property-registry' : 'documents-center'; if (appId === 'property-registry') { return Promise.reject(new Error('deployment unreachable')); } @@ -104,8 +102,16 @@ it.effect('keeps a healthy deployment available on cold start when another is un expect(catalog.moduleIds).toEqual(['documents.center']); expect(catalog.deploymentStatuses).toEqual([ - { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, - { appId: 'property-registry', reason: 'unavailable', status: 'unavailable' }, + { + appId: 'documents-center', + moduleId: 'documents.center', + status: 'available', + }, + { + appId: 'property-registry', + reason: 'unavailable', + status: 'unavailable', + }, ]); }), ); @@ -113,11 +119,7 @@ it.effect('keeps a healthy deployment available on cold start when another is un const unavailableResponses = [ ['unavailable', () => Promise.reject(new Error('secret host failure')), 'unavailable'], ['redirect', () => Promise.resolve(response({}, { status: 302 })), 'unavailable'], - [ - 'non-JSON', - () => Promise.resolve(response('{}', { headers: { 'content-type': 'text/html' } })), - 'incompatible', - ], + ['non-JSON', () => Promise.resolve(response('{}', { headers: { 'content-type': 'text/html' } })), 'incompatible'], ['malformed JSON', () => Promise.resolve(response('{broken')), 'incompatible'], ['invalid schema', () => Promise.resolve(response({ schemaVersion: '0' })), 'incompatible'], [ @@ -141,75 +143,93 @@ for (const [label, fetcher, expectedReason] of unavailableResponses) { const catalog = yield* loader; expect(catalog.moduleIds).toEqual([]); expect(catalog.deploymentStatuses).toEqual([ - { appId: 'property-registry', reason: expectedReason, status: 'unavailable' }, + { + appId: 'property-registry', + reason: expectedReason, + status: 'unavailable', + }, ]); }), ); } -it.live( - 'classifies oversized, timed-out, and duplicate-module deployments without caching failures', - () => - Effect.gen(function* verifyCase4() { - let attempts = 0; - const one: DeploymentAllowlist['entries'][number] = { - appId: 'property-registry', - contractUrl: 'https://property.example.test/.well-known/ontos-module-manifest.json', - }; - const oversized = makeInstalledModuleCatalogLoader( - allowlist([one]), - () => Promise.resolve(response('x'.repeat(64))), - { maxBytes: 32 }, - ); - expect(yield* oversized).toMatchObject({ - deploymentStatuses: [ - { appId: 'property-registry', reason: 'unavailable', status: 'unavailable' }, - ], - }); +it.live('classifies oversized, timed-out, and duplicate-module deployments without caching failures', () => + Effect.gen(function* verifyCase4() { + let attempts = 0; + const one: DeploymentAllowlist['entries'][number] = { + appId: 'property-registry', + contractUrl: 'https://property.example.test/.well-known/ontos-module-manifest.json', + }; + const oversized = makeInstalledModuleCatalogLoader( + allowlist([one]), + () => Promise.resolve(response('x'.repeat(64))), + { maxBytes: 32 }, + ); + expect(yield* oversized).toMatchObject({ + deploymentStatuses: [ + { + appId: 'property-registry', + reason: 'unavailable', + status: 'unavailable', + }, + ], + }); - const timedOut = makeInstalledModuleCatalogLoader( - allowlist([one]), - (_url, init) => { - const pending = Promise.withResolvers(); - init?.signal?.addEventListener('abort', () => pending.reject(new Error('aborted')), { - once: true, - }); - return Promise.resolve(pending.promise); + const timedOut = makeInstalledModuleCatalogLoader( + allowlist([one]), + (_url, init) => { + const pending = Promise.withResolvers(); + init?.signal?.addEventListener('abort', () => pending.reject(new Error('aborted')), { + once: true, + }); + return Promise.resolve(pending.promise); + }, + { timeoutMs: 10 }, + ); + expect(yield* timedOut).toMatchObject({ + deploymentStatuses: [ + { + appId: 'property-registry', + reason: 'timeout', + status: 'unavailable', }, - { timeoutMs: 10 }, - ); - expect(yield* timedOut).toMatchObject({ - deploymentStatuses: [ - { appId: 'property-registry', reason: 'timeout', status: 'unavailable' }, - ], - }); + ], + }); - const duplicate = makeInstalledModuleCatalogLoader( - allowlist([ - one, - { - appId: 'documents-center', - contractUrl: 'https://documents.example.test/.well-known/ontos-module-manifest.json', - }, - ]), - (url) => { - attempts += 1; - return Promise.resolve( - new Request(url).url.includes('property') - ? response(contract('property-registry', 'shared.module')) - : response(contract('documents-center', 'shared.module')), - ); + const duplicate = makeInstalledModuleCatalogLoader( + allowlist([ + one, + { + appId: 'documents-center', + contractUrl: 'https://documents.example.test/.well-known/ontos-module-manifest.json', }, - ); - expect(yield* duplicate).toMatchObject({ - deploymentStatuses: [ - { appId: 'documents-center', reason: 'incompatible', status: 'unavailable' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, - ], - }); - yield* duplicate; - expect(attempts).toBe(4); - }), + ]), + (url) => { + attempts += 1; + return Promise.resolve( + new Request(url).url.includes('property') + ? response(contract('property-registry', 'shared.module')) + : response(contract('documents-center', 'shared.module')), + ); + }, + ); + expect(yield* duplicate).toMatchObject({ + deploymentStatuses: [ + { + appId: 'documents-center', + reason: 'incompatible', + status: 'unavailable', + }, + { + appId: 'property-registry', + reason: 'incompatible', + status: 'unavailable', + }, + ], + }); + yield* duplicate; + expect(attempts).toBe(4); + }), ); it.effect('recovers a deployment on a later read and caches only the fully healthy result', () => @@ -236,10 +256,18 @@ it.effect('recovers a deployment on a later read and caches only the fully healt const cached = yield* loader; expect(degraded.deploymentStatuses).toEqual([ - { appId: 'property-registry', reason: 'unavailable', status: 'unavailable' }, + { + appId: 'property-registry', + reason: 'unavailable', + status: 'unavailable', + }, ]); expect(recovered.deploymentStatuses).toEqual([ - { appId: 'property-registry', moduleId: 'property.registry', status: 'available' }, + { + appId: 'property-registry', + moduleId: 'property.registry', + status: 'available', + }, ]); expect(cached).toBe(recovered); expect(requests).toBe(2); diff --git a/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts b/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts index ae17d4a95..a6bfc29ad 100644 --- a/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts +++ b/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts @@ -1,7 +1,8 @@ -import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; -import { expect, it } from 'effect-rstest'; import { buildInstalledModuleCatalog } from '@app/core-runtime'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; import { matchInstalledOutboxMessagesOnce } from '../../api/modules/installed-outbox-matcher.ts'; const contract = (appId: string, moduleId: string, outboxSubscriptions: readonly object[] = []) => diff --git a/app/apps/shell-super-app/tests/unit/installed-verticals.test.ts b/app/apps/shell-super-app/tests/unit/installed-verticals.test.ts index 5d8bcfd16..95555c36a 100644 --- a/app/apps/shell-super-app/tests/unit/installed-verticals.test.ts +++ b/app/apps/shell-super-app/tests/unit/installed-verticals.test.ts @@ -1,41 +1,36 @@ import fs from 'node:fs'; -import { expect, it } from 'effect-rstest'; + import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { DeploymentAllowlistTopologySchema } from '../../api/modules/deployment-allowlist.ts'; import { deriveInstalledVerticalIds, InstalledVerticalTopologyError, installedVerticalIds, } from '../../api/verticals/installed-verticals.ts'; -import { DeploymentAllowlistTopologySchema } from '../../api/modules/deployment-allowlist.ts'; -it.effect( - 'derives installed vertical IDs from the injected topology without hardcoded registrations', - () => - Effect.gen(function* verifyCase1() { - const topology = Schema.decodeUnknownSync(DeploymentAllowlistTopologySchema)( - JSON.parse( - fs.readFileSync( - new URL('../../../../topology/reference-topology.json', import.meta.url), - 'utf-8', - ), - ), - ); - const expectedInstalledIds = yield* deriveInstalledVerticalIds(topology); +it.effect('derives installed vertical IDs from the injected topology without hardcoded registrations', () => + Effect.gen(function* verifyCase1() { + const topology = Schema.decodeUnknownSync(DeploymentAllowlistTopologySchema)( + JSON.parse(fs.readFileSync(new URL('../../../../topology/reference-topology.json', import.meta.url), 'utf-8')), + ); + const expectedInstalledIds = yield* deriveInstalledVerticalIds(topology); - expect([...expectedInstalledIds]).toEqual(['party-registry']); - expect(expectedInstalledIds.has('party.registry')).toBe(false); - expect([...(yield* installedVerticalIds)]).toEqual([...expectedInstalledIds]); - const valid = yield* deriveInstalledVerticalIds({ - sharedPackages: [{ id: 'shared-contracts', kind: 'package' }], - shell: { id: 'shell-super-app', kind: 'shell' }, - verticals: [ - { id: 'property-registry', kind: 'vertical' }, - { id: 'future-generated', kind: 'vertical' }, - ], - }); - expect([...valid]).toEqual(['property-registry', 'future-generated']); - expect(valid.has('property.registry')).toBe(false); - }), + expect([...expectedInstalledIds]).toEqual(['party-registry']); + expect(expectedInstalledIds.has('party.registry')).toBe(false); + expect([...(yield* installedVerticalIds)]).toEqual([...expectedInstalledIds]); + const valid = yield* deriveInstalledVerticalIds({ + sharedPackages: [{ id: 'shared-contracts', kind: 'package' }], + shell: { id: 'shell-super-app', kind: 'shell' }, + verticals: [ + { id: 'property-registry', kind: 'vertical' }, + { id: 'future-generated', kind: 'vertical' }, + ], + }); + expect([...valid]).toEqual(['property-registry', 'future-generated']); + expect(valid.has('property.registry')).toBe(false); + }), ); it.effect('rejects malformed, non-vertical, invalid, and duplicate installed entries', () => diff --git a/app/apps/shell-super-app/tests/unit/layout.test.tsx b/app/apps/shell-super-app/tests/unit/layout.test.tsx index fcf0e24a2..8652cca56 100644 --- a/app/apps/shell-super-app/tests/unit/layout.test.tsx +++ b/app/apps/shell-super-app/tests/unit/layout.test.tsx @@ -1,12 +1,15 @@ -import { afterEach, expect, it, rstest, test } from 'effect-rstest'; -import { cleanup, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Menu as ActualMenu } from '@techsio/ui-kit/molecules/menu' with { rstest: 'importActual' }; +import { Menu as ActualMenu } from '@techsio/ui-kit/molecules/menu' with { + rstest: 'importActual', +}; import { Select as ActualSelect } from '@techsio/ui-kit/molecules/select' with { rstest: 'importActual', }; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Effect } from 'effect'; +import { afterEach, expect, it, rstest, test } from 'effect-rstest'; import type { ComponentProps, ReactNode } from 'react'; + import { AppIdSchema } from '../../shared/api'; import Layout from '../../src/routes/layout'; import { AuthenticatedDashboardLayout } from '../../src/routes/shell-frame'; @@ -49,8 +52,7 @@ rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ 'shell.dashboard.legalEntity.failed': 'Legal entity switching failed. Try again.', 'shell.dashboard.legalEntity.pending': 'Switching legal entity…', 'shell.dashboard.legalEntity.placeholder': 'Select a legal entity', - 'shell.dashboard.legalEntity.unavailable': - 'Legal entity choices are temporarily unavailable.', + 'shell.dashboard.legalEntity.unavailable': 'Legal entity choices are temporarily unavailable.', 'shell.dashboard.navigation.home': 'Home', 'shell.dashboard.navigation.label': 'Dashboard navigation', 'shell.dashboard.sidebar.label': 'Dashboard sidebar', @@ -174,13 +176,11 @@ test('renders the default Home dashboard contract and preserves page children', expect(tenantSelect.hasAttribute('disabled')).toBe(false); expect(screen.getAllByText('Alpha tenant').length).toBeGreaterThan(0); - const navigationElement = screen.getByRole('navigation', { name: 'Dashboard navigation' }); + const navigationElement = screen.getByRole('navigation', { + name: 'Dashboard navigation', + }); const links = [...navigationElement.querySelectorAll('a')]; - expect(links.map((link) => link.textContent)).toEqual([ - 'Home', - 'Future generated', - 'Testing one', - ]); + expect(links.map((link) => link.textContent)).toEqual(['Home', 'Future generated', 'Testing one']); expect(links.map((link) => link.getAttribute('href'))).toEqual([ '/en/', '/en/modules/future-generated', @@ -209,9 +209,7 @@ test('supports an alternate title and current MicroVertical without changing chi expect(screen.getByRole('heading', { level: 1, name: 'Testing workspace' })).toBeTruthy(); expect(screen.getByText('Stable child content')).toBeTruthy(); expect(screen.getByRole('link', { name: 'Home' }).hasAttribute('aria-current')).toBe(false); - expect(screen.getByRole('link', { name: 'Testing one' }).getAttribute('aria-current')).toBe( - 'page', - ); + expect(screen.getByRole('link', { name: 'Testing one' }).getAttribute('aria-current')).toBe('page'); }); test('supports module pages without a shell heading and keeps reduced horizontal content padding', () => { @@ -248,9 +246,7 @@ test('keeps Home as the only navigation link when no active modules are supplied , ); - expect( - screen.getByRole('navigation', { name: 'Dashboard navigation' }).querySelectorAll('a'), - ).toHaveLength(1); + expect(screen.getByRole('navigation', { name: 'Dashboard navigation' }).querySelectorAll('a')).toHaveLength(1); }); test('shows failed installed deployments as disabled identities with typed reasons', () => { @@ -346,9 +342,7 @@ it.effect('retains the account trigger and disables the sole command while logou const trigger = screen.getByRole('button', { name: 'Ada Lovelace' }); yield* Effect.promise(() => user.click(trigger)); - const command = yield* Effect.promise(() => - screen.findByRole('menuitem', { name: 'Logging out…' }), - ); + const command = yield* Effect.promise(() => screen.findByRole('menuitem', { name: 'Logging out…' })); expect(command.getAttribute('aria-disabled')).toBe('true'); yield* Effect.promise(() => user.click(command)); expect(onLogout).not.toHaveBeenCalled(); @@ -406,9 +400,7 @@ test('disables unavailable, one-choice, and pending tenant states with associate Content , ); - expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe( - true, - ); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe(true); rerender( , ); - expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe( - true, - ); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe(true); rerender( , ); - expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe( - true, - ); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).hasAttribute('disabled')).toBe(true); expect(screen.getByText('Switching tenant…')).toBeTruthy(); }); @@ -499,17 +487,16 @@ test('names the legal-entity selector by its own label and keeps a sole choice o , ); - const legalEntity = screen.getByRole('combobox', { name: 'Current legal entity' }); + const legalEntity = screen.getByRole('combobox', { + name: 'Current legal entity', + }); expect(legalEntity.hasAttribute('aria-label')).toBe(false); expect(legalEntity.hasAttribute('aria-describedby')).toBe(false); expect(legalEntity.hasAttribute('disabled')).toBe(false); - expect(screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-label')).toBe( - 'Current tenant', - ); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-label')).toBe('Current tenant'); expect(screen.queryByText('Select a legal entity')).toBeNull(); - const { currentLegalEntityId: _selectedLegalEntityId, ...unselectedLegalEntityProps } = - tenantProps; + const { currentLegalEntityId: _selectedLegalEntityId, ...unselectedLegalEntityProps } = tenantProps; rerender( , ); - const legalEntity = screen.getByRole('combobox', { name: 'Current legal entity' }); + const legalEntity = screen.getByRole('combobox', { + name: 'Current legal entity', + }); expect(legalEntity.hasAttribute('disabled')).toBe(disabled); expect(legalEntity.getAttribute('aria-describedby')).toBe('legal-entity-switch-status'); expect(screen.getByText(statusText)).toBeTruthy(); - expect( - screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-describedby'), - ).toBeNull(); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-describedby')).toBeNull(); }, ); diff --git a/app/apps/shell-super-app/tests/unit/legal-entity-selection.test.ts b/app/apps/shell-super-app/tests/unit/legal-entity-selection.test.ts index 02824ee32..2217d577c 100644 --- a/app/apps/shell-super-app/tests/unit/legal-entity-selection.test.ts +++ b/app/apps/shell-super-app/tests/unit/legal-entity-selection.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { ContextAccess, LegalEntityContext } from '@app/core-runtime'; import type { ContextAccessService, LegalEntityContextService } from '@app/core-runtime'; import { Effect, Layer, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { resolveAuthorizedLegalEntities, validateAuthorizedLegalEntity, @@ -26,16 +27,17 @@ const context = (entities = [alpha, beta] as const): LegalEntityContextService = }, }); -const access = ( - decisions: Readonly>, -): ContextAccessService => ({ +const access = (decisions: Readonly>): ContextAccessService => ({ legalEntities: ({ legalEntityIds }) => - Effect.succeed(legalEntityIds.map((key) => ({ decision: decisions[key] ?? 'denied', key }))), - modules: ({ moduleIds }) => - Effect.succeed(moduleIds.map((key) => ({ decision: 'denied' as const, key }))), + Effect.succeed( + legalEntityIds.map((key) => ({ + decision: decisions[key] ?? 'denied', + key, + })), + ), + modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision: 'denied' as const, key }))), resources: () => Effect.succeed([]), - tenants: ({ tenantIds }) => - Effect.succeed(tenantIds.map((key) => ({ decision: 'denied' as const, key }))), + tenants: ({ tenantIds }) => Effect.succeed(tenantIds.map((key) => ({ decision: 'denied' as const, key }))), }); const provideSelectionServices = ( @@ -59,7 +61,11 @@ it.effect('auto-selects the only authorized entity and preserves an exact saved context(), access({ [alpha.legalEntityId]: 'allowed' }), ); - expect(only).toEqual({ available: [alpha], selected: alpha, state: 'selected' }); + expect(only).toEqual({ + available: [alpha], + selected: alpha, + state: 'selected', + }); const saved = yield* provideSelectionServices( resolveAuthorizedLegalEntities({ principalId, @@ -67,9 +73,16 @@ it.effect('auto-selects the only authorized entity and preserves an exact saved tenantId, }), context(), - access({ [alpha.legalEntityId]: 'allowed', [beta.legalEntityId]: 'allowed' }), + access({ + [alpha.legalEntityId]: 'allowed', + [beta.legalEntityId]: 'allowed', + }), ); - expect(saved).toEqual({ available: [alpha, beta], selected: beta, state: 'selected' }); + expect(saved).toEqual({ + available: [alpha, beta], + selected: beta, + state: 'selected', + }); }), ); @@ -79,15 +92,14 @@ it.effect('requires a choice for several entities and blocks zero definite grant yield* provideSelectionServices( resolveAuthorizedLegalEntities({ principalId, tenantId }), context(), - access({ [alpha.legalEntityId]: 'allowed', [beta.legalEntityId]: 'allowed' }), + access({ + [alpha.legalEntityId]: 'allowed', + [beta.legalEntityId]: 'allowed', + }), ), ).toEqual({ available: [alpha, beta], state: 'selection_required' }); expect( - yield* provideSelectionServices( - resolveAuthorizedLegalEntities({ principalId, tenantId }), - context(), - access({}), - ), + yield* provideSelectionServices(resolveAuthorizedLegalEntities({ principalId, tenantId }), context(), access({})), ).toEqual({ available: [], state: 'access_blocked' }); }), ); diff --git a/app/apps/shell-super-app/tests/unit/module-entrypoint-loader.test.ts b/app/apps/shell-super-app/tests/unit/module-entrypoint-loader.test.ts index 8596179db..90b258cb6 100644 --- a/app/apps/shell-super-app/tests/unit/module-entrypoint-loader.test.ts +++ b/app/apps/shell-super-app/tests/unit/module-entrypoint-loader.test.ts @@ -1,6 +1,3 @@ -import { expect, it } from 'effect-rstest'; -import { Clock, Effect, Fiber, Function as Fn, Match, Predicate, Schema } from 'effect'; -import { TestClock } from 'effect/testing'; import { ModuleStateCheckUnavailableError, ModuleStateDeniedError, @@ -14,6 +11,10 @@ import type { ModuleStateSnapshot, TrustedPrincipalContext, } from '@app/core-runtime'; +import { Clock, Effect, Fiber, Function as Fn, Match, Predicate, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + import { loadModuleEntrypointComposition, MODULE_LOAD_CONCURRENCY, @@ -55,10 +56,7 @@ const makeFakeGateway = (options: FakeGatewayOptions = {}): ModuleEntrypointGate ) : Effect.void; }; - const prepareSnapshot: ModuleEntrypointGatewayService['prepareSnapshot'] = ( - context, - entrypoints, - ) => { + const prepareSnapshot: ModuleEntrypointGatewayService['prepareSnapshot'] = (context, entrypoints) => { options.onPrepare?.(entrypoints); if (options.unavailable === true || context.tenantId.length === 0) { return Effect.fail( @@ -70,9 +68,7 @@ const makeFakeGateway = (options: FakeGatewayOptions = {}): ModuleEntrypointGate } const snapshot: ModuleStateSnapshot = Object.freeze({ entrypointKeys: Object.freeze(entrypoints.map(({ entrypointKey }) => entrypointKey)), - moduleKeys: Object.freeze( - [...new Set(entrypoints.map(({ moduleKey }) => moduleKey))].toSorted(), - ), + moduleKeys: Object.freeze([...new Set(entrypoints.map(({ moduleKey }) => moduleKey))].toSorted()), tenantId: context.tenantId, }); return Effect.succeed(snapshot); @@ -92,10 +88,7 @@ const makeFakeGateway = (options: FakeGatewayOptions = {}): ModuleEntrypointGate Effect.flatMap((trusted) => prepareSnapshot(trusted, entrypoints)), ), run: (input) => - check(input.snapshot, input.entrypoint).pipe( - Effect.andThen(input.authorize), - Effect.andThen(input.load), - ), + check(input.snapshot, input.entrypoint).pipe(Effect.andThen(input.authorize), Effect.andThen(input.load)), }; return gateway; }; @@ -115,8 +108,7 @@ const component = defineTenantModuleEntrypoint({ role: 'public_component', }); -const compatibleRemoteModule = (value: { readonly default: unknown }) => - Predicate.isFunction(value.default); +const compatibleRemoteModule = (value: { readonly default: unknown }) => Predicate.isFunction(value.default); it.effect('prepares one complete trusted composition and invokes allowed lazy loaders', () => Effect.gen(function* verifyCompleteComposition() { @@ -174,23 +166,14 @@ it.effect('checks the complete composition before authorizing or invoking any lo }), ); -class RemoteLoadUnavailable extends Schema.TaggedError()( - 'RemoteLoadUnavailable', - {}, -) {} +class RemoteLoadUnavailable extends Schema.TaggedError()('RemoteLoadUnavailable', {}) {} const FakeUnavailableUiStateSchema = Schema.Literals(['forbidden', 'unavailable']); type FakeUnavailableUiState = typeof FakeUnavailableUiStateSchema.Type; -const mapFakeUnavailableUiState = ( - error: ModuleStateGateError | RemoteLoadUnavailable, -): FakeUnavailableUiState => +const mapFakeUnavailableUiState = (error: ModuleStateGateError | RemoteLoadUnavailable): FakeUnavailableUiState => Match.value(error).pipe( Match.tag('ModuleStateDeniedError', () => 'forbidden' as const), - Match.tag( - 'ModuleStateCheckUnavailableError', - 'RemoteLoadUnavailable', - () => 'unavailable' as const, - ), + Match.tag('ModuleStateCheckUnavailableError', 'RemoteLoadUnavailable', () => 'unavailable' as const), Match.exhaustive, ); @@ -198,7 +181,11 @@ it.effect('preserves typed gate and remote-load failures for exhaustive UI mappi Effect.gen(function* verifyTypedFailures() { const gateFailure = yield* Effect.flip( loadModuleEntrypointComposition(makeFakeGateway({ unavailable: true }), trustedContext, [ - { authorize: Effect.void, entrypoint: page, load: Effect.succeed('unreachable') }, + { + authorize: Effect.void, + entrypoint: page, + load: Effect.succeed('unreachable'), + }, ]), ); expect(mapFakeUnavailableUiState(gateFailure)).toBe('unavailable'); @@ -217,38 +204,42 @@ it.effect('preserves typed gate and remote-load failures for exhaustive UI mappi }), ); -it.live( - 'settles browser entrypoint success, rejection, incompatibility, and timeout independently', - () => - Effect.gen(function* verifySettledLoads() { - const pending = Promise.withResolvers<{ readonly default: () => null }>(); - const [ready, unavailable, incompatible, timedOut] = yield* Effect.all( - [ - settleModuleEntrypointLoad( - Fn.constant(Promise.resolve({ default: remoteDefault })), - compatibleRemoteModule, - 50, - ), - settleModuleEntrypointLoad( - Fn.constant(Promise.reject(new Error('remote unavailable'))), - compatibleRemoteModule, - 50, - ), - settleModuleEntrypointLoad( - Fn.constant(Promise.resolve({ default: 'not a component' })), - compatibleRemoteModule, - 50, - ), - settleModuleEntrypointLoad(Fn.constant(pending.promise), compatibleRemoteModule, 1), - ], - { concurrency: 'unbounded' }, - ); +it.live('settles browser entrypoint success, rejection, incompatibility, and timeout independently', () => + Effect.gen(function* verifySettledLoads() { + const pending = Promise.withResolvers<{ readonly default: () => null }>(); + const [ready, unavailable, incompatible, timedOut] = yield* Effect.all( + [ + settleModuleEntrypointLoad( + Fn.constant(Promise.resolve({ default: remoteDefault })), + compatibleRemoteModule, + 50, + ), + settleModuleEntrypointLoad( + Fn.constant(Promise.reject(new Error('remote unavailable'))), + compatibleRemoteModule, + 50, + ), + settleModuleEntrypointLoad( + Fn.constant(Promise.resolve({ default: 'not a component' })), + compatibleRemoteModule, + 50, + ), + settleModuleEntrypointLoad(Fn.constant(pending.promise), compatibleRemoteModule, 1), + ], + { concurrency: 'unbounded' }, + ); - expect(ready.state).toBe('ready'); - expect(unavailable).toEqual({ reason: 'unavailable', state: 'unavailable' }); - expect(incompatible).toEqual({ reason: 'incompatible', state: 'unavailable' }); - expect(timedOut).toEqual({ reason: 'timeout', state: 'unavailable' }); - }), + expect(ready.state).toBe('ready'); + expect(unavailable).toEqual({ + reason: 'unavailable', + state: 'unavailable', + }); + expect(incompatible).toEqual({ + reason: 'incompatible', + state: 'unavailable', + }); + expect(timedOut).toEqual({ reason: 'timeout', state: 'unavailable' }); + }), ); it.effect('settles several browser entrypoints without one failure hiding healthy loads', () => @@ -396,9 +387,7 @@ it.live('never starts an expired queued load when synchronous work delays deadli it.effect('abandons queued loads when the caller is interrupted', () => Effect.gen(function* verifyInterruptedCaller() { - const pendingLoads = Array.from({ length: MODULE_LOAD_CONCURRENCY }, () => - Promise.withResolvers(), - ); + const pendingLoads = Array.from({ length: MODULE_LOAD_CONCURRENCY }, () => Promise.withResolvers()); const firstWindowStarted = Promise.withResolvers(); const started: number[] = []; const caller = yield* Effect.forkChild( @@ -431,64 +420,58 @@ it.effect('abandons queued loads when the caller is interrupted', () => }).pipe(Effect.provide(TestClock.layer())), ); -it.effect( - 'holds a running timed-out load permit until settlement then releases it to a live queued load', - () => - Effect.gen(function* verifyPermitRelease() { - const pendingLoads = Array.from({ length: MODULE_LOAD_CONCURRENCY }, () => - Promise.withResolvers(), - ); - const firstWindowStarted = Promise.withResolvers(); - const queuedLoadStarted = Promise.withResolvers(); - const events: string[] = []; - const resultsFiber = yield* Effect.forkChild( - settleModuleEntrypointLoads( - Array.from({ length: MODULE_LOAD_CONCURRENCY + 1 }, (_, index) => ({ - identity: `module-${index}/page`, - isCompatible: compatibleRemoteModule, - - load: () => { - events.push(`started-${index}`); - if (index === MODULE_LOAD_CONCURRENCY - 1) { - firstWindowStarted.resolve(null); - } - const pending = pendingLoads[index]; - if (pending === undefined) { - queuedLoadStarted.resolve(null); - return Promise.resolve({ default: remoteDefault }); - } - return Promise.resolve( - pending.promise.then((value) => { - events.push(`settled-${index}`); - return value; - }), - ); - }, - timeoutMs: index === 0 ? 10 : 1000, - })), - ), - ); +it.effect('holds a running timed-out load permit until settlement then releases it to a live queued load', () => + Effect.gen(function* verifyPermitRelease() { + const pendingLoads = Array.from({ length: MODULE_LOAD_CONCURRENCY }, () => Promise.withResolvers()); + const firstWindowStarted = Promise.withResolvers(); + const queuedLoadStarted = Promise.withResolvers(); + const events: string[] = []; + const resultsFiber = yield* Effect.forkChild( + settleModuleEntrypointLoads( + Array.from({ length: MODULE_LOAD_CONCURRENCY + 1 }, (_, index) => ({ + identity: `module-${index}/page`, + isCompatible: compatibleRemoteModule, - yield* Effect.promise(() => firstWindowStarted.promise); - yield* TestClock.adjust('20 millis'); - expect(events).toEqual( - Array.from({ length: MODULE_LOAD_CONCURRENCY }, (_, index) => `started-${index}`), - ); + load: () => { + events.push(`started-${index}`); + if (index === MODULE_LOAD_CONCURRENCY - 1) { + firstWindowStarted.resolve(null); + } + const pending = pendingLoads[index]; + if (pending === undefined) { + queuedLoadStarted.resolve(null); + return Promise.resolve({ default: remoteDefault }); + } + return Promise.resolve( + pending.promise.then((value) => { + events.push(`settled-${index}`); + return value; + }), + ); + }, + timeoutMs: index === 0 ? 10 : 1000, + })), + ), + ); - pendingLoads[0]?.resolve({ default: () => null }); - yield* Effect.promise(() => queuedLoadStarted.promise); - expect(events.slice(-2)).toEqual(['settled-0', `started-${MODULE_LOAD_CONCURRENCY}`]); - for (const pending of pendingLoads) { - pending.resolve({ default: () => null }); - } - const results = yield* Fiber.join(resultsFiber); - expect(results[0]).toEqual({ - identity: 'module-0/page', - reason: 'timeout', - state: 'unavailable', - }); - expect(results.slice(1).every(({ state }) => state === 'ready')).toBe(true); - }).pipe(Effect.provide(TestClock.layer())), + yield* Effect.promise(() => firstWindowStarted.promise); + yield* TestClock.adjust('20 millis'); + expect(events).toEqual(Array.from({ length: MODULE_LOAD_CONCURRENCY }, (_, index) => `started-${index}`)); + + pendingLoads[0]?.resolve({ default: () => null }); + yield* Effect.promise(() => queuedLoadStarted.promise); + expect(events.slice(-2)).toEqual(['settled-0', `started-${MODULE_LOAD_CONCURRENCY}`]); + for (const pending of pendingLoads) { + pending.resolve({ default: () => null }); + } + const results = yield* Fiber.join(resultsFiber); + expect(results[0]).toEqual({ + identity: 'module-0/page', + reason: 'timeout', + state: 'unavailable', + }); + expect(results.slice(1).every(({ state }) => state === 'ready')).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect('does not surface a late remote rejection after a timeout', () => @@ -558,7 +541,10 @@ it.effect.each(['selection_required', 'not_found', 'forbidden', 'unavailable'] a it.effect('invokes the lazy registry only after receiving an approved target', () => Effect.gen(function* verifyApprovedTargetResolution() { let loads = 0; - const target = { appId: 'inventory-app', componentKey: 'inventory.stock.page' }; + const target = { + appId: 'inventory-app', + componentKey: 'inventory.stock.page', + }; const result = yield* resolveThenLoadModuleTarget(Effect.succeed(target), (approved) => Effect.sync(() => { loads += 1; diff --git a/app/apps/shell-super-app/tests/unit/routes/authenticated-shell-fixture.ts b/app/apps/shell-super-app/tests/unit/routes/authenticated-shell-fixture.ts index 777f4db79..3ac7f987f 100644 --- a/app/apps/shell-super-app/tests/unit/routes/authenticated-shell-fixture.ts +++ b/app/apps/shell-super-app/tests/unit/routes/authenticated-shell-fixture.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { LegalEntityIdSchema, SafeTenantIdentitySchema } from '../../../shared/api.ts'; import type { AuthenticatedHomePageModel } from '../../../src/routes/[lang]/page.data.ts'; @@ -13,9 +14,7 @@ export const authenticatedShellFixture = (): AuthenticatedHomePageModel => ({ }), legalEntities: { items: [], state: 'available' }, navigation: { items: [], state: 'available', unavailableDeployments: [] }, - selectedLegalEntityId: Schema.decodeUnknownSync(LegalEntityIdSchema)( - '20000000-0000-4000-8000-000000000001', - ), + selectedLegalEntityId: Schema.decodeUnknownSync(LegalEntityIdSchema)('20000000-0000-4000-8000-000000000001'), state: 'authenticated', tenants: { items: [], state: 'available' }, }); diff --git a/app/apps/shell-super-app/tests/unit/routes/home/loader.test.ts b/app/apps/shell-super-app/tests/unit/routes/home/loader.test.ts index 1a056225f..d383bcba4 100644 --- a/app/apps/shell-super-app/tests/unit/routes/home/loader.test.ts +++ b/app/apps/shell-super-app/tests/unit/routes/home/loader.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, expect, rstest, it } from 'effect-rstest'; import { ConfigProvider, Effect } from 'effect'; +import { beforeEach, expect, rstest, it } from 'effect-rstest'; + import * as actualAuthClient from '../../../../src/api/auth-client.ts' with { rstest: 'importActual', }; @@ -57,16 +58,10 @@ const request = () => const loadModel = ({ request: input }: { readonly request: Request }) => Effect.suspend(() => loadHomePageModel(input).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromUnknown(browserConfigValuesMock()), - ), + Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown(browserConfigValuesMock())), ), ); -const withBetterAuthUrl = ( - baseUrl: string, - operation: () => Effect.Effect, -) => +const withBetterAuthUrl = (baseUrl: string, operation: () => Effect.Effect) => Effect.suspend(() => { browserConfigValuesMock.mockReturnValueOnce({ BETTER_AUTH_URL: baseUrl }); return operation(); @@ -83,7 +78,11 @@ beforeEach(() => { }), ); shellCompositionMock.mockReturnValue( - Effect.succeed({ navigation, state: 'available' as const, unavailableDeployments: [] }), + Effect.succeed({ + navigation, + state: 'available' as const, + unavailableDeployments: [], + }), ); availableTenantsMock.mockReturnValue( Effect.succeed({ @@ -104,7 +103,11 @@ it.effect('resolves trusted context before returning one serializable compositio items: [{ legalEntityId: 'legal-1', legalName: 'Alpha company' }], state: 'available', }, - navigation: { items: navigation, state: 'available', unavailableDeployments: [] }, + navigation: { + items: navigation, + state: 'available', + unavailableDeployments: [], + }, selectedLegalEntityId: 'legal-1', state: 'authenticated', tenants: { @@ -124,7 +127,9 @@ it.effect('resolves trusted context before returning one serializable compositio it.effect('does not request composition for an anonymous session', () => Effect.gen(function* verifyCase2() { currentSessionMock.mockReturnValueOnce(Effect.succeed({ state: 'anonymous' as const })); - expect(yield* loadModel({ request: request() })).toEqual({ state: 'anonymous' }); + expect(yield* loadModel({ request: request() })).toEqual({ + state: 'anonymous', + }); expect(shellCompositionMock).not.toHaveBeenCalled(); expect(availableTenantsMock).not.toHaveBeenCalled(); }), @@ -167,7 +172,9 @@ it.effect('uses the configured HTTPS origin for the server-side session request' yield* withBetterAuthUrl('https://shell.stage.example.test', () => Effect.gen(function* verifyCase5() { - return yield* loadModel({ request: new Request('http://shell.stage.example.test/en') }); + return yield* loadModel({ + request: new Request('http://shell.stage.example.test/en'), + }); }), ); @@ -183,7 +190,9 @@ it.effect('keeps the configured local HTTP origin for the server-side session re yield* withBetterAuthUrl('http://localhost:3020', () => Effect.gen(function* verifyCase7() { - return yield* loadModel({ request: new Request('http://localhost:3020/en') }); + return yield* loadModel({ + request: new Request('http://localhost:3020/en'), + }); }), ); @@ -195,9 +204,7 @@ it.effect('keeps the configured local HTTP origin for the server-side session re it.effect('maps composition failure to unavailable without discarding verified context', () => Effect.gen(function* verifyCase8() { - shellCompositionMock.mockReturnValueOnce( - Effect.fail({ _tag: 'ShellCapabilityUnavailableProblem' }), - ); + shellCompositionMock.mockReturnValueOnce(Effect.fail({ _tag: 'ShellCapabilityUnavailableProblem' })); expect(yield* loadModel({ request: request() })).toMatchObject({ contextState: 'authenticated', identity, @@ -209,46 +216,41 @@ it.effect('maps composition failure to unavailable without discarding verified c it.effect('maps tenant failure to the current-tenant fallback without discarding composition', () => Effect.gen(function* verifyCase9() { - availableTenantsMock.mockReturnValueOnce( - Effect.fail({ _tag: 'TenantCapabilityUnavailableProblem' }), - ); + availableTenantsMock.mockReturnValueOnce(Effect.fail({ _tag: 'TenantCapabilityUnavailableProblem' })); expect(yield* loadModel({ request: request() })).toMatchObject({ navigation: { items: navigation, state: 'available' }, - tenants: { items: [{ name: 'tenant-1', tenantId: 'tenant-1' }], state: 'unavailable' }, + tenants: { + items: [{ name: 'tenant-1', tenantId: 'tenant-1' }], + state: 'unavailable', + }, }); }), ); -it.effect( - 'keeps legal-entity acquisition failure explicit without claiming choices are available', - () => - Effect.gen(function* verifyCase10() { - availableLegalEntitiesMock.mockReturnValueOnce( - Effect.fail({ _tag: 'TenantCapabilityUnavailableProblem' }), - ); - expect(yield* loadModel({ request: request() })).toMatchObject({ - legalEntities: { items: [], state: 'unavailable' }, - state: 'authenticated', - }); - }), +it.effect('keeps legal-entity acquisition failure explicit without claiming choices are available', () => + Effect.gen(function* verifyCase10() { + availableLegalEntitiesMock.mockReturnValueOnce(Effect.fail({ _tag: 'TenantCapabilityUnavailableProblem' })); + expect(yield* loadModel({ request: request() })).toMatchObject({ + legalEntities: { items: [], state: 'unavailable' }, + state: 'authenticated', + }); + }), ); -it.effect( - 'does not collapse an authentication infrastructure failure into an anonymous session', - () => - Effect.gen(function* verifyCase11() { - currentSessionMock.mockReturnValueOnce( - Effect.fail({ _tag: 'AuthenticationUnavailableProblem' }), - ); - expect(yield* loadModel({ request: request() })).toEqual({ state: 'unavailable' }); - }), +it.effect('does not collapse an authentication infrastructure failure into an anonymous session', () => + Effect.gen(function* verifyCase11() { + currentSessionMock.mockReturnValueOnce(Effect.fail({ _tag: 'AuthenticationUnavailableProblem' })); + expect(yield* loadModel({ request: request() })).toEqual({ + state: 'unavailable', + }); + }), ); it.effect('tears down stale authenticated data when tenant context requires authentication', () => Effect.gen(function* verifyCase12() { - availableTenantsMock.mockReturnValueOnce( - Effect.fail({ _tag: 'TenantAuthenticationRequiredProblem' }), - ); - expect(yield* loadModel({ request: request() })).toEqual({ state: 'anonymous' }); + availableTenantsMock.mockReturnValueOnce(Effect.fail({ _tag: 'TenantAuthenticationRequiredProblem' })); + expect(yield* loadModel({ request: request() })).toEqual({ + state: 'anonymous', + }); }), ); diff --git a/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx index 4bb85c58b..0a04c1e19 100644 --- a/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx @@ -1,11 +1,8 @@ -import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { - rstest: 'importActual', -}; -import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Effect, Schema } from 'effect'; -import type { ReactNode } from 'react'; +import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; + import { AppIdSchema, GroupKeySchema, @@ -17,22 +14,34 @@ import { TenantAuthenticationRequiredProblemSchema, TenantIdSchema, } from '../../../../shared/api.ts'; -import { HomeView } from '../../../../src/routes/[lang]/page.tsx'; import type { HomePageModel } from '../../../../src/routes/[lang]/page.data.ts'; +import { HomeView } from '../../../../src/routes/[lang]/page.tsx'; +import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { + rstest: 'importActual', +}; +import type { LocalizedLinkCall, LocalizedLinkDoubleProps } from '../../../support/localized-link-double.tsx'; +import { renderLocalizedLinkDouble } from '../../../support/localized-link-double.tsx'; const { browserRunPromiseMock, + languageState, + localizedLinkCalls, navigateMock, signOutMock, switchLegalEntityMock, switchTenantMock, -} = rstest.hoisted(() => ({ - browserRunPromiseMock: rstest.fn(), - navigateMock: rstest.fn(), - signOutMock: rstest.fn(), - switchLegalEntityMock: rstest.fn(), - switchTenantMock: rstest.fn(), -})); +} = rstest.hoisted(() => { + const recordedLinkCalls: LocalizedLinkCall[] = []; + return { + browserRunPromiseMock: rstest.fn(), + languageState: { current: 'en' }, + localizedLinkCalls: recordedLinkCalls, + navigateMock: rstest.fn(), + signOutMock: rstest.fn(), + switchLegalEntityMock: rstest.fn(), + switchTenantMock: rstest.fn(), + }; +}); const translations = new Map( Object.entries({ @@ -70,14 +79,17 @@ const translations = new Map( ); rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ - Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( - - {children} - - ), - useLocalizedLocation: () => ({ alternates: { cs: '/cs/', en: '/en/' }, canonical: '/en/' }), + Link: (props: LocalizedLinkDoubleProps) => + renderLocalizedLinkDouble(props, { + calls: localizedLinkCalls, + language: languageState, + }), + useLocalizedLocation: () => ({ + alternates: { cs: '/cs/', en: '/en/' }, + canonical: '/en/', + }), useModernI18n: () => ({ - language: 'en', + language: languageState.current, t: (key: string) => translations.get(key) ?? key, }), })); @@ -97,22 +109,16 @@ rstest.mock('../../../../src/runtime/browser-effect-runtime.ts', () => ({ browserRuntime: { runPromise: browserRunPromiseMock }, })); -const principalId = Schema.decodeUnknownSync(PrincipalIdSchema)( - '00000000-0000-4000-8000-000000000001', -); +const principalId = Schema.decodeUnknownSync(PrincipalIdSchema)('00000000-0000-4000-8000-000000000001'); const tenantId1 = Schema.decodeUnknownSync(TenantIdSchema)('00000000-0000-4000-8000-000000000101'); const tenantId2 = Schema.decodeUnknownSync(TenantIdSchema)('00000000-0000-4000-8000-000000000102'); -const legalEntityId1 = Schema.decodeUnknownSync(LegalEntityIdSchema)( - '00000000-0000-4000-8000-000000000201', -); -const legalEntityId2 = Schema.decodeUnknownSync(LegalEntityIdSchema)( - '00000000-0000-4000-8000-000000000202', -); +const legalEntityId1 = Schema.decodeUnknownSync(LegalEntityIdSchema)('00000000-0000-4000-8000-000000000201'); +const legalEntityId2 = Schema.decodeUnknownSync(LegalEntityIdSchema)('00000000-0000-4000-8000-000000000202'); const inventoryAppId = Schema.decodeUnknownSync(AppIdSchema)('inventory-app'); const navigationGroupKey = Schema.decodeUnknownSync(GroupKeySchema)('shell.navigation.modules'); const inventoryModuleId = Schema.decodeUnknownSync(ModuleIdSchema)('inventory.stock'); -const authenticatedModel = (): HomePageModel => ({ +const authenticatedModel = (options?: { readonly moduleEnabled?: boolean }): HomePageModel => ({ contextState: 'authenticated', identity: { displayName: 'Ada Lovelace', @@ -131,7 +137,7 @@ const authenticatedModel = (): HomePageModel => ({ items: [ { appId: inventoryAppId, - enabled: true, + enabled: options?.moduleEnabled ?? true, groupKey: navigationGroupKey, href: '/modules/inventory.stock', label: 'Inventory', @@ -166,6 +172,8 @@ beforeEach(() => { afterEach(() => { cleanup(); + languageState.current = 'en'; + localizedLinkCalls.length = 0; rstest.clearAllMocks(); }); @@ -175,11 +183,37 @@ it('anonymous home exposes only the localized login action', () => { expect(screen.queryByRole('banner')).toBeNull(); }); +it('the anonymous login action hands a canonical target to the framework link', () => { + render(); + const loginCall = localizedLinkCalls.find((call) => call.to === '/login'); + expect(loginCall).toBeDefined(); + expect(loginCall?.params).toBeUndefined(); + expect(loginCall?.href).toBeUndefined(); +}); + +it('the anonymous login action resolves Czech from the same canonical target', () => { + languageState.current = 'cs'; + render(); + expect(localizedLinkCalls.map((call) => call.to)).toContain('/login'); + expect(screen.getByRole('link', { name: 'Login' }).getAttribute('href')).toBe('/cs/login'); +}); + +it('the unavailable dashboard exposes no navigable affordance', () => { + render(); + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(localizedLinkCalls).toHaveLength(0); +}); + +it('a disabled module affordance stays non-interactive text', () => { + render(); + expect(screen.queryByRole('link', { name: 'Inventory' })).toBeNull(); + expect(screen.getByText('Inventory')).toBeTruthy(); + expect(localizedLinkCalls.map((call) => call.to)).not.toContain('/modules/inventory.stock'); +}); + it('authenticated home renders server-composed navigation and selected legal context', () => { render(); - expect(screen.getByRole('link', { name: 'Inventory' }).getAttribute('href')).toBe( - '/en/modules/inventory.stock', - ); + expect(screen.getByRole('link', { name: 'Inventory' }).getAttribute('href')).toBe('/en/modules/inventory.stock'); expect(screen.getByText('Read only')).toBeTruthy(); expect(screen.getByText(legalEntityId1)).toBeTruthy(); expect(screen.queryByText('inventory.stock')).toBeNull(); @@ -189,21 +223,20 @@ it.live('successful tenant switch performs a full document reload', () => Effect.gen(function* successfulTenantSwitchPerformsAFull() { const user = userEvent.setup(); render(); + yield* Effect.promise(() => user.click(screen.getByRole('combobox', { name: 'Current tenant' }))); + const tenantOption = yield* Effect.promise(() => screen.findByRole('option', { name: 'Zeta tenant' })); + yield* Effect.promise(() => user.click(tenantOption)); yield* Effect.promise(() => - user.click(screen.getByRole('combobox', { name: 'Current tenant' })), - ); - const tenantOption = yield* Effect.promise(() => - screen.findByRole('option', { name: 'Zeta tenant' }), + waitFor(() => expect(switchTenantMock).toHaveBeenCalledWith({ tenantId: tenantId2 }, { locale: 'en' })), ); - yield* Effect.promise(() => user.click(tenantOption)); yield* Effect.promise(() => waitFor(() => - expect(switchTenantMock).toHaveBeenCalledWith({ tenantId: tenantId2 }, { locale: 'en' }), + expect(navigateMock).toHaveBeenCalledWith({ + reloadDocument: true, + to: '.', + }), ), ); - yield* Effect.promise(() => - waitFor(() => expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '.' })), - ); }), ); @@ -211,23 +244,21 @@ it.live('successful legal-entity switch performs a full document reload', () => Effect.gen(function* successfulLegalEntitySwitchPerformsA() { const user = userEvent.setup(); render(); - yield* Effect.promise(() => - user.click(screen.getByRole('combobox', { name: 'Current legal entity' })), - ); - const legalEntityOption = yield* Effect.promise(() => - screen.findByRole('option', { name: 'Beta company' }), - ); + yield* Effect.promise(() => user.click(screen.getByRole('combobox', { name: 'Current legal entity' }))); + const legalEntityOption = yield* Effect.promise(() => screen.findByRole('option', { name: 'Beta company' })); yield* Effect.promise(() => user.click(legalEntityOption)); yield* Effect.promise(() => waitFor(() => - expect(switchLegalEntityMock).toHaveBeenCalledWith( - { legalEntityId: legalEntityId2 }, - { locale: 'en' }, - ), + expect(switchLegalEntityMock).toHaveBeenCalledWith({ legalEntityId: legalEntityId2 }, { locale: 'en' }), ), ); yield* Effect.promise(() => - waitFor(() => expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '.' })), + waitFor(() => + expect(navigateMock).toHaveBeenCalledWith({ + reloadDocument: true, + to: '.', + }), + ), ); }), ); @@ -236,9 +267,7 @@ it.live('search submission navigates to the localized Shell search route', () => Effect.gen(function* searchSubmissionNavigatesToTheLocalized() { const user = userEvent.setup(); render(); - yield* Effect.promise(() => - user.type(screen.getByLabelText('Search this legal entity'), 'Unit 1'), - ); + yield* Effect.promise(() => user.type(screen.getByLabelText('Search this legal entity'), 'Unit 1')); yield* Effect.promise(() => user.click(screen.getByRole('button', { name: 'Search' }))); expect(navigateMock).toHaveBeenCalledWith({ to: '/en/search?q=Unit%201' }); }), @@ -249,21 +278,24 @@ it.live('logout clears the authenticated composition together', () => const user = userEvent.setup(); render(); yield* Effect.promise(() => user.click(screen.getByRole('button', { name: 'Ada Lovelace' }))); - const logoutItem = yield* Effect.promise(() => - screen.findByRole('menuitem', { name: 'Logout' }), - ); + const logoutItem = yield* Effect.promise(() => screen.findByRole('menuitem', { name: 'Logout' })); + // Happy DOM has no layout. Give pointer movement distinct coordinates so + // the menu can distinguish it from virtual focus after a prior selection. + yield* Effect.promise(() => user.pointer({ coords: { x: 10, y: 10 }, target: logoutItem })); yield* Effect.promise(() => user.click(logoutItem)); + yield* Effect.promise(() => waitFor(() => expect(signOutMock).toHaveBeenCalledWith({ locale: 'en' }))); yield* Effect.promise(() => waitFor(() => - expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '/en/login' }), + expect(navigateMock).toHaveBeenCalledWith({ + reloadDocument: true, + to: '/en/login', + }), ), ); }), ); -const tenantAuthenticationRequired = Schema.decodeUnknownSync( - TenantAuthenticationRequiredProblemSchema, -)({ +const tenantAuthenticationRequired = Schema.decodeUnknownSync(TenantAuthenticationRequiredProblemSchema)({ _tag: 'TenantAuthenticationRequiredProblem', detail: 'The tenant session expired.', status: 401, @@ -277,9 +309,7 @@ const tenantAccessForbidden = Schema.decodeUnknownSync(TenantAccessForbiddenProb title: 'Tenant access forbidden', type: 'https://ontos.dev/problems/tenant-access-forbidden', }); -const legalEntityAccessForbidden = Schema.decodeUnknownSync( - LegalEntityAccessForbiddenProblemSchema, -)({ +const legalEntityAccessForbidden = Schema.decodeUnknownSync(LegalEntityAccessForbiddenProblemSchema)({ _tag: 'LegalEntityAccessForbiddenProblem', detail: 'The principal cannot use this legal entity.', status: 403, @@ -360,23 +390,20 @@ it.live.each(switchFailureCases)( if (reloads) { yield* Effect.promise(() => waitFor(() => - expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '.' }), + expect(navigateMock).toHaveBeenCalledWith({ + reloadDocument: true, + to: '.', + }), ), ); - yield* Effect.promise(() => - waitFor(() => expect(screen.queryByText(pendingText)).toBeNull()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.queryByText(pendingText)).toBeNull())); expect(screen.queryByText(failedText)).toBeNull(); } else { - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText(failedText)).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText(failedText)).toBeTruthy())); expect(navigateMock).not.toHaveBeenCalled(); expect(screen.queryByText(pendingText)).toBeNull(); } - expect(screen.getByRole('combobox', { name: comboboxName }).hasAttribute('disabled')).toBe( - false, - ); + expect(screen.getByRole('combobox', { name: comboboxName }).hasAttribute('disabled')).toBe(false); }), ); diff --git a/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts b/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts index 8477e2f5e..b201fda93 100644 --- a/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts +++ b/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'effect-rstest'; + import cs from '../../../../locales/cs/shell.json'; import en from '../../../../locales/en/shell.json'; import { ultramodernRouteMetadata } from '../../../../src/routes/ultramodern-route-metadata'; diff --git a/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx index c66e1a300..6cfec708d 100644 --- a/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx @@ -1,20 +1,31 @@ -import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { - rstest: 'importActual', -}; -import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; -import { Effect, Redacted } from 'effect'; +import { toaster } from '@techsio/ui-kit/molecules/toast'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { toaster } from '@techsio/ui-kit/molecules/toast'; +import { Effect, Redacted } from 'effect'; +import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; + import LoginPage from '../../../../src/routes/[lang]/login/page'; +import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { + rstest: 'importActual', +}; +import type { LocalizedLinkCall, LocalizedLinkDoubleProps } from '../../../support/localized-link-double.tsx'; +import { renderLocalizedLinkDouble } from '../../../support/localized-link-double.tsx'; -const { browserRunPromiseMock, navigateMock, signInMock } = rstest.hoisted(() => ({ - browserRunPromiseMock: rstest.fn(), - navigateMock: rstest.fn(), - signInMock: rstest.fn(), -})); +const { browserRunPromiseMock, invalidateMock, languageState, localizedLinkCalls, navigateMock, signInMock } = + rstest.hoisted(() => { + const recordedLinkCalls: LocalizedLinkCall[] = []; + return { + browserRunPromiseMock: rstest.fn(), + invalidateMock: rstest.fn(), + languageState: { current: 'en' }, + localizedLinkCalls: recordedLinkCalls, + navigateMock: rstest.fn(), + signInMock: rstest.fn(), + }; + }); beforeEach(() => { + invalidateMock.mockImplementation(() => Promise.resolve()); navigateMock.mockImplementation(() => Promise.resolve()); browserRunPromiseMock.mockImplementation(browserRuntime.runPromise); signInMock.mockReturnValue( @@ -44,6 +55,11 @@ const translations = new Map( ); rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ + Link: (props: LocalizedLinkDoubleProps) => + renderLocalizedLinkDouble(props, { + calls: localizedLinkCalls, + language: languageState, + }), useLocalizedLocation: () => ({ alternates: { cs: '/cs/login', @@ -52,13 +68,14 @@ rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ canonical: '/en/login', }), useModernI18n: () => ({ - language: 'en', + language: languageState.current, t: (key: string) => translations.get(key) ?? key, }), })); rstest.mock('@modern-js/plugin-tanstack/runtime', () => ({ useNavigate: () => navigateMock, + useRouter: () => ({ invalidate: invalidateMock }), })); rstest.mock('../../../../src/api/auth-client.ts', () => ({ @@ -77,6 +94,8 @@ const renderLogin = () => render(); afterEach(() => { cleanup(); + languageState.current = 'en'; + localizedLinkCalls.length = 0; toaster.remove(); rstest.unstubAllGlobals(); rstest.clearAllMocks(); @@ -97,9 +116,24 @@ it('shows the required login controls through the UI kit', () => { expect(password.getAttribute('autocomplete')).toBe('current-password'); expect(password.hasAttribute('required')).toBe(true); expect(submit.getAttribute('type')).toBe('submit'); - expect(screen.getByRole('link', { name: '← Back to the home page' }).getAttribute('href')).toBe( - '/en', - ); + expect(screen.getByRole('link', { name: '← Back to the home page' }).getAttribute('href')).toBe('/en'); +}); + +it('the back link hands the canonical home target to the framework link', () => { + renderLogin(); + + const homeCall = localizedLinkCalls.find((call) => call.to === '/'); + expect(homeCall).toBeDefined(); + expect(homeCall?.params).toBeUndefined(); + expect(homeCall?.href).toBeUndefined(); +}); + +it('the back link resolves Czech from the same canonical target', () => { + languageState.current = 'cs'; + renderLogin(); + + expect(localizedLinkCalls.map((call) => call.to)).toContain('/'); + expect(screen.getByRole('link', { name: '← Back to the home page' }).getAttribute('href')).toBe('/cs'); }); const submitLogin = (login: string, password: string) => @@ -243,6 +277,7 @@ it.effect('submits valid values through the Shell authentication client and navi { locale: 'en' }, ); expect(browserRunPromiseMock).toHaveBeenCalledTimes(1); + expect(invalidateMock).toHaveBeenCalledWith({ sync: true }); expect(navigateMock).toHaveBeenCalledWith({ to: '/en/' }); expect(getSubmit().hasAttribute('disabled')).toBe(false); expect(screen.queryByText('shell.login.error.internal')).toBeNull(); @@ -267,3 +302,18 @@ it.effect('reports navigation failure and restores the login form after authenti ); }), ); + +it.effect('keeps navigation on the login route when auth cache refresh fails', () => + Effect.gen(function* reportsAuthenticationRefreshFailure() { + invalidateMock.mockRejectedValueOnce('Route refresh failed'); + yield* submitLogin('admin', 'secret'); + yield* Effect.promise(() => + waitFor(() => { + expect(invalidateMock).toHaveBeenCalledWith({ sync: true }); + expect(navigateMock).not.toHaveBeenCalled(); + expect(screen.getByText('shell.login.error.internal')).toBeDefined(); + expect(getSubmit().hasAttribute('disabled')).toBe(false); + }), + ); + }), +); diff --git a/app/apps/shell-super-app/tests/unit/routes/modules/loader.test.ts b/app/apps/shell-super-app/tests/unit/routes/modules/loader.test.ts index 7980efa36..fd7817d46 100644 --- a/app/apps/shell-super-app/tests/unit/routes/modules/loader.test.ts +++ b/app/apps/shell-super-app/tests/unit/routes/modules/loader.test.ts @@ -1,13 +1,11 @@ -import { beforeEach, expect, rstest, it } from 'effect-rstest'; import { Cause, ConfigProvider, Deferred, Effect, Fiber } from 'effect'; +import { beforeEach, expect, rstest, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; + import * as actualAuthClient from '../../../../src/api/auth-client.ts' with { rstest: 'importActual', }; -import { - loadModulePageModel, - selectRouteParams, -} from '../../../../src/routes/[lang]/modules/[moduleId]/page.data.ts'; +import { loadModulePageModel, selectRouteParams } from '../../../../src/routes/[lang]/modules/[moduleId]/page.data.ts'; const { loadHomePageModelMock, resolveModuleTargetMock } = rstest.hoisted(() => ({ loadHomePageModelMock: rstest.fn(), @@ -50,9 +48,7 @@ const request = () => { /** The module program reads its origin from config; pin an empty provider so every case is identical. */ const moduleModel = (input: Parameters[0]) => - loadModulePageModel(input).pipe( - Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({})), - ); + loadModulePageModel(input).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({}))); beforeEach(() => { loadHomePageModelMock.mockReturnValue(Effect.succeed(authenticatedShell)); @@ -81,45 +77,46 @@ it('selects only declared safe route parameters and omits overlong values', () = ).toEqual({ id: 'party-1' }); }); -it.effect( - 'retains only declared bounded route parameters outside the resolved target identity', - () => - Effect.gen(function* retainsOnlyDeclaredBoundedRouteParameters() { - expect( - yield* moduleModel({ - params: { - entrypointKey: 'party.registry.page.contacts', - moduleId: 'party.registry', - }, - request: request(), - routeParams: { id: 'party-1' }, - }), - ).toMatchObject({ - routeParams: { id: 'party-1' }, - state: 'resolved', - target: { - appId: 'party-registry', - componentKey: 'party.registry.page-contacts', +it.effect('retains only declared bounded route parameters outside the resolved target identity', () => + Effect.gen(function* retainsOnlyDeclaredBoundedRouteParameters() { + expect( + yield* moduleModel({ + params: { entrypointKey: 'party.registry.page.contacts', moduleId: 'party.registry', }, - }); - expect(resolveModuleTargetMock).toHaveBeenCalledWith( - { entrypointKey: 'party.registry.page.contacts', moduleId: 'party.registry' }, - expect.any(Object), - ); - }), + request: request(), + routeParams: { id: 'party-1' }, + }), + ).toMatchObject({ + routeParams: { id: 'party-1' }, + state: 'resolved', + target: { + appId: 'party-registry', + componentKey: 'party.registry.page-contacts', + entrypointKey: 'party.registry.page.contacts', + moduleId: 'party.registry', + }, + }); + expect(resolveModuleTargetMock).toHaveBeenCalledWith( + { + entrypointKey: 'party.registry.page.contacts', + moduleId: 'party.registry', + }, + expect.any(Object), + ); + }), ); it.effect('retains module landing behavior when no exact page entrypoint is supplied', () => Effect.gen(function* retainsModuleLandingBehaviorWhenNo() { expect( - yield* moduleModel({ params: { moduleId: 'party.registry' }, request: request() }), + yield* moduleModel({ + params: { moduleId: 'party.registry' }, + request: request(), + }), ).toMatchObject({ routeParams: {} }); - expect(resolveModuleTargetMock).toHaveBeenCalledWith( - { moduleId: 'party.registry' }, - expect.any(Object), - ); + expect(resolveModuleTargetMock).toHaveBeenCalledWith({ moduleId: 'party.registry' }, expect.any(Object)); }), ); @@ -162,11 +159,12 @@ it.effect.each([ it.effect('fails with the typed timeout instead of hanging on an unresponsive shell read', () => Effect.gen(function* failsWithTheTypedTimeout() { const entered = yield* Deferred.make<'entered'>(); - loadHomePageModelMock.mockReturnValueOnce( - Effect.andThen(Deferred.succeed(entered, 'entered'), Effect.never), - ); + loadHomePageModelMock.mockReturnValueOnce(Effect.andThen(Deferred.succeed(entered, 'entered'), Effect.never)); const fiber = yield* Effect.forkChild( - moduleModel({ params: { moduleId: 'party.registry' }, request: request() }), + moduleModel({ + params: { moduleId: 'party.registry' }, + request: request(), + }), ); yield* Deferred.await(entered); diff --git a/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx index 2aedc649a..9e584d843 100644 --- a/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx @@ -1,26 +1,24 @@ -import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { Effect, Schema } from 'effect'; +import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; import type { ReactNode } from 'react'; + import { ResolvedModuleTargetSchema } from '../../../../shared/api.ts'; -import { authenticatedShellFixture } from '../authenticated-shell-fixture.ts'; import ContactsPage from '../../../../src/routes/[lang]/contacts/page.tsx'; -import ModuleTargetPage from '../../../../src/routes/[lang]/modules/[moduleId]/page.tsx'; import type { ModuleTargetPageModel } from '../../../../src/routes/[lang]/modules/[moduleId]/page.data.ts'; +import ModuleTargetPage from '../../../../src/routes/[lang]/modules/[moduleId]/page.tsx'; +import { authenticatedShellFixture } from '../authenticated-shell-fixture.ts'; type ResolvedPageModel = Extract; -const { - findApprovedVerticalPageClientMock, - loadRemotePageMock, - remotePropsMock, - useLoaderDataMock, -} = rstest.hoisted(() => ({ - findApprovedVerticalPageClientMock: rstest.fn(), - loadRemotePageMock: rstest.fn(), - remotePropsMock: rstest.fn(), - useLoaderDataMock: rstest.fn(), -})); +const { findApprovedVerticalPageClientMock, loadRemotePageMock, remotePropsMock, useLoaderDataMock } = rstest.hoisted( + () => ({ + findApprovedVerticalPageClientMock: rstest.fn(), + loadRemotePageMock: rstest.fn(), + remotePropsMock: rstest.fn(), + useLoaderDataMock: rstest.fn(), + }), +); rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ useModernI18n: () => ({ t: (key: string) => key }), @@ -39,9 +37,7 @@ rstest.mock('../../../../src/api/vertical-clients.ts', () => ({ })); rstest.mock('../../../../src/routes/shell-frame.tsx', () => ({ - AuthenticatedDashboardLayout: ({ children }: { readonly children: ReactNode }) => ( -
{children}
- ), + AuthenticatedDashboardLayout: ({ children }: { readonly children: ReactNode }) =>
{children}
, })); rstest.mock('../../../../src/routes/use-shell-controls.ts', () => ({ @@ -150,13 +146,18 @@ beforeEach(() => { target, }: { readonly routeParams: Readonly>; - readonly target: { readonly componentKey: string; readonly writable: boolean }; + readonly target: { + readonly componentKey: string; + readonly writable: boolean; + }; }) => { remotePropsMock({ routeParams, target }); return
{`${target.componentKey}:${routeParams['id'] ?? 'static'}`}
; }, }); - findApprovedVerticalPageClientMock.mockReturnValue({ load: loadRemotePageMock }); + findApprovedVerticalPageClientMock.mockReturnValue({ + load: loadRemotePageMock, + }); }); afterEach(() => { @@ -167,7 +168,10 @@ afterEach(() => { it.each(['selection_required', 'forbidden', 'not_found', 'unavailable'] as const)( 'does not consult or invoke the private registry for a %s exact-page response', (state) => { - useLoaderDataMock.mockReturnValue({ shell, state } satisfies ModuleTargetPageModel); + useLoaderDataMock.mockReturnValue({ + shell, + state, + } satisfies ModuleTargetPageModel); render(); expect(findApprovedVerticalPageClientMock).not.toHaveBeenCalled(); expect(loadRemotePageMock).not.toHaveBeenCalled(); @@ -180,9 +184,7 @@ it.live('invokes the exact private page loader only after a resolved authenticat render(); expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(resolvedModel.target); yield* Effect.promise(() => waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1))); - expect( - yield* Effect.promise(() => screen.findByText('contacts.core.page-customers:customer-1')), - ).toBeTruthy(); + expect(yield* Effect.promise(() => screen.findByText('contacts.core.page-customers:customer-1'))).toBeTruthy(); }), ); @@ -208,9 +210,7 @@ it.live('maps an unreachable approved remote to its safe local diagnostic', () = render(); - expect( - yield* Effect.promise(() => screen.findByText('shell.moduleTarget.unavailable')), - ).toBeTruthy(); + expect(yield* Effect.promise(() => screen.findByText('shell.moduleTarget.unavailable'))).toBeTruthy(); }), ); @@ -221,9 +221,7 @@ it.live('rejects a malformed remote module before React receives it', () => render(); - expect( - yield* Effect.promise(() => screen.findByText('shell.moduleTarget.incompatible')), - ).toBeTruthy(); + expect(yield* Effect.promise(() => screen.findByText('shell.moduleTarget.incompatible'))).toBeTruthy(); expect(remotePropsMock).not.toHaveBeenCalled(); }), ); @@ -233,9 +231,7 @@ it.live('passes an empty route-parameter record to a resolved static page', () = useLoaderDataMock.mockReturnValue({ ...resolvedModel, routeParams: {} }); render(); yield* Effect.promise(() => waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1))); - expect( - yield* Effect.promise(() => screen.findByText('contacts.core.page-customers:static')), - ).toBeTruthy(); + expect(yield* Effect.promise(() => screen.findByText('contacts.core.page-customers:static'))).toBeTruthy(); }), ); @@ -253,10 +249,11 @@ it.live.each(exactPageCases)( render(); expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(exactModel.target); - yield* Effect.promise(() => - waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)), - ); - expect(remotePropsMock).toHaveBeenCalledWith({ routeParams, target: exactModel.target }); + yield* Effect.promise(() => waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1))); + expect(remotePropsMock).toHaveBeenCalledWith({ + routeParams, + target: exactModel.target, + }); expect(yield* Effect.promise(() => screen.findByText(renderedText))).toBeTruthy(); }), ); diff --git a/app/apps/shell-super-app/tests/unit/routes/resources/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/resources/page.test.tsx index 4161f04a7..7c831cc98 100644 --- a/app/apps/shell-super-app/tests/unit/routes/resources/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/resources/page.test.tsx @@ -1,19 +1,17 @@ -import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { - rstest: 'importActual', -}; -import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Deferred, Effect, Schema } from 'effect'; +import { afterEach, beforeEach, expect, rstest, it } from 'effect-rstest'; import type { ReactNode } from 'react'; -import { - ShellResourceResponseSchema, - ShellTargetForbiddenProblemSchema, -} from '../../../../shared/api.ts'; + +import { ShellResourceResponseSchema, ShellTargetForbiddenProblemSchema } from '../../../../shared/api.ts'; import type { MediaAttachmentResponse, ShellResourceResponse } from '../../../../shared/api.ts'; -import { authenticatedShellFixture } from '../authenticated-shell-fixture.ts'; -import ResourcePage from '../../../../src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx'; import type { ResourcePageModel } from '../../../../src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.data.ts'; +import ResourcePage from '../../../../src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx'; +import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { + rstest: 'importActual', +}; +import { authenticatedShellFixture } from '../authenticated-shell-fixture.ts'; type ReadyModel = Extract; type ClosedState = Exclude; @@ -23,22 +21,17 @@ interface DashboardPageProps { readonly title: string; } -const { - attachResourceMediaMock, - browserRunPromiseMock, - dashboardRenders, - shellControlsMock, - useLoaderDataMock, -} = rstest.hoisted(() => { - const renders: DashboardPageProps[] = []; - return { - attachResourceMediaMock: rstest.fn(), - browserRunPromiseMock: rstest.fn(), - dashboardRenders: renders, - shellControlsMock: rstest.fn(), - useLoaderDataMock: rstest.fn(), - }; -}); +const { attachResourceMediaMock, browserRunPromiseMock, dashboardRenders, shellControlsMock, useLoaderDataMock } = + rstest.hoisted(() => { + const renders: DashboardPageProps[] = []; + return { + attachResourceMediaMock: rstest.fn(), + browserRunPromiseMock: rstest.fn(), + dashboardRenders: renders, + shellControlsMock: rstest.fn(), + useLoaderDataMock: rstest.fn(), + }; + }); const translations = new Map( Object.entries({ @@ -88,10 +81,7 @@ rstest.mock('../../../../src/routes/use-shell-controls.ts', () => ({ })); rstest.mock('../../../../src/routes/shell-frame.tsx', () => ({ - AuthenticatedDashboardLayout: ({ - children, - ...props - }: DashboardPageProps & { readonly children: ReactNode }) => { + AuthenticatedDashboardLayout: ({ children, ...props }: DashboardPageProps & { readonly children: ReactNode }) => { dashboardRenders.push(props); return (
@@ -187,8 +177,14 @@ afterEach(() => { }); it.each([ - { blockedText: 'The dashboard is unavailable', shellState: 'unavailable' as const }, - { blockedText: 'Select a legal entity first', shellState: 'anonymous' as const }, + { + blockedText: 'The dashboard is unavailable', + shellState: 'unavailable' as const, + }, + { + blockedText: 'Select a legal entity first', + shellState: 'anonymous' as const, + }, ])( 'refuses the whole page for a $shellState shell without a dashboard or an attach seam', ({ blockedText, shellState }) => { @@ -204,7 +200,10 @@ it.each([ }, ); -const closedStates: readonly { readonly closedText: string; readonly state: ClosedState }[] = [ +const closedStates: readonly { + readonly closedText: string; + readonly state: ClosedState; +}[] = [ { closedText: 'You cannot open this resource', state: 'forbidden' }, { closedText: 'This resource does not exist', state: 'not_found' }, { closedText: 'Select a legal entity first', state: 'selection_required' }, @@ -258,7 +257,10 @@ const disabledMediaCases = [ { blockedText: 'This module is read only', reason: 'read_only' as const }, { blockedText: 'This resource has no media', reason: 'absent' as const }, { blockedText: 'You cannot attach media here', reason: 'forbidden' as const }, - { blockedText: 'Media attachment is unavailable', reason: 'unavailable' as const }, + { + blockedText: 'Media attachment is unavailable', + reason: 'unavailable' as const, + }, ]; it.live.each(disabledMediaCases)( @@ -317,9 +319,7 @@ it.live('holds the attach seam disabled for the whole in-flight attachment', () renderResourcePage(readyModel()); yield* Effect.promise(() => user.click(attachButton())); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Attaching media…')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Attaching media…')).toBeTruthy())); expect(attachButton().hasAttribute('disabled')).toBe(true); yield* Effect.promise(() => user.click(attachButton())); @@ -327,9 +327,7 @@ it.live('holds the attach seam disabled for the whole in-flight attachment', () expect(browserRunPromiseMock).toHaveBeenCalledTimes(1); yield* Deferred.succeed(gate, attachedResponse); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy())); expect(attachButton().hasAttribute('disabled')).toBe(false); }), ); @@ -341,9 +339,7 @@ it.live('attaches media for the loaded resource reference and reports success on renderResourcePage(model); yield* Effect.promise(() => user.click(attachButton())); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy())); expect(attachResourceMediaMock).toHaveBeenCalledTimes(1); expect(attachResourceMediaMock).toHaveBeenCalledWith(model.resource.ref); @@ -360,9 +356,7 @@ it.live('settles a typed attachment failure into its own status without a defect renderResourcePage(readyModel()); yield* Effect.promise(() => user.click(attachButton())); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Attaching the media failed')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Attaching the media failed')).toBeTruthy())); expect(screen.queryByText('Media attached')).toBeNull(); expect(screen.queryByText('Attaching media…')).toBeNull(); @@ -377,14 +371,10 @@ it.live('retries after a failure and replaces the failure status with success', renderResourcePage(readyModel()); yield* Effect.promise(() => user.click(attachButton())); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Attaching the media failed')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Attaching the media failed')).toBeTruthy())); yield* Effect.promise(() => user.click(attachButton())); - yield* Effect.promise(() => - waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy()), - ); + yield* Effect.promise(() => waitFor(() => expect(screen.getByText('Media attached')).toBeTruthy())); expect(attachResourceMediaMock).toHaveBeenCalledTimes(2); expect(browserRunPromiseMock).toHaveBeenCalledTimes(2); diff --git a/app/apps/shell-super-app/tests/unit/routes/search/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/search/page.test.tsx new file mode 100644 index 000000000..737e755a7 --- /dev/null +++ b/app/apps/shell-super-app/tests/unit/routes/search/page.test.tsx @@ -0,0 +1,257 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { Effect, Schema } from 'effect'; +import { afterEach, beforeEach, expect, rstest, test } from 'effect-rstest'; + +import { + AppIdSchema, + GroupKeySchema, + LegalEntityIdSchema, + ModuleIdSchema, + PrincipalIdSchema, + ResourceIdSchema, + TenantIdSchema, +} from '../../../../shared/api.ts'; +import type { HomePageModel } from '../../../../src/routes/[lang]/page.data.ts'; +import type { SearchPageModel } from '../../../../src/routes/[lang]/search/page.data.ts'; +import SearchPage from '../../../../src/routes/[lang]/search/page.tsx'; +import { browserRuntime } from '../../../../src/runtime/browser-effect-runtime.ts' with { + rstest: 'importActual', +}; +import type { LocalizedLinkCall, LocalizedLinkDoubleProps } from '../../../support/localized-link-double.tsx'; +import { renderLocalizedLinkDouble } from '../../../support/localized-link-double.tsx'; + +const { + browserRunPromiseMock, + languageState, + localizedLinkCalls, + navigateMock, + signOutMock, + switchLegalEntityMock, + switchTenantMock, + useLoaderDataMock, +} = rstest.hoisted(() => { + const recordedLinkCalls: LocalizedLinkCall[] = []; + return { + browserRunPromiseMock: rstest.fn(), + languageState: { current: 'en' }, + localizedLinkCalls: recordedLinkCalls, + navigateMock: rstest.fn(async () => {}), + signOutMock: rstest.fn(), + switchLegalEntityMock: rstest.fn(), + switchTenantMock: rstest.fn(), + useLoaderDataMock: rstest.fn(), + }; +}); + +const translations = new Map( + Object.entries({ + 'shell.auth.identity.title': 'Authenticated identity', + 'shell.auth.logout.action': 'Logout', + 'shell.auth.logout.failed': 'Logout failed', + 'shell.dashboard.account.label': 'Account menu', + 'shell.dashboard.brand': 'OntOS', + 'shell.dashboard.header.label': 'Dashboard header', + 'shell.dashboard.legalEntity.accessibleLabel': 'Current legal entity', + 'shell.dashboard.navigation.home': 'Home', + 'shell.dashboard.navigation.label': 'Dashboard navigation', + 'shell.dashboard.sidebar.label': 'Dashboard sidebar', + 'shell.dashboard.tenant.accessibleLabel': 'Current tenant', + 'shell.dashboard.unavailable': 'Dashboard unavailable', + 'shell.modules.state.readOnly': 'Read only', + 'shell.search.empty': 'No results', + 'shell.search.label': 'Search this legal entity', + 'shell.search.selection_required': 'Select a legal entity first', + 'shell.search.submit': 'Search', + 'shell.search.title': 'Search', + 'shell.search.unavailable': 'Search unavailable', + }), +); + +rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ + Link: (props: LocalizedLinkDoubleProps) => + renderLocalizedLinkDouble(props, { + calls: localizedLinkCalls, + language: languageState, + }), + useLocalizedLocation: () => ({ + alternates: { cs: '/cs/hledat', en: '/en/search' }, + }), + useModernI18n: () => ({ + language: languageState.current, + t: (key: string) => translations.get(key) ?? key, + }), +})); + +rstest.mock('@modern-js/plugin-tanstack/runtime', () => ({ + useLoaderData: useLoaderDataMock, + useNavigate: () => navigateMock, +})); + +rstest.mock('../../../../src/api/auth-client.ts', () => ({ + signOut: signOutMock, + switchLegalEntity: switchLegalEntityMock, + switchTenant: switchTenantMock, +})); + +rstest.mock('../../../../src/runtime/browser-effect-runtime.ts', () => ({ + browserRuntime: { runPromise: browserRunPromiseMock }, +})); + +const principalId = Schema.decodeUnknownSync(PrincipalIdSchema)('00000000-0000-4000-8000-000000000001'); +const tenantId = Schema.decodeUnknownSync(TenantIdSchema)('00000000-0000-4000-8000-000000000101'); +const legalEntityId = Schema.decodeUnknownSync(LegalEntityIdSchema)('00000000-0000-4000-8000-000000000201'); +const inventoryAppId = Schema.decodeUnknownSync(AppIdSchema)('inventory-app'); +const navigationGroupKey = Schema.decodeUnknownSync(GroupKeySchema)('shell.navigation.modules'); +const inventoryModuleId = Schema.decodeUnknownSync(ModuleIdSchema)('inventory.stock'); +const plainResourceId = Schema.decodeUnknownSync(ResourceIdSchema)('unit-1'); +const awkwardResourceId = Schema.decodeUnknownSync(ResourceIdSchema)('unit #1/2'); + +const authenticatedShell = (): HomePageModel => ({ + contextState: 'authenticated', + identity: { + displayName: 'Ada Lovelace', + email: 'ada@example.test', + principalId, + tenantId, + }, + legalEntities: { + items: [{ legalEntityId, legalName: 'Alpha company' }], + state: 'available', + }, + navigation: { + items: [ + { + appId: inventoryAppId, + enabled: true, + groupKey: navigationGroupKey, + href: '/modules/inventory.stock', + label: 'Inventory', + moduleId: inventoryModuleId, + order: 10, + state: 'read_only', + unavailable: false, + writable: false, + }, + ], + state: 'available', + unavailableDeployments: [], + }, + selectedLegalEntityId: legalEntityId, + state: 'authenticated', + tenants: { + items: [{ name: 'Alpha tenant', tenantId }], + state: 'available', + }, +}); + +const readyModel = (resourceType: string, resourceId: typeof plainResourceId): SearchPageModel => ({ + query: 'unit', + response: { + partial: false, + results: [ + { + kind: 'resource', + ref: { moduleId: inventoryModuleId, resourceId, resourceType }, + title: 'Unit 1', + }, + ], + }, + shell: authenticatedShell(), + state: 'ready', +}); + +const resourceLinkCalls = () => localizedLinkCalls.filter((call) => call.to.startsWith('/resources')); + +beforeEach(() => { + browserRunPromiseMock.mockImplementation(browserRuntime.runPromise); + signOutMock.mockReturnValue(Effect.succeed({ signedOut: true })); + switchTenantMock.mockReturnValue(Effect.succeed({ selectedTenantId: tenantId })); + switchLegalEntityMock.mockReturnValue(Effect.succeed({ selectedLegalEntityId: legalEntityId })); + useLoaderDataMock.mockReturnValue(readyModel('stock-item', plainResourceId)); +}); + +afterEach(() => { + cleanup(); + languageState.current = 'en'; + localizedLinkCalls.length = 0; + rstest.clearAllMocks(); +}); + +test('a search result hands the canonical resource route to the framework link', () => { + render(); + + const [resultCall] = resourceLinkCalls(); + expect(resourceLinkCalls()).toHaveLength(1); + expect(resultCall?.to).toBe('/resources/$moduleId/$resourceType/$resourceId'); + expect(resultCall?.params).toEqual({ + moduleId: 'inventory.stock', + resourceId: 'unit-1', + resourceType: 'stock-item', + }); + expect(resultCall?.href).toBeUndefined(); + expect(screen.getByRole('link', { name: 'Unit 1' }).getAttribute('href')).toBe( + '/en/resources/inventory.stock/stock-item/unit-1', + ); +}); + +test('a search result resolves the Czech resource route from the same canonical target', () => { + languageState.current = 'cs'; + render(); + + expect(resourceLinkCalls()[0]?.to).toBe('/resources/$moduleId/$resourceType/$resourceId'); + expect(screen.getByRole('link', { name: 'Unit 1' }).getAttribute('href')).toBe( + '/cs/zdroje/inventory.stock/stock-item/unit-1', + ); +}); + +test('resource path segments stay percent-encoded per segment', () => { + useLoaderDataMock.mockReturnValue(readyModel('stock item', awkwardResourceId)); + render(); + + expect(resourceLinkCalls()[0]?.params).toEqual({ + moduleId: 'inventory.stock', + resourceId: 'unit #1/2', + resourceType: 'stock item', + }); + expect(screen.getByRole('link', { name: 'Unit 1' }).getAttribute('href')).toBe( + '/en/resources/inventory.stock/stock%20item/unit%20%231%2F2', + ); +}); + +test('an empty result set exposes no resource affordance', () => { + useLoaderDataMock.mockReturnValue({ + query: 'unit', + response: { partial: false, results: [] }, + shell: authenticatedShell(), + state: 'ready', + } satisfies SearchPageModel); + render(); + + expect(screen.getByText('No results')).toBeTruthy(); + expect(resourceLinkCalls()).toHaveLength(0); +}); + +test('an unavailable search exposes no resource affordance', () => { + useLoaderDataMock.mockReturnValue({ + query: 'unit', + shell: authenticatedShell(), + state: 'unavailable', + } satisfies SearchPageModel); + render(); + + expect(screen.getByText('Search unavailable')).toBeTruthy(); + expect(resourceLinkCalls()).toHaveLength(0); +}); + +test('a closed shell state exposes no navigable affordance at all', () => { + useLoaderDataMock.mockReturnValue({ + query: 'unit', + shell: { state: 'unavailable' }, + state: 'unavailable', + } satisfies SearchPageModel); + render(); + + expect(screen.getByText('Dashboard unavailable')).toBeTruthy(); + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(localizedLinkCalls).toHaveLength(0); +}); diff --git a/app/apps/shell-super-app/tests/unit/shell-composition.test.ts b/app/apps/shell-super-app/tests/unit/shell-composition.test.ts index a95887300..d553e4ecd 100644 --- a/app/apps/shell-super-app/tests/unit/shell-composition.test.ts +++ b/app/apps/shell-super-app/tests/unit/shell-composition.test.ts @@ -1,4 +1,3 @@ -import { expect, it } from 'effect-rstest'; import { buildInstalledModuleCatalog, resolveInstalledModuleCatalog } from '@app/core-runtime'; import type { ContextAccessDecision, @@ -7,6 +6,8 @@ import type { TenantModuleState, } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeShellComposition } from '../../api/modules/shell-composition.ts'; import { ShellCompositionSchema } from '../../shared/api.ts'; @@ -21,15 +22,7 @@ const deployment = (appId: string, moduleId: string, displayName: string, order: defaultState: 'inactive', preservesHistoryWhenInactive: true, scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }, module: { description: `${displayName} capability.`, @@ -59,7 +52,10 @@ const deployment = (appId: string, moduleId: string, displayName: string, order: contributionKey: `${moduleId}.navigation.home`, entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module_access' }, + authorization: { + kind: 'context_permission', + permission: 'module_access', + }, entrypointKey: `${moduleId}.page.home`, moduleKey: moduleId, role: 'page', @@ -76,7 +72,10 @@ const deployment = (appId: string, moduleId: string, displayName: string, order: contributionKey: `${moduleId}.page.home`, entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module_access' }, + authorization: { + kind: 'context_permission', + permission: 'module_access', + }, entrypointKey: `${moduleId}.page.home`, moduleKey: moduleId, role: 'page', @@ -127,12 +126,10 @@ const catalogWithNumberLikeOrder = (): InstalledModuleCatalog => { ...contract.manifest.publicSurface, shellContributions: { ...contract.manifest.publicSurface.shellContributions, - navigation: contract.manifest.publicSurface.shellContributions.navigation.map( - (contribution) => ({ - ...contribution, - order: numberLikeOrder(contribution.order), - }), - ), + navigation: contract.manifest.publicSurface.shellContributions.navigation.map((contribution) => ({ + ...contribution, + order: numberLikeOrder(contribution.order), + })), }, }, }, @@ -152,7 +149,10 @@ const catalogWithSecondPropertyPage = (): InstalledModuleCatalog => { contributionKey: 'property.registry.page.customers', entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module_access' }, + authorization: { + kind: 'context_permission', + permission: 'module_access', + }, entrypointKey: 'property.registry.page.customers', moduleKey: 'property.registry', role: 'page', @@ -200,8 +200,7 @@ it.effect('composes one deterministic state and permission batch with lifecycle return Effect.succeed( moduleIds.map((moduleKey) => ({ moduleKey, - state: - moduleKey === 'documents.center' ? ('read_only' as const) : ('deprecated' as const), + state: moduleKey === 'documents.center' ? ('read_only' as const) : ('deprecated' as const), })), ); }, @@ -238,7 +237,10 @@ it.effect('composes one deterministic state and permission batch with lifecycle state: 'available', unavailableDeployments: [], }); - expect({ permissionBatches, stateBatches }).toEqual({ permissionBatches: 1, stateBatches: 1 }); + expect({ permissionBatches, stateBatches }).toEqual({ + permissionBatches: 1, + stateBatches: 1, + }); }), ); @@ -271,7 +273,11 @@ it.effect('keeps healthy navigation and exposes failed installed deployments sep } expect(result.navigation.map(({ moduleId }) => moduleId)).toEqual(['documents.center']); expect(result.unavailableDeployments).toEqual([ - { appId: 'property-registry', reason: 'timeout', status: 'unavailable' }, + { + appId: 'property-registry', + reason: 'timeout', + status: 'unavailable', + }, ]); expect(() => Schema.decodeUnknownSync(ShellCompositionSchema)(result)).not.toThrow(); }), @@ -312,7 +318,11 @@ it.effect.each(['inactive', 'suspended', 'quarantined', 'archived'] as const)( Effect.succeed(moduleIds.map((moduleKey) => ({ moduleKey, state }))), }, }).compose(context); - expect(result).toEqual({ navigation: [], state: 'available', unavailableDeployments: [] }); + expect(result).toEqual({ + navigation: [], + state: 'available', + unavailableDeployments: [], + }); }), ); @@ -346,59 +356,57 @@ it.effect('omits definite denial while preserving unavailable authorization as d }), ); -it.effect( - 'resolves direct targets independently with exhaustive safe outcomes and historical reads', - () => - Effect.gen(function* resolvesDirectTargetsIndependentlyWithExhaustive() { - let state: TenantModuleState = 'active'; - let decision: ContextAccessDecision = 'allowed'; - const mutableAccess = contextAccess({}); - const composition = makeShellComposition({ - catalog: Effect.succeed(catalog()), - contextAccess: { - ...mutableAccess, - modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision, key }))), - }, - moduleStates: { - getTenantModuleStates: (_tenantId, moduleIds) => - Effect.succeed(moduleIds.map((moduleKey) => ({ moduleKey, state }))), - }, - }); - const resolved = yield* composition.resolveModuleTarget(context, { - moduleId: 'property.registry', - }); - expect(resolved.outcome).toBe('resolved'); - decision = 'denied'; - const forbidden = yield* composition.resolveModuleTarget(context, { - moduleId: 'property.registry', - }); - expect(forbidden.outcome).toBe('forbidden'); - decision = 'unavailable'; - const unavailable = yield* composition.resolveModuleTarget(context, { - moduleId: 'property.registry', - }); - expect(unavailable.outcome).toBe('unavailable'); - decision = 'allowed'; - state = 'archived'; - const archived = yield* composition.resolveModuleTarget(context, { - moduleId: 'property.registry', - }); - expect(archived.outcome).toBe('not_found'); - const historical = yield* composition.resolveModuleTarget(context, { - access: 'historical_read', - moduleId: 'property.registry', - }); - expect(historical.outcome).toBe('resolved'); - const selectionRequired = yield* composition.resolveModuleTarget( - { principalId, tenantId }, - { moduleId: 'property.registry' }, - ); - expect(selectionRequired.outcome).toBe('selection_required'); - const missing = yield* composition.resolveModuleTarget(context, { - moduleId: 'missing.module', - }); - expect(missing.outcome).toBe('not_found'); - }), +it.effect('resolves direct targets independently with exhaustive safe outcomes and historical reads', () => + Effect.gen(function* resolvesDirectTargetsIndependentlyWithExhaustive() { + let state: TenantModuleState = 'active'; + let decision: ContextAccessDecision = 'allowed'; + const mutableAccess = contextAccess({}); + const composition = makeShellComposition({ + catalog: Effect.succeed(catalog()), + contextAccess: { + ...mutableAccess, + modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision, key }))), + }, + moduleStates: { + getTenantModuleStates: (_tenantId, moduleIds) => + Effect.succeed(moduleIds.map((moduleKey) => ({ moduleKey, state }))), + }, + }); + const resolved = yield* composition.resolveModuleTarget(context, { + moduleId: 'property.registry', + }); + expect(resolved.outcome).toBe('resolved'); + decision = 'denied'; + const forbidden = yield* composition.resolveModuleTarget(context, { + moduleId: 'property.registry', + }); + expect(forbidden.outcome).toBe('forbidden'); + decision = 'unavailable'; + const unavailable = yield* composition.resolveModuleTarget(context, { + moduleId: 'property.registry', + }); + expect(unavailable.outcome).toBe('unavailable'); + decision = 'allowed'; + state = 'archived'; + const archived = yield* composition.resolveModuleTarget(context, { + moduleId: 'property.registry', + }); + expect(archived.outcome).toBe('not_found'); + const historical = yield* composition.resolveModuleTarget(context, { + access: 'historical_read', + moduleId: 'property.registry', + }); + expect(historical.outcome).toBe('resolved'); + const selectionRequired = yield* composition.resolveModuleTarget( + { principalId, tenantId }, + { moduleId: 'property.registry' }, + ); + expect(selectionRequired.outcome).toBe('selection_required'); + const missing = yield* composition.resolveModuleTarget(context, { + moduleId: 'missing.module', + }); + expect(missing.outcome).toBe('not_found'); + }), ); it.effect.each(['active', 'read_only', 'deprecated'] as const)( diff --git a/app/apps/shell-super-app/tests/unit/shell-governed-read-schemas.test.ts b/app/apps/shell-super-app/tests/unit/shell-governed-read-schemas.test.ts index dd8d5165c..3abd282d8 100644 --- a/app/apps/shell-super-app/tests/unit/shell-governed-read-schemas.test.ts +++ b/app/apps/shell-super-app/tests/unit/shell-governed-read-schemas.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from 'effect-rstest'; import { Schema } from 'effect'; +import { describe, expect, test } from 'effect-rstest'; + import { GovernedResolvedModuleTargetSchema, GovernedResolveModuleTargetPayloadSchema, @@ -24,9 +25,7 @@ describe('Shell governed module-target schemas', () => { expect(decodedInput).toEqual(input); expect(decodedResult).toEqual(result); - expect(Schema.encodeSync(GovernedResolveModuleTargetPayloadSchema)(decodedInput)).toEqual( - input, - ); + expect(Schema.encodeSync(GovernedResolveModuleTargetPayloadSchema)(decodedInput)).toEqual(input); expect(Schema.encodeSync(GovernedResolvedModuleTargetSchema)(decodedResult)).toEqual(result); }); }); diff --git a/app/apps/shell-super-app/tests/unit/shell-resources.test.ts b/app/apps/shell-super-app/tests/unit/shell-resources.test.ts index 5735eca6d..ef4287506 100644 --- a/app/apps/shell-super-app/tests/unit/shell-resources.test.ts +++ b/app/apps/shell-super-app/tests/unit/shell-resources.test.ts @@ -1,4 +1,3 @@ -import { expect, it } from 'effect-rstest'; import { buildInstalledModuleCatalog } from '@app/core-runtime'; import type { ContextAccessDecision, @@ -7,6 +6,8 @@ import type { TenantModuleState, } from '@app/core-runtime'; import { DateTime, Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { attachShellMedia, makeShellResourceDetail, @@ -61,15 +62,7 @@ const catalog = (): InstalledModuleCatalog => defaultState: 'inactive', preservesHistoryWhenInactive: true, scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }, module: { description: 'Property capability.', @@ -100,7 +93,12 @@ const catalog = (): InstalledModuleCatalog => schemaVersion: '1', }, ], - api: [{ key: 'property.registry.resource-api', operationKeys: ['detail'] }], + api: [ + { + key: 'property.registry.resource-api', + operationKeys: ['detail'], + }, + ], components: [], events: [], reports: [], @@ -180,8 +178,7 @@ const access = ( resourceWriteDecision: ContextAccessDecision = resourceDecision, ): ContextAccessService => ({ legalEntities: () => Effect.succeed([]), - modules: ({ moduleIds }) => - Effect.succeed(moduleIds.map((key) => ({ decision: moduleDecision, key }))), + modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision: moduleDecision, key }))), resources: ({ permission = 'read', resources }) => Effect.succeed( resources.map(({ moduleId: owner, resourceId, resourceType: type }) => ({ @@ -189,8 +186,7 @@ const access = ( key: `${owner}:${type}:${resourceId}`, })), ), - tenants: ({ tenantIds }) => - Effect.succeed(tenantIds.map((key) => ({ decision: moduleDecision, key }))), + tenants: ({ tenantIds }) => Effect.succeed(tenantIds.map((key) => ({ decision: moduleDecision, key }))), }); const dependencies = ( @@ -232,23 +228,21 @@ it.effect('search treats empty input as empty without touching providers', () => }), ); -it.effect( - 'search keeps an eligible provider with zero candidates as a successful empty result', - () => - Effect.gen(function* searchKeepsAnEligibleProviderWith() { - const baseline = dependencies(); - const result = yield* makeShellSearch( - { - ...baseline, - contextAccess: { - ...baseline.contextAccess, - resources: () => Effect.die('empty results must not authorize an empty resource batch'), - }, +it.effect('search keeps an eligible provider with zero candidates as a successful empty result', () => + Effect.gen(function* searchKeepsAnEligibleProviderWith() { + const baseline = dependencies(); + const result = yield* makeShellSearch( + { + ...baseline, + contextAccess: { + ...baseline.contextAccess, + resources: () => Effect.die('empty results must not authorize an empty resource batch'), }, - { search: () => Effect.succeed([]) }, - ).search(context, 'unit'); - expect(result).toEqual({ partial: false, results: [] }); - }), + }, + { search: () => Effect.succeed([]) }, + ).search(context, 'unit'); + expect(result).toEqual({ partial: false, results: [] }); + }), ); it.effect('search filters resource denials and reports partial provider failure', () => @@ -392,9 +386,7 @@ it.effect( }, }, }; - const partyCatalog = buildInstalledModuleCatalog([ - { contract: partyContract, expectedAppId: 'party-registry' }, - ]); + const partyCatalog = buildInstalledModuleCatalog([{ contract: partyContract, expectedAppId: 'party-registry' }]); const calls: unknown[] = []; const baseline = dependencies(); const result = yield* makeShellSearch( @@ -429,9 +421,16 @@ it.effect( ]); }, }, - ).search(tenantContext, { includeArchived: true, query: ' party ', role: 'CUSTOMER' }); + ).search(tenantContext, { + includeArchived: true, + query: ' party ', + role: 'CUSTOMER', + }); - expect(calls[0]).toEqual({ permission: 'read_party_identity', tenantIds: [tenantId] }); + expect(calls[0]).toEqual({ + permission: 'read_party_identity', + tenantIds: [tenantId], + }); expect(calls[1]).toMatchObject({ includeArchived: true, query: 'party' }); expect(calls[1]).not.toHaveProperty('role'); expect(result).toEqual({ @@ -463,136 +462,134 @@ it.effect('search fails only when every eligible provider fails', () => }), ); -it.effect( - 'Counterparty search preserves both identities, selected scope, roles and collision metadata', - () => - Effect.gen(function* CounterpartySearchPreservesBothIdentitiesSelected() { - const [contract] = catalog().contracts; - if (contract === undefined) { - throw new Error('The test catalog must include one installed contract'); - } - const filteredCatalog = buildInstalledModuleCatalog([ - { - contract: { - ...contract, - manifest: { - ...contract.manifest, - publicSurface: { - ...contract.manifest.publicSurface, - search: contract.manifest.publicSurface.search.map((descriptor) => ({ - ...descriptor, - requestFilters: ['includeArchived', 'role'] as const, - })), - }, +it.effect('Counterparty search preserves both identities, selected scope, roles and collision metadata', () => + Effect.gen(function* CounterpartySearchPreservesBothIdentitiesSelected() { + const [contract] = catalog().contracts; + if (contract === undefined) { + throw new Error('The test catalog must include one installed contract'); + } + const filteredCatalog = buildInstalledModuleCatalog([ + { + contract: { + ...contract, + manifest: { + ...contract.manifest, + publicSurface: { + ...contract.manifest.publicSurface, + search: contract.manifest.publicSurface.search.map((descriptor) => ({ + ...descriptor, + requestFilters: ['includeArchived', 'role'] as const, + })), }, }, - expectedAppId: 'property-registry', }, - ]); - const counterpartyRef = { ...ref, tenantId }; - const canonicalPartyRef = { - ...ref, - resourceId: 'party-1', - resourceType: 'property.registry.party', - tenantId, - }; - const collision = { - counterpartyRefs: [counterpartyRef, { ...counterpartyRef, resourceId: 'unit-2' }], - kind: 'CANONICAL_PARTY_COUNTERPARTY_COLLISION', - }; - const value = { - collision, - currentRoles: ['CUSTOMER', 'SUPPLIER'], - legalEntity: { legalEntityId, tenantId }, - party: { - archived: true, - matchedViaAlias: true, - ref: canonicalPartyRef, - title: 'Canonical Party', - }, - ref: counterpartyRef, - }; - const calls: unknown[] = []; - const search = makeShellSearch( - { ...dependencies(), catalog: Effect.succeed(filteredCatalog) }, - { - search: (input) => { - calls.push(input); - return Effect.succeed([value]); - }, + expectedAppId: 'property-registry', + }, + ]); + const counterpartyRef = { ...ref, tenantId }; + const canonicalPartyRef = { + ...ref, + resourceId: 'party-1', + resourceType: 'property.registry.party', + tenantId, + }; + const collision = { + counterpartyRefs: [counterpartyRef, { ...counterpartyRef, resourceId: 'unit-2' }], + kind: 'CANONICAL_PARTY_COUNTERPARTY_COLLISION', + }; + const value = { + collision, + currentRoles: ['CUSTOMER', 'SUPPLIER'], + legalEntity: { legalEntityId, tenantId }, + party: { + archived: true, + matchedViaAlias: true, + ref: canonicalPartyRef, + title: 'Canonical Party', + }, + ref: counterpartyRef, + }; + const calls: unknown[] = []; + const search = makeShellSearch( + { ...dependencies(), catalog: Effect.succeed(filteredCatalog) }, + { + search: (input) => { + calls.push(input); + return Effect.succeed([value]); }, - ); - const result = yield* search.search(context, { - includeArchived: true, - query: 'canonical', - role: 'CUSTOMER', - }); - expect(calls[0]).toMatchObject({ includeArchived: true, role: 'CUSTOMER' }); - expect(result).toEqual({ - partial: false, - results: [{ ...value, kind: 'counterparty', title: 'Canonical Party' }], - }); - expect(yield* search.search(tenantContext, 'canonical')).toEqual({ - partial: false, - results: [], - }); - expect(calls).toHaveLength(1); - const baseline = dependencies(); - const redacted = yield* makeShellSearch( - { - ...baseline, - catalog: Effect.succeed(filteredCatalog), - contextAccess: { - ...baseline.contextAccess, - resources: ({ resources }) => - Effect.succeed( - resources.map((resource) => ({ - decision: - resource.resourceId === 'unit-2' ? ('denied' as const) : ('allowed' as const), - key: `${resource.moduleId}:${resource.resourceType}:${resource.resourceId}`, - })), - ), - }, + }, + ); + const result = yield* search.search(context, { + includeArchived: true, + query: 'canonical', + role: 'CUSTOMER', + }); + expect(calls[0]).toMatchObject({ + includeArchived: true, + role: 'CUSTOMER', + }); + expect(result).toEqual({ + partial: false, + results: [{ ...value, kind: 'counterparty', title: 'Canonical Party' }], + }); + expect(yield* search.search(tenantContext, 'canonical')).toEqual({ + partial: false, + results: [], + }); + expect(calls).toHaveLength(1); + const baseline = dependencies(); + const redacted = yield* makeShellSearch( + { + ...baseline, + catalog: Effect.succeed(filteredCatalog), + contextAccess: { + ...baseline.contextAccess, + resources: ({ resources }) => + Effect.succeed( + resources.map((resource) => ({ + decision: resource.resourceId === 'unit-2' ? ('denied' as const) : ('allowed' as const), + key: `${resource.moduleId}:${resource.resourceType}:${resource.resourceId}`, + })), + ), }, - { search: () => Effect.succeed([value]) }, - ).search(context, 'canonical'); - expect(JSON.stringify(redacted)).not.toContain('unit-2'); - expect(redacted.results[0]).not.toHaveProperty('collision'); - }), + }, + { search: () => Effect.succeed([value]) }, + ).search(context, 'canonical'); + expect(JSON.stringify(redacted)).not.toContain('unit-2'); + expect(redacted.results[0]).not.toHaveProperty('collision'); + }), ); -it.effect( - 'treats a missing tenant module-state record as hidden rather than authorization uncertainty', - () => - Effect.gen(function* treatsAMissingTenantModuleState() { - let calls = 0; - const hiddenDependencies = { - ...dependencies(), - moduleStates: { getTenantModuleStates: () => Effect.succeed([]) }, - }; - expect( - yield* makeShellSearch(hiddenDependencies, { - search: () => { - calls += 1; - return Effect.succeed([{ ref, title: 'Unit 1' }]); - }, - }).search(context, 'unit'), - ).toEqual({ partial: false, results: [] }); - const gateway = { - detail: () => { +it.effect('treats a missing tenant module-state record as hidden rather than authorization uncertainty', () => + Effect.gen(function* treatsAMissingTenantModuleState() { + let calls = 0; + const hiddenDependencies = { + ...dependencies(), + moduleStates: { getTenantModuleStates: () => Effect.succeed([]) }, + }; + expect( + yield* makeShellSearch(hiddenDependencies, { + search: () => { calls += 1; - return Effect.succeed({ fields: [], title: 'Unit 1' }); + return Effect.succeed([{ ref, title: 'Unit 1' }]); }, - timeline: () => Effect.succeed({ entries: [], projectionLagging: false }), - }; - expect( - yield* makeShellResourceDetail(hiddenDependencies, gateway).resolve(context, ref), - ).toEqual({ outcome: 'not_found' }); - expect(yield* attachShellMedia(context, ref)).toEqual({ - outcome: 'unavailable', - }); - expect(calls).toBe(0); - }), + }).search(context, 'unit'), + ).toEqual({ partial: false, results: [] }); + const gateway = { + detail: () => { + calls += 1; + return Effect.succeed({ fields: [], title: 'Unit 1' }); + }, + timeline: () => Effect.succeed({ entries: [], projectionLagging: false }), + }; + expect(yield* makeShellResourceDetail(hiddenDependencies, gateway).resolve(context, ref)).toEqual({ + outcome: 'not_found', + }); + expect(yield* attachShellMedia(context, ref)).toEqual({ + outcome: 'unavailable', + }); + expect(calls).toBe(0); + }), ); it.effect('search fails closed for module or resource authorization uncertainty', () => @@ -618,35 +615,27 @@ it.effect('search fails closed for module or resource authorization uncertainty' }), ); -it.effect( - 'resource detail applies catalog, state, module and resource gates before providers', - () => - Effect.gen(function* resourceDetailAppliesCatalogStateModule() { - let calls = 0; - const provider = { - detail: () => { - calls += 1; - return Effect.succeed({ fields: [], title: 'Unit 1' }); - }, - timeline: () => Effect.succeed({ entries: [], projectionLagging: false }), - }; - expect( - yield* makeShellResourceDetail(dependencies('inactive'), provider).resolve(context, ref), - ).toEqual({ outcome: 'not_found' }); - expect( - yield* makeShellResourceDetail(dependencies('active', 'denied'), provider).resolve( - context, - ref, - ), - ).toEqual({ outcome: 'forbidden' }); - expect( - yield* makeShellResourceDetail( - dependencies('active', 'allowed', 'unavailable'), - provider, - ).resolve(context, ref), - ).toEqual({ outcome: 'unavailable' }); - expect(calls).toBe(0); - }), +it.effect('resource detail applies catalog, state, module and resource gates before providers', () => + Effect.gen(function* resourceDetailAppliesCatalogStateModule() { + let calls = 0; + const provider = { + detail: () => { + calls += 1; + return Effect.succeed({ fields: [], title: 'Unit 1' }); + }, + timeline: () => Effect.succeed({ entries: [], projectionLagging: false }), + }; + expect(yield* makeShellResourceDetail(dependencies('inactive'), provider).resolve(context, ref)).toEqual({ + outcome: 'not_found', + }); + expect(yield* makeShellResourceDetail(dependencies('active', 'denied'), provider).resolve(context, ref)).toEqual({ + outcome: 'forbidden', + }); + expect( + yield* makeShellResourceDetail(dependencies('active', 'allowed', 'unavailable'), provider).resolve(context, ref), + ).toEqual({ outcome: 'unavailable' }); + expect(calls).toBe(0); + }), ); it.effect('resource detail sorts an authorized timeline and exposes projection lag', () => @@ -656,8 +645,16 @@ it.effect('resource detail sorts an authorized timeline and exposes projection l timeline: () => Effect.succeed({ entries: [ - { occurredAt: '2026-01-01T00:00:00Z', summary: 'Created', timelineEntryId: '1' }, - { occurredAt: '2026-02-01T00:00:00Z', summary: 'Updated', timelineEntryId: '2' }, + { + occurredAt: '2026-01-01T00:00:00Z', + summary: 'Created', + timelineEntryId: '1', + }, + { + occurredAt: '2026-02-01T00:00:00Z', + summary: 'Updated', + timelineEntryId: '2', + }, ], projectionLagging: true, }), @@ -683,11 +680,17 @@ it.effect('resource detail sorts an authorized timeline and exposes projection l if (result.outcome !== 'resolved') { throw new TypeError('The authorized resource fixture must resolve'); } - expect( - yield* Schema.encodeEffect(Schema.Array(ShellTimelineEntrySchema))(result.timeline), - ).toEqual([ - { occurredAt: '2026-02-01T00:00:00.000Z', summary: 'Updated', timelineEntryId: '2' }, - { occurredAt: '2026-01-01T00:00:00.000Z', summary: 'Created', timelineEntryId: '1' }, + expect(yield* Schema.encodeEffect(Schema.Array(ShellTimelineEntrySchema))(result.timeline)).toEqual([ + { + occurredAt: '2026-02-01T00:00:00.000Z', + summary: 'Updated', + timelineEntryId: '2', + }, + { + occurredAt: '2026-01-01T00:00:00.000Z', + summary: 'Created', + timelineEntryId: '1', + }, ]); }), ); @@ -698,18 +701,18 @@ it.effect('media affordance remains unavailable until a generated Action exists' detail: () => Effect.succeed({ fields: [], title: 'Unit 1' }), timeline: () => Effect.succeed({ entries: [], projectionLagging: false }), }; + expect(yield* makeShellResourceDetail(dependencies('read_only'), provider).resolve(context, ref)).toMatchObject({ + media: { enabled: false, reason: 'read_only' }, + }); expect( - yield* makeShellResourceDetail(dependencies('read_only'), provider).resolve(context, ref), - ).toMatchObject({ media: { enabled: false, reason: 'read_only' } }); - expect( - yield* makeShellResourceDetail( - dependencies('active', 'allowed', 'allowed', 'denied'), - provider, - ).resolve(context, ref), - ).toMatchObject({ media: { enabled: false, reason: 'unavailable' } }); - expect( - yield* makeShellResourceDetail(dependencies(), provider).resolve(context, ref), + yield* makeShellResourceDetail(dependencies('active', 'allowed', 'allowed', 'denied'), provider).resolve( + context, + ref, + ), ).toMatchObject({ media: { enabled: false, reason: 'unavailable' } }); + expect(yield* makeShellResourceDetail(dependencies(), provider).resolve(context, ref)).toMatchObject({ + media: { enabled: false, reason: 'unavailable' }, + }); }), ); diff --git a/app/apps/shell-super-app/tests/unit/stage-demo-bootstrap.test.ts b/app/apps/shell-super-app/tests/unit/stage-demo-bootstrap.test.ts index 6c701852e..41363ad5d 100644 --- a/app/apps/shell-super-app/tests/unit/stage-demo-bootstrap.test.ts +++ b/app/apps/shell-super-app/tests/unit/stage-demo-bootstrap.test.ts @@ -1,6 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { readFile } from 'node:fs/promises'; + import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { STAGE_DEMO_ACCOUNTS, classifyExactStageDemoRecord, @@ -72,7 +74,9 @@ it.effect('refuses to provision outside stage or without an operator-supplied pa STAGE_DEMO_PASSWORD: undefined, }), ), - ).toMatchObject({ reason: expect.stringMatching(/STAGE_DEMO_PASSWORD/u) }); + ).toMatchObject({ + reason: expect.stringMatching(/STAGE_DEMO_PASSWORD/u), + }); expect( yield* Effect.flip( parseStageDemoBootstrapConfig({ @@ -80,18 +84,22 @@ it.effect('refuses to provision outside stage or without an operator-supplied pa STAGE_SIAMPARK_PASSWORD: undefined, }), ), - ).toMatchObject({ reason: expect.stringMatching(/STAGE_SIAMPARK_PASSWORD/u) }); + ).toMatchObject({ + reason: expect.stringMatching(/STAGE_SIAMPARK_PASSWORD/u), + }); }), ); it.effect('treats an exact record as idempotent and rejects conflicting state', () => Effect.gen(function* treatsAnExactRecordAsIdempotent() { - const expected = { name: 'Techsio', slug: 'techsio', status: 'active' } as const; + const expected = { + name: 'Techsio', + slug: 'techsio', + status: 'active', + } as const; expect(yield* classifyExactStageDemoRecord('tenant', undefined, expected)).toBe('create'); expect(yield* classifyExactStageDemoRecord('tenant', expected, expected)).toBe('existing'); expect( - yield* Effect.flip( - classifyExactStageDemoRecord('tenant', { ...expected, name: 'Other tenant' }, expected), - ), + yield* Effect.flip(classifyExactStageDemoRecord('tenant', { ...expected, name: 'Other tenant' }, expected)), ).toMatchObject({ reason: expect.stringMatching(/conflicts/u) }); }), ); @@ -100,36 +108,24 @@ it.live('keeps the demo bootstrap operator-invoked and excludes its password fro const rootPackage = yield* Effect.promise(() => readFile(new URL('../../../../package.json', import.meta.url), 'utf-8'), ); - const shellPackage = yield* Effect.promise(() => - readFile(new URL('../../package.json', import.meta.url), 'utf-8'), - ); + const shellPackage = yield* Effect.promise(() => readFile(new URL('../../package.json', import.meta.url), 'utf-8')); const bootstrapCommand = yield* Effect.promise(() => readFile(new URL('../../scripts/bootstrap-stage-demo.sh', import.meta.url), 'utf-8'), ); - const zerops = yield* Effect.promise(() => - readFile(new URL('../../../../zerops.yaml', import.meta.url), 'utf-8'), - ); + const zerops = yield* Effect.promise(() => readFile(new URL('../../../../zerops.yaml', import.meta.url), 'utf-8')); const coreBootstrap = yield* Effect.promise(() => readFile( - new URL( - '../../../../packages/core-runtime/src/install/stage-context-bootstrap.ts', - import.meta.url, - ), + new URL('../../../../packages/core-runtime/src/install/stage-context-bootstrap.ts', import.meta.url), 'utf-8', ), ); const shellBootstrap = yield* Effect.promise(() => - readFile( - new URL('../../api/auth/stage-demo-bootstrap-runtime-infrastructure.ts', import.meta.url), - 'utf-8', - ), + readFile(new URL('../../api/auth/stage-demo-bootstrap-runtime-infrastructure.ts', import.meta.url), 'utf-8'), ); expect(JSON.parse(rootPackage).scripts['stage:bootstrap-demo']).toBe( 'pnpm --filter @app/shell-super-app stage:bootstrap-demo', ); - expect(JSON.parse(shellPackage).scripts['stage:bootstrap-demo']).toBe( - 'sh scripts/bootstrap-stage-demo.sh', - ); + expect(JSON.parse(shellPackage).scripts['stage:bootstrap-demo']).toBe('sh scripts/bootstrap-stage-demo.sh'); expect(bootstrapCommand).toMatch(/stty -echo/u); expect(bootstrapCommand).toMatch(/STAGE_DEMO_PASSWORD/u); expect(bootstrapCommand).toMatch(/STAGE_SIAMPARK_PASSWORD/u); diff --git a/app/apps/shell-super-app/tsconfig.json b/app/apps/shell-super-app/tsconfig.json index 97d9677e4..e1f8a9596 100644 --- a/app/apps/shell-super-app/tsconfig.json +++ b/app/apps/shell-super-app/tsconfig.json @@ -8,27 +8,30 @@ "incremental": true, "noEmit": false, "outDir": "../../node_modules/.cache/tsgo/declarations/apps__shell-super-app", - "skipLibCheck": true, "tsBuildInfoFile": "../../node_modules/.cache/tsgo/apps__shell-super-app.tsbuildinfo", - "types": ["node", "bun-types/sqlite"] + "types": [ + "node", + "bun-types/sqlite" + ] }, "include": [ - "api", "src", "locales/**/*.json", "package.json", - "shared" + "shared", + "server", + "api" ], "references": [ - { - "path": "../../packages/core-runtime" - }, { "path": "../../packages/shared-contracts" }, { "path": "../../packages/shared-design-tokens" }, + { + "path": "../../packages/core-runtime" + }, { "path": "../../verticals/party-registry" } diff --git a/app/apps/shell-super-app/tsconfig.mf-types.json b/app/apps/shell-super-app/tsconfig.mf-types.json index cb2a4206b..d2e95fd52 100644 --- a/app/apps/shell-super-app/tsconfig.mf-types.json +++ b/app/apps/shell-super-app/tsconfig.mf-types.json @@ -2,5 +2,8 @@ "extends": "../../tsconfig.base.json", "include": [ "src/modern-app-env.d.ts" - ] + ], + "compilerOptions": { + "skipLibCheck": true + } } diff --git a/app/docs/architecture/ACTIONS.md b/app/docs/architecture/ACTIONS.md index c4512d72b..901ed6f7c 100644 --- a/app/docs/architecture/ACTIONS.md +++ b/app/docs/architecture/ACTIONS.md @@ -2,10 +2,7 @@ This document defines state-changing Action execution. MicroVertical deployment and communication are defined in [MicroVertical Architecture](./MICROVERTICALS.md); public failure contracts are defined in [Effect Error and HTTP Contracts](./ERRORS.md). -Operation scope, owner-local scoped services, database settings, and governed read evidence are -defined in [Governed Data Access and Operation Scope](./DATA_ACCESS.md). Every Action explicitly -declares legal-entity scope independently of entrypoint tenant/system scope. Business handlers never -receive or import a database executor. +Operation scope, owner-local scoped services, database settings, and governed read evidence are defined in [Governed Data Access and Operation Scope](./DATA_ACCESS.md). Every Action explicitly declares legal-entity scope independently of entrypoint tenant/system scope. Business handlers never receive or import a database executor. ## Core Rules @@ -14,89 +11,21 @@ receive or import a database executor. - Every Action descriptor declares an explicit readonly array of immutable Policy object references. A global Shell/Core Policy may be referenced by any Action; an executable MicroVertical Policy may be referenced only by an Action with the same owning module key. Raw Policy keys, registries, and cross-owner Policy imports are forbidden. - A Domain Event is a past-tense business fact produced by a successfully committed Action. Domain Events describe what happened; they do not initiate hidden synchronous business state changes. - Generate Actions, Permissions, Policies, and Outbox Messages with their respective Codesmith generators. -- Every Action requires an explicit SpiceDB `executor` relationship. A fully consistent - `action#execute` result of `NO_PERMISSION`, including an Action with no relationships, is a - definite denial; there is no unconfigured allow path. +- Every Action requires an explicit SpiceDB `executor` relationship. A fully consistent `action#execute` result of `NO_PERMISSION`, including an Action with no relationships, is a definite denial; there is no unconfigured allow path. - Every Action descriptor owns a structured `action`/`write` entrypoint. Business Actions are tenant-scoped; Core recovery capabilities are explicitly system-scoped as defined by [Module Entrypoints and Tenant State](./MODULE_ENTRYPOINTS.md). -- A MicroVertical Action's `owningModuleKey`, key prefix, event producer, and access-policy identity - use the manifest's dotted OntOS `moduleId`, never the topology deployment `appId`. The real Action - value is published in the owner-authored manifest and bound to its private handler only in the - owner-local runtime registration. See [OntOS Module Manifests](./MODULE_MANIFESTS.md). - -The only installation exceptions are the first operator-invoked creation of a deployment's initial -Tenant context and a statically defined, operator-invoked stage bootstrap context set. Each context -may include its legal entity, human Principal/Auth binding, module state, and matching authorization -relationships. An Action cannot perform these transitions because their trusted tenant and Principal -do not exist yet. The stage set must be fixed in source control; callers cannot supply arbitrary -Tenant or context data. Every bootstrap must be stage/environment gated, idempotent and conflict -detecting, must stay inside a Core-owned Effect boundary, and must never run from normal application -startup or an automatic deployment. Shell may create the matching Better Auth credentials, then pass -only their provider user IDs in the fixed documented order to that Core installation boundary. Core -owns the context definitions and their provider-user mapping. Every later or non-fixed state change -uses an Action. - -Better Auth credential and session lifecycle operations—sign-in, sign-out or revocation, refresh, -active tenant selection, API-key provider mechanics, and mechanical impersonation-session -creation/restoration—are Shell-owned authentication mechanics, not canonical business-state -mutations. They use the strict typed Auth BFF and must not update Core business tables or emit -Domain Events. Core Principal Auth Bindings remain the tenant-access authority, and a selected -tenant ID stored on the Auth session grants no permission. Any later canonical Core or -MicroVertical state change still requires an Action; authentication mechanics do not provide a -bypass. - -All Core identity changes use generated restricted `core.identity.*` Actions: non-human principal -creation/status, self or managed API-key binding/status, and requested/started/stopped support -checkpoints. Provider key IDs may appear only in private Shell orchestration and the binding Action -payload; raw keys, hashes, cookies, provider user IDs, and session tokens may not. Identity handlers -record invariant reads as Data Access Events. Support checkpoint handlers additionally attach the -safe reason, original/effective principal IDs, checkpoint, and optional safe session reference to -the sensitive `action.executed` evidence; the audit row supplies tenant, timestamp, and Action -identity. - -Action execution and tenant role authorization are independent grants. The default environment -rule is an explicit `action:#executor@tenant:#member` relationship, so it -allows every authenticated active Principal in that trusted Tenant and nobody outside it. Direct -`principal` executor relationships remain supported for narrower grants and rollout compatibility. -The self-key Actions require their explicit Action executor. Principal creation/status and managed-key mutations -require both their Action executor and tenant `manage_identity`; support start requires the support -checkpoint executor and tenant `impersonate`. Provision Action relations with the lossless object ID -from `toSpiceDbActionObjectId`, never a hand-maintained alternate encoding, and remove them when the -role, membership, or workload authorization is revoked. The parameterless operator command -`mise exec -- pnpm authorization:provision-current-actions` discovers the complete current Action -catalog and provisions only the fixed development or stage Tenant sets. It is idempotent, accepts no -caller-supplied Tenant or Action identifiers, and must never run during startup, migration, sandbox -preparation, or automatic deployment. Bootstrap `allowed-principal` tuples are test-only. -The generated Action descriptor declares the additional tenant permission, and Core evaluates it -inside the canonical Action authorization boundary after the executor check. A definite tenant-role -denial produces the same durable permission-denial outcome; an indeterminate check fails retryably. -Only a decoded support `stopped` checkpoint omits the continuing `impersonate` requirement so secure -termination remains possible, while its Action executor check is still mandatory. - -Provider cleanup is not an Action retry disguised as a new mutation. Shell compares the governed -Core binding status with Auth's enabled metadata, reports disagreement as `cleanupPending`, and may -retry only the provider mechanic when Core already holds the requested terminal state. A rotation -must never return a retryable failure while leaving a newly active secret undisclosed: it first -attempts to revoke the replacement, and if that rollback cannot be proven it returns the one-time -secret with cleanup debt. Newly issued provider keys carry a private mechanical -`binding_pending_v1` marker from the same provider insert that creates the credential. Shell clears -the marker after it observes the Core binding; a repeated issuance reconciles any retained marker -against Core and disables an orphan before creating another key. Each marker is scoped by the -trusted tenant and issuing Principal and remains leased for five minutes, including retries with the -same caller idempotency key, so concurrent requests cannot reclaim a credential that is still being -bound. The marker is neither an OntOS permission nor public metadata. -Stale-marker lookup is tenant/issuer/staleness-filtered and indexed in Auth, processes at most one -bounded batch per request, and requires a retry before issuance when more cleanup remains. - -Support start creates an Auth-owned non-secret recovery record after the provider session is -initialized and before the started checkpoint commits; support stop therefore always has durable -recovery state before Better Auth deletes or expires the impersonated session. The record carries -only safe correlation, OntOS principal/binding IDs, reason, -tenant, and safe session reference—not a token or cookie. Stopped evidence is idempotent; -post-restore evidence or recovery cleanup failure still forwards the restored cookie and a repeated -stop resumes the checkpoint. The recovery context is accepted only for the exact generated Action -and a decoded `stopped` payload; it still performs the Action's normal SpiceDB permission check. -Mechanical session termination therefore remains independent of evidence availability, while a -denied or unavailable checkpoint remains pending instead of fabricating authorization. +- A MicroVertical Action's `owningModuleKey`, key prefix, event producer, and access-policy identity use the manifest's dotted OntOS `moduleId`, never the topology deployment `appId`. The real Action value is published in the owner-authored manifest and bound to its private handler only in the owner-local runtime registration. See [OntOS Module Manifests](./MODULE_MANIFESTS.md). + +The only installation exceptions are the first operator-invoked creation of a deployment's initial Tenant context and a statically defined, operator-invoked stage bootstrap context set. Each context may include its legal entity, human Principal/Auth binding, module state, and matching authorization relationships. An Action cannot perform these transitions because their trusted tenant and Principal do not exist yet. The stage set must be fixed in source control; callers cannot supply arbitrary Tenant or context data. Every bootstrap must be stage/environment gated, idempotent and conflict detecting, must stay inside a Core-owned Effect boundary, and must never run from normal application startup or an automatic deployment. Shell may create the matching Better Auth credentials, then pass only their provider user IDs in the fixed documented order to that Core installation boundary. Core owns the context definitions and their provider-user mapping. Every later or non-fixed state change uses an Action. + +Better Auth credential and session lifecycle operations—sign-in, sign-out or revocation, refresh, active tenant selection, API-key provider mechanics, and mechanical impersonation-session creation/restoration—are Shell-owned authentication mechanics, not canonical business-state mutations. They use the strict typed Auth BFF and must not update Core business tables or emit Domain Events. Core Principal Auth Bindings remain the tenant-access authority, and a selected tenant ID stored on the Auth session grants no permission. Any later canonical Core or MicroVertical state change still requires an Action; authentication mechanics do not provide a bypass. + +All Core identity changes use generated restricted `core.identity.*` Actions: non-human principal creation/status, self or managed API-key binding/status, and requested/started/stopped support checkpoints. Provider key IDs may appear only in private Shell orchestration and the binding Action payload; raw keys, hashes, cookies, provider user IDs, and session tokens may not. Identity handlers record invariant reads as Data Access Events. Support checkpoint handlers additionally attach the safe reason, original/effective principal IDs, checkpoint, and optional safe session reference to the sensitive `action.executed` evidence; the audit row supplies tenant, timestamp, and Action identity. + +Action execution and tenant role authorization are independent grants. The default environment rule is an explicit `action:#executor@tenant:#member` relationship, so it allows every authenticated active Principal in that trusted Tenant and nobody outside it. Direct `principal` executor relationships remain supported for narrower grants and rollout compatibility. The self-key Actions require their explicit Action executor. Principal creation/status and managed-key mutations require both their Action executor and tenant `manage_identity`; support start requires the support checkpoint executor and tenant `impersonate`. Provision Action relations with the lossless object ID from `toSpiceDbActionObjectId`, never a hand-maintained alternate encoding, and remove them when the role, membership, or workload authorization is revoked. The parameterless operator command `mise exec -- pnpm authorization:provision-current-actions` discovers the complete current Action catalog and provisions only the fixed development or stage Tenant sets. It is idempotent, accepts no caller-supplied Tenant or Action identifiers, and must never run during startup, migration, sandbox preparation, or automatic deployment. Bootstrap `allowed-principal` tuples are test-only. The generated Action descriptor declares the additional tenant permission, and Core evaluates it inside the canonical Action authorization boundary after the executor check. A definite tenant-role denial produces the same durable permission-denial outcome; an indeterminate check fails retryably. Only a decoded support `stopped` checkpoint omits the continuing `impersonate` requirement so secure termination remains possible, while its Action executor check is still mandatory. + +Provider cleanup is not an Action retry disguised as a new mutation. Shell compares the governed Core binding status with Auth's enabled metadata, reports disagreement as `cleanupPending`, and may retry only the provider mechanic when Core already holds the requested terminal state. A rotation must never return a retryable failure while leaving a newly active secret undisclosed: it first attempts to revoke the replacement, and if that rollback cannot be proven it returns the one-time secret with cleanup debt. Newly issued provider keys carry a private mechanical `binding_pending_v1` marker from the same provider insert that creates the credential. Shell clears the marker after it observes the Core binding; a repeated issuance reconciles any retained marker against Core and disables an orphan before creating another key. Each marker is scoped by the trusted tenant and issuing Principal and remains leased for five minutes, including retries with the same caller idempotency key, so concurrent requests cannot reclaim a credential that is still being bound. The marker is neither an OntOS permission nor public metadata. Stale-marker lookup is tenant/issuer/staleness-filtered and indexed in Auth, processes at most one bounded batch per request, and requires a retry before issuance when more cleanup remains. + +Support start creates an Auth-owned non-secret recovery record after the provider session is initialized and before the started checkpoint commits; support stop therefore always has durable recovery state before Better Auth deletes or expires the impersonated session. The record carries only safe correlation, OntOS principal/binding IDs, reason, tenant, and safe session reference—not a token or cookie. Stopped evidence is idempotent; post-restore evidence or recovery cleanup failure still forwards the restored cookie and a repeated stop resumes the checkpoint. The recovery context is accepted only for the exact generated Action and a decoded `stopped` payload; it still performs the Action's normal SpiceDB permission check. Mechanical session termination therefore remains independent of evidence availability, while a denied or unavailable checkpoint remains pending instead of fabricating authorization. ## Invocation Lifecycle @@ -113,15 +42,7 @@ Process every Action request in this order: 9. Only after permission and all Policies allow, persist the accepted invocation transition from `received` to `running` independently so a definite business rollback leaves it open. 10. Open the Core-owned transaction, lock and recheck the invocation, install and verify the transaction-local operational database scope, then lock the tenant and authoritatively recheck tenant `write` access. Only then may Core construct owner-local services, create the collector, resolve the private handler, and execute it. Competing requests may repeat read-only gates, but their handlers must never run concurrently. -The first Shell/Core runtime receives an already trusted principal context. Permission and Policy -evaluation are both enforced before the invocation becomes `running`, the business transaction -opens, or the handler and collector are created. Core performs one fully consistent `execute` check: -`HAS_PERMISSION` allows and `NO_PERMISSION` durably rejects the invocation before Policy, service, -or handler resolution. Timeout, unavailability, authentication/schema failure, conditional -decisions, and every other indeterminate result return a sanitized retryable check error while -leaving the invocation open in `received`. The legacy `restriction` relation and `is_restricted` -permission remain in the compatible schema only for N/N-1 rollout; the candidate runtime does not -read them. Remove them only in a later contract release after previous runtimes and tuples are gone. +The first Shell/Core runtime receives an already trusted principal context. Permission and Policy evaluation are both enforced before the invocation becomes `running`, the business transaction opens, or the handler and collector are created. Core performs one fully consistent `execute` check: `HAS_PERMISSION` allows and `NO_PERMISSION` durably rejects the invocation before Policy, service, or handler resolution. Timeout, unavailability, authentication/schema failure, conditional decisions, and every other indeterminate result return a sanitized retryable check error while leaving the invocation open in `received`. The legacy `restriction` relation and `is_restricted` permission remain in the compatible schema only for N/N-1 rollout; the candidate runtime does not read them. Remove them only in a later contract release after previous runtimes and tuples are gone. ## Outcomes @@ -177,22 +98,10 @@ Cross-MicroVertical consumers use only the message producer's published schema-o Authentication, permission, policy, and domain rejections remain typed Effect errors throughout the Action lifecycle. At the Backend for Frontend (BFF) endpoint, map them exhaustively to the declared public error schemas and status codes in [Effect Error and HTTP Contracts](./ERRORS.md). Do not let an Action error escape as an exception, an untyped rejected Promise, or an ad hoc HTTP response. -Authentication assertion failures occur before the Action lifecycle and must not create an Action -Invocation Log or reach an Action handler. An endpoint maps missing, malformed, tampered, expired, -or otherwise unusable assertions to its declared `401` Problem Details response with a -`WWW-Authenticate: Bearer` challenge. Public-JWKS or verification configuration unavailability maps -to a declared retryable `503`. These endpoint-specific mappings do not replace the separate Core -permission and Policy mappings and do not justify a generic Action HTTP endpoint. +Authentication assertion failures occur before the Action lifecycle and must not create an Action Invocation Log or reach an Action handler. An endpoint maps missing, malformed, tampered, expired, or otherwise unusable assertions to its declared `401` Problem Details response with a `WWW-Authenticate: Bearer` challenge. Public-JWKS or verification configuration unavailability maps to a declared retryable `503`. These endpoint-specific mappings do not replace the separate Core permission and Policy mappings and do not justify a generic Action HTTP endpoint. ## Authorization provisioning and compatibility -Every Action descriptor declares `authorization.kind = action_execution` and one provisioning -intent. `tenant_membership_default` permits the fixed development/stage provisioner to create the -tenant-member executor relation. `explicit` requires its intended relation to exist already and is -never granted blanket tenant membership. +Every Action descriptor declares `authorization.kind = action_execution` and one provisioning intent. `tenant_membership_default` permits the fixed development/stage provisioner to create the tenant-member executor relation. `explicit` requires its intended relation to exist already and is never granted blanket tenant membership. -The default runtime remains fail closed. A bounded `report_only` contract may preserve only an -explicitly baselined, pre-existing missing-policy allow while computing the candidate denial and -emitting one sanitized `authorization.would_deny` event. Explicit denials, infrastructure errors, -cross-tenant scope, invalid or expired credentials, disabled modules, and replay are never -compatible. New Actions cannot enter the baseline implicitly. +The default runtime remains fail closed. A bounded `report_only` contract may preserve only an explicitly baselined, pre-existing missing-policy allow while computing the candidate denial and emitting one sanitized `authorization.would_deny` event. Explicit denials, infrastructure errors, cross-tenant scope, invalid or expired credentials, disabled modules, and replay are never compatible. New Actions cannot enter the baseline implicitly. diff --git a/app/docs/architecture/COMMERCE_APPLICATIONS.md b/app/docs/architecture/COMMERCE_APPLICATIONS.md index 708d6ecd7..70706d581 100644 --- a/app/docs/architecture/COMMERCE_APPLICATIONS.md +++ b/app/docs/architecture/COMMERCE_APPLICATIONS.md @@ -77,20 +77,13 @@ It must preserve owner-local validation, Permission, Business Policy, Action, au ## Customer Configuration and implementations -This section defines accepted target selection semantics. Current V0 supports one implicit -`standard` implementation per `moduleId`; it does not yet serialize or select `implementationId`. +This section defines accepted target selection semantics. Current V0 supports one implicit `standard` implementation per `moduleId`; it does not yet serialize or select `implementationId`. - `moduleId` is the Module Contract Identity and owns public capability semantics. - `implementationId` identifies one catalogued executable implementation, for example `standard` or `akros`. - `appId` remains the independently deployable topology identity and exact gateway audience. -Once that target contract exists, two implementations may share `moduleId` only while public -semantics and compatibility remain the same. Different semantics require a different `moduleId`. -Each implementation records immutable build revision/digest, public-contract hash/version, migration -set, owner, health, and readiness; the catalog rejects missing, duplicate, ambiguous, incompatible, -or invisible implementation identities. Implement the target only by extending Codesmith, Effect -Schemas, serialized contracts, topology/allowlist validation, Customer Configuration resolution, -and tests together. Do not hand-author fields or customer branches as a substitute. +Once that target contract exists, two implementations may share `moduleId` only while public semantics and compatibility remain the same. Different semantics require a different `moduleId`. Each implementation records immutable build revision/digest, public-contract hash/version, migration set, owner, health, and readiness; the catalog rejects missing, duplicate, ambiguous, incompatible, or invisible implementation identities. Implement the target only by extending Codesmith, Effect Schemas, serialized contracts, topology/allowlist validation, Customer Configuration resolution, and tests together. Do not hand-author fields or customer branches as a substitute. Prefer shared behavior plus Business Policy. Add an implementation alternative only when an ordinary reusable capability cannot express the required behavior without distorting its contract. Never patch an implementation per customer under the same identity, and never move the exception into Shell/Core. @@ -114,8 +107,7 @@ Before production activation, prove: - approved Purchase Proposal Revisions cannot bypass Approval Revalidation or Order Commitment Gate; - the Medusa facade matches its declared subset and native clients do not depend on it accidentally; - dependency failures produce typed partial degradation without unrelated outage; -- once explicit alternatives exist, Customer Configuration resolves one permitted, healthy - implementation for every selected contract; +- once explicit alternatives exist, Customer Configuration resolves one permitted, healthy implementation for every selected contract; - contract/build skew is rejected and canary/rollback identifies exact artifacts; - Party/Counterparty linking and lifecycle events contain no credentials or cross-Tenant leakage; and - every Integration Route demonstrates idempotency, retry, reconciliation, observability, and recovery. diff --git a/app/docs/architecture/DATABASE.md b/app/docs/architecture/DATABASE.md index 242719bd5..5df83a11e 100644 --- a/app/docs/architecture/DATABASE.md +++ b/app/docs/architecture/DATABASE.md @@ -1,62 +1,29 @@ # Database Architecture -For operation-scope validation, scoped owner services, RLS, runtime/admin roles, and same-tenant -constraints, also follow [Governed Data Access and Operation Scope](./DATA_ACCESS.md). The current -effective-role evidence and trusted-context limitations are recorded in -[Database Trust-Boundary Audit](./DATABASE_TRUST_BOUNDARIES.md). Business -handlers and BFF adapters never receive or import a database executor; owner repositories are built -inside a Core-owned scoped transaction. - -This document defines authoritative database access and schema-ownership rules -for the OntOS application. MicroVertical deployment boundaries remain governed -by [MicroVertical Architecture](./MICROVERTICALS.md), and state changes remain -governed by [Action Execution](./ACTIONS.md). +For operation-scope validation, scoped owner services, RLS, runtime/admin roles, and same-tenant constraints, also follow [Governed Data Access and Operation Scope](./DATA_ACCESS.md). The current effective-role evidence and trusted-context limitations are recorded in [Database Trust-Boundary Audit](./DATABASE_TRUST_BOUNDARIES.md). Business handlers and BFF adapters never receive or import a database executor; owner repositories are built inside a Core-owned scoped transaction. + +This document defines authoritative database access and schema-ownership rules for the OntOS application. MicroVertical deployment boundaries remain governed by [MicroVertical Architecture](./MICROVERTICALS.md), and state changes remain governed by [Action Execution](./ACTIONS.md). ## Ownership - `@app/core-runtime` owns only the PostgreSQL schema named exactly `core`. - The `core` migration history contains only Core infrastructure tables. - PostgreSQL schema `public` owns no OntOS application tables. -- Auth and every MicroVertical own separate schemas and migration histories. - They are not registered in the Core Drizzle configuration or runtime schema. -- All migration histories store bookkeeping in the shared PostgreSQL schema - `drizzle`, using a distinct journal table per owner. Independent migration - histories must never share one journal table because their timestamps would - suppress each other's migrations. -- Each owner history uses the Drizzle v1 layout: one folder per migration - named `_` that holds `migration.sql` and `snapshot.json`. - There is no `meta/_journal.json`. Never hand-edit a committed `migration.sql` - or renumber a folder; the bookkeeping table matches rows by folder name and - content hash. The upgrade record and re-proof sequence live in - [Drizzle v1 Upgrade](./DRIZZLE_V1_UPGRADE.md). -- Code outside an owning package must not import a private schema, repository, - migration, or database client from that package. +- Auth and every MicroVertical own separate schemas and migration histories. They are not registered in the Core Drizzle configuration or runtime schema. +- All migration histories store bookkeeping in the shared PostgreSQL schema `drizzle`, using a distinct journal table per owner. Independent migration histories must never share one journal table because their timestamps would suppress each other's migrations. +- Each owner history uses the Drizzle v1 layout: one folder per migration named `_` that holds `migration.sql` and `snapshot.json`. There is no `meta/_journal.json`. Never hand-edit a committed `migration.sql` or renumber a folder; the bookkeeping table matches rows by folder name and content hash. The upgrade record and re-proof sequence live in [Drizzle v1 Upgrade](./DRIZZLE_V1_UPGRADE.md). +- Code outside an owning package must not import a private schema, repository, migration, or database client from that package. ## Drizzle Cohort -OntOS runs the Drizzle v1 line (`drizzle-orm` and `drizzle-kit` at the same -version) pinned identically in every owner, together with the matching Better -Auth line and its `@better-auth/drizzle-adapter/relations-v2` entrypoint. The -package manifests are the source of truth for the exact versions. Bump the -pair only as one cohort and only through the proofs in -[Drizzle v1 Upgrade](./DRIZZLE_V1_UPGRADE.md). +OntOS runs the Drizzle v1 line (`drizzle-orm` and `drizzle-kit` at the same version) pinned identically in every owner, together with the matching Better Auth line and its `@better-auth/drizzle-adapter/relations-v2` entrypoint. The package manifests are the source of truth for the exact versions. Bump the pair only as one cohort and only through the proofs in [Drizzle v1 Upgrade](./DRIZZLE_V1_UPGRADE.md). Owner conventions on v1: -- Declare relations with `defineRelations(, (r) => ...)`. Core, Auth, and Party - construct native executors with `makeWithDefaults({ relations })` from - `drizzle-orm/effect-postgres`, supplying `PgClient` and `Reactivity`; their types - are `EffectPgDatabaseRelations>`. Auth's database service also - creates Better Auth's supported adapter using a private `node-postgres` - Drizzle handle on the same scoped pool. It exposes the adapter, never that - Promise-based handle. Application Auth queries use the native executor. Relational - Queries v1 (`relations(...)`, callback `where`) are unavailable. -- Declare governed tables with `.table.withRLS(...)` and attach - `tenantRlsPolicies` or `tenantLegalEntityRlsPolicies` from `@app/core-runtime`. +- Declare relations with `defineRelations(, (r) => ...)`. Core, Auth, and Party construct native executors with `makeWithDefaults({ relations })` from `drizzle-orm/effect-postgres`, supplying `PgClient` and `Reactivity`; their types are `EffectPgDatabaseRelations>`. Auth's database service also creates Better Auth's supported adapter using a private `node-postgres` Drizzle handle on the same scoped pool. It exposes the adapter, never that Promise-based handle. Application Auth queries use the native executor. Relational Queries v1 (`relations(...)`, callback `where`) are unavailable. +- Declare governed tables with `.table.withRLS(...)` and attach `tenantRlsPolicies` or `tenantLegalEntityRlsPolicies` from `@app/core-runtime`. - Use `getColumns` instead of the deprecated `getTableColumns`. -- After adding a migration or rebasing a branch that adds one, run - `pnpm db:check`; it validates each owner's snapshot chain and reports - non-commutative migrations across branches. +- After adding a migration or rebasing a branch that adds one, run `pnpm db:check`; it validates each owner's snapshot chain and reports non-commutative migrations across branches. ## Typed Drizzle and Effect @@ -64,32 +31,14 @@ Every application query and mutation must: 1. run inside an Effect service; 2. use the owning package's typed Drizzle table and column references; -3. use Drizzle query builders for selects, inserts, updates, deletes, and - transactions; and +3. use Drizzle query builders for selects, inserts, updates, deletes, and transactions; and 4. preserve expected failures in a declared Effect error channel. -Application code must not use direct `pg` queries, interpolated SQL strings, -string-concatenated SQL, untyped result objects, or exported promise-only -database APIs when Drizzle and Effect can represent the behavior. The -node-postgres pool is a private implementation detail acquired and released by -an Effect scope. - -Core, Auth, and Party persistence use `drizzle-orm/effect-postgres` with `@effect/sql-pg`. -Queries are native Effects: yield the query directly and map its typed error at the -owning repository or service. Transaction callbacks return an Effect; the native SQL -client owns connection acquisition, commit, rollback, savepoints, and interruption. -Do not wrap native queries in `Effect.tryPromise`, or start another runtime inside a -transaction callback. Caller services, references, tracing, and cancellation remain -in the same Effect execution. - -Drizzle query failures carry an Effect `Cause` containing the SQL driver failure. -`findPostgresFailure` is the sole decoder for sanitized PostgreSQL code and constraint -metadata. Owners assign domain meaning only to their exact code/constraint pairs. -Native SQL settlement failures use the defect channel; transaction owners narrow -only `SqlError` to their declared failure and preserve all other defects. The Action -runtime distinguishes an uncertain commit acknowledgement from a failed body, retains -the failed body for rollback diagnostics, and resolves uncertainty from the durable -invocation marker instead of rerunning the Action. +Application code must not use direct `pg` queries, interpolated SQL strings, string-concatenated SQL, untyped result objects, or exported promise-only database APIs when Drizzle and Effect can represent the behavior. The node-postgres pool is a private implementation detail acquired and released by an Effect scope. + +Core, Auth, and Party persistence use `drizzle-orm/effect-postgres` with `@effect/sql-pg`. Queries are native Effects: yield the query directly and map its typed error at the owning repository or service. Transaction callbacks return an Effect; the native SQL client owns connection acquisition, commit, rollback, savepoints, and interruption. Do not wrap native queries in `Effect.tryPromise`, or start another runtime inside a transaction callback. Caller services, references, tracing, and cancellation remain in the same Effect execution. + +Drizzle query failures carry an Effect `Cause` containing the SQL driver failure. `findPostgresFailure` is the sole decoder for sanitized PostgreSQL code and constraint metadata. Owners assign domain meaning only to their exact code/constraint pairs. Native SQL settlement failures use the defect channel; transaction owners narrow only `SqlError` to their declared failure and preserve all other defects. The Action runtime distinguishes an uncertain commit acknowledgement from a failed body, retains the failed body for rollback diagnostics, and resolves uncertainty from the durable invocation marker instead of rerunning the Action. ## Narrow SQL Exceptions @@ -97,61 +46,30 @@ Drizzle's parameterized `sql` tagged template is allowed only for: - typed schema checks, index predicates, and defaults; - migration or bootstrap work; or -- a documented operation that cannot be represented by a Drizzle query - builder. +- a documented operation that cannot be represented by a Drizzle query builder. -Every application-level exception needs a nearby explanation and a focused -test. Parameters must remain values in the tagged template; never construct SQL -by joining or interpolating strings. Raw native Drizzle reads explicitly pass -`'objects'` as the second argument to `execute`: the default raw mode exposes -the underlying driver result instead of the object-row array. +Every application-level exception needs a nearby explanation and a focused test. Parameters must remain values in the tagged template; never construct SQL by joining or interpolating strings. Raw native Drizzle reads explicitly pass `'objects'` as the second argument to `execute`: the default raw mode exposes the underlying driver result instead of the object-row array. -Generated migration SQL is an output of the typed schema and is not application -query code. Handwritten migration SQL must not replace an expressible typed -Drizzle schema definition. +Generated migration SQL is an output of the typed schema and is not application query code. Handwritten migration SQL must not replace an expressible typed Drizzle schema definition. ## Environment and Lifecycle -Local Compose and application tooling share the root `DATABASE_URL` contract -documented in `.env.example`. Package configuration resolves the root `.env` -by an explicit path, independent of the invocation directory. +Local Compose and application tooling share the root `DATABASE_URL` contract documented in `.env.example`. Package configuration resolves the root `.env` by an explicit path, independent of the invocation directory. -Missing or malformed configuration is an expected typed Effect error. There is -no silent localhost fallback. The application database layer owns a `pg.Pool`, -binds it through `PgClient.fromPool` to native Drizzle Effect queries, and closes it -when its Effect scope ends. Pool acquisition and server-side statement deadlines -remain configured on the pool; a local timeout does not prove that PostgreSQL stopped -executing a statement. +Missing or malformed configuration is an expected typed Effect error. There is no silent localhost fallback. The application database layer owns a `pg.Pool`, binds it through `PgClient.fromPool` to native Drizzle Effect queries, and closes it when its Effect scope ends. Pool acquisition and server-side statement deadlines remain configured on the pool; a local timeout does not prove that PostgreSQL stopped executing a statement. ## Core Migration Boundary -The Core schema inventory is an exact set. Migration generation, application -registration, tests, and verification all use -`packages/core-runtime/src/db/schema.ts` as their only schema source. +The Core schema inventory is an exact set. Migration generation, application registration, tests, and verification all use `packages/core-runtime/src/db/schema.ts` as their only schema source. -Each owner verifier must reach every owned table through its typed Drizzle reference and -exact-match only that owner's schema inventory and migration journal. The root application -verifier separately exact-matches the complete set of application schemas and owner-specific -Drizzle journals before invoking every owner verifier. PostgreSQL system catalogs and Drizzle's -migration bookkeeping remain infrastructure metadata rather than a shared business schema. +Each owner verifier must reach every owned table through its typed Drizzle reference and exact-match only that owner's schema inventory and migration journal. The root application verifier separately exact-matches the complete set of application schemas and owner-specific Drizzle journals before invoking every owner verifier. PostgreSQL system catalogs and Drizzle's migration bookkeeping remain infrastructure metadata rather than a shared business schema. ## Canonical, Projected, and Artifact Data -PostgreSQL is canonical for operational state. Neo4j, search documents, reporting aggregates, and -other read models are projections: they may lag, must be rebuildable, and must not become the only -source for business writes, audit, billing, or authorization. SpiceDB remains the separate -authorization store and must not be used as the business relationship graph. +PostgreSQL is canonical for operational state. Neo4j, search documents, reporting aggregates, and other read models are projections: they may lag, must be rebuildable, and must not become the only source for business writes, audit, billing, or authorization. SpiceDB remains the separate authorization store and must not be used as the business relationship graph. -Binary content belongs in object storage. PostgreSQL owns its metadata, lifecycle, links, evidence -references, and authorization context. Storage keys are collision-resistant technical identifiers, -not user filenames or business hierarchy. Preserve an optional original filename as provenance and -a sanitized display filename for presentation; neither establishes ownership or uniqueness. +Binary content belongs in object storage. PostgreSQL owns its metadata, lifecycle, links, evidence references, and authorization context. Storage keys are collision-resistant technical identifiers, not user filenames or business hierarchy. Preserve an optional original filename as provenance and a sanitized display filename for presentation; neither establishes ownership or uniqueness. -After ingest completes, record exact byte size and a SHA-256 hash of the stored bytes. Treat the -storage key, provider object-version reference, size, and content hash as immutable content -identity. Presentation metadata and processing state may change independently. +After ingest completes, record exact byte size and a SHA-256 hash of the stored bytes. Treat the storage key, provider object-version reference, size, and content hash as immutable content identity. Presentation metadata and processing state may change independently. -Legal or compliance immutability requires provider-enforced WORM/Object Lock. Record the requested -and verified provider state in the evidence reference. Database constraints and application -permissions alone provide application-level protection and must not be described as storage-level -WORM. +Legal or compliance immutability requires provider-enforced WORM/Object Lock. Record the requested and verified provider state in the evidence reference. Database constraints and application permissions alone provide application-level protection and must not be described as storage-level WORM. diff --git a/app/docs/architecture/DATABASE_TRUST_BOUNDARIES.md b/app/docs/architecture/DATABASE_TRUST_BOUNDARIES.md index 9830bf2d0..f35cc4dfe 100644 --- a/app/docs/architecture/DATABASE_TRUST_BOUNDARIES.md +++ b/app/docs/architecture/DATABASE_TRUST_BOUNDARIES.md @@ -1,9 +1,6 @@ # Database Trust-Boundary Audit -This is the reproducible current-state evidence for -[TechsioCZ/ontos#370](https://github.com/TechsioCZ/ontos/issues/370). It informs the human decision -in [TechsioCZ/ontos#174](https://github.com/TechsioCZ/ontos/issues/174); it does not change grants, -credentials, or application behavior. +This is the reproducible current-state evidence for [TechsioCZ/ontos#370](https://github.com/TechsioCZ/ontos/issues/370). It informs the human decision in [TechsioCZ/ontos#174](https://github.com/TechsioCZ/ontos/issues/174); it does not change grants, credentials, or application behavior. ## Run it @@ -13,10 +10,7 @@ After migrations and runtime-role bootstrap: mise exec -- pnpm database-trust:audit ``` -The command uses distinct admin and runtime connections to the same PostgreSQL database and writes -`.codex/reports/database/database-trust-boundary.json`. The ignored report contains identities, -effective authority, RLS state, trusted-context probes, and finding codes. It never contains URLs, -passwords, secrets, tenant IDs, or legal-entity IDs. +The command uses distinct admin and runtime connections to the same PostgreSQL database and writes `.codex/reports/database/database-trust-boundary.json`. The ignored report contains identities, effective authority, RLS state, trusted-context probes, and finding codes. It never contains URLs, passwords, secrets, tenant IDs, or legal-entity IDs. Evidence in this document is: @@ -24,14 +18,9 @@ Evidence in this document is: - **INFERRED** when it follows from current composition but was not observed in deployment; - **UNKNOWN** when it is controlled outside this repository. -The audit covers current and reachable-role authority over databases, schemas, relations, -sequences, routines, types, parameters, grant options, defaults, RLS, owner-context views, and -directly executable `SECURITY DEFINER` routines. The executable report is the detailed capability -inventory; this document records only the architectural conclusions. +The audit covers current and reachable-role authority over databases, schemas, relations, sequences, routines, types, parameters, grant options, defaults, RLS, owner-context views, and directly executable `SECURITY DEFINER` routines. The executable report is the detailed capability inventory; this document records only the architectural conclusions. -This is not a general PostgreSQL reachability analyzer. Indirect execution through triggers, -rewrite rules, aggregate support functions, event triggers, cascades, or partition routing is -outside this baseline; introducing such a path requires its own narrow security contract and test. +This is not a general PostgreSQL reachability analyzer. Indirect execution through triggers, rewrite rules, aggregate support functions, event triggers, cascades, or partition routing is outside this baseline; introducing such a path requires its own narrow security contract and test. ## Reproduced local baseline @@ -47,17 +36,14 @@ outside this baseline; introducing such a path requires its own narrow security | RLS | Two tables have enabled and forced RLS | | Trusted settings | Can set both `ontos.tenant_id` and `ontos.legal_entity_id`; local values disappear after rollback | -Exact counts describe this local database, not production. Re-run the audit against each target -environment. +Exact counts describe this local database, not production. Re-run the audit against each target environment. The baseline has exactly two high-severity findings: 1. `runtime_role_has_cross_schema_dml`: one credential spans Core, Auth, and Contacts. -2. `runtime_role_can_forge_trusted_context`: that credential can choose both custom GUC values used - by RLS. +2. `runtime_role_can_forge_trusted_context`: that credential can choose both custom GUC values used by RLS. -The first is a blast-radius problem. The second is a trust-root problem; splitting role names alone -does not solve it. +The first is a blast-radius problem. The second is a trust-root problem; splitting role names alone does not solve it. ## Process-to-identity map @@ -71,8 +57,7 @@ does not solve it. | SpiceDB | Separate datastore login; applications use gRPC plus a pre-shared key. | Bootstrap **VERIFIED**; deployed distribution **UNKNOWN**. | | Browser/remotes | Boundary checks reject database imports outside server owners. | Static boundary **VERIFIED**; not protection from a compromised server process. | -External service configuration may supply production credentials, so their absence from -`zerops.yaml` proves nothing about deployed identity distribution. +External service configuration may supply production credentials, so their absence from `zerops.yaml` proves nothing about deployed identity distribution. ## Tenant-context trust path @@ -86,13 +71,9 @@ validated request context -> commit or rollback clears them ``` -**VERIFIED:** transaction scoping prevents missing context from matching rows and prevents values -leaking to a later transaction on the same pooled connection. +**VERIFIED:** transaction scoping prevents missing context from matching rows and prevents values leaking to a later transaction on the same pooled connection. -**VERIFIED:** the ordinary runtime role can also call `set_config` directly. Arbitrary SQL inside a -compromised runtime can therefore choose another valid scope. Forced RLS still runs, but evaluates -attacker-selected context. Typed services and import rules prevent accidents; they do not make a -shared credential an unforgeable security boundary. +**VERIFIED:** the ordinary runtime role can also call `set_config` directly. Arbitrary SQL inside a compromised runtime can therefore choose another valid scope. Forced RLS still runs, but evaluates attacker-selected context. Typed services and import rules prevent accidents; they do not make a shared credential an unforgeable security boundary. ## Negative evidence and remaining gaps @@ -108,8 +89,7 @@ shared credential an unforgeable security boundary. ### A. Process-scoped roles -Give Shell, Contacts, and workers distinct logins. Each vertical gets its owner schema plus an -explicit minimal Core grant set; migrations remain administrative. +Give Shell, Contacts, and workers distinct logins. Each vertical gets its owner schema plus an explicit minimal Core grant set; migrations remain administrative. - Benefit: measurable blast-radius reduction. - Cost: per-process provisioning/rotation and a maintained Core grant manifest. @@ -117,41 +97,32 @@ explicit minimal Core grant set; migrations remain administrative. ### B. Admin-owned trusted-scope entry point -Validate an unforgeable scope assertion in a narrow admin-owned entry point and store scope where -the ordinary role cannot write it. RLS reads that trusted state and direct table access is revoked -for the pilot. +Validate an unforgeable scope assertion in a narrow admin-owned entry point and store scope where the ordinary role cannot write it. RLS reads that trusted state and direct table access is revoked for the pilot. - Benefit: enforceable scope authenticity inside PostgreSQL. -- Cost: privileged-function hardening, pool lifecycle, replay/audience/expiry validation, - observability, and rollback design. +- Cost: privileged-function hardening, pool lifecycle, replay/audience/expiry validation, observability, and rollback design. - Constraint: a `SECURITY DEFINER` function accepting caller-chosen IDs remains forgeable. ### C. Trusted broker or pooler -A trusted component validates scope, selects the allowed database identity, and establishes -authoritative context before forwarding work. Applications cannot connect around it. +A trusted component validates scope, selects the allowed database identity, and establishes authoritative context before forwarding work. Applications cannot connect around it. - Benefit: centralized identity, rotation, and audit across independent or multi-cloud deployments. -- Cost: an availability-sensitive hop, network/provider integration, pooling semantics, local - parity, and a strict no-bypass credential path. -- Constraint: generic SQL filtering is insufficient; the broker must establish state the ordinary - role cannot overwrite. +- Cost: an availability-sensitive hop, network/provider integration, pooling semantics, local parity, and a strict no-bypass credential path. +- Constraint: generic SQL filtering is insufficient; the broker must establish state the ordinary role cannot overwrite. -Per-tenant PostgreSQL logins or databases are outside this pilot because of credential and -connection cardinality. +Per-tenant PostgreSQL logins or databases are outside this pilot because of credential and connection cardinality. ## Proposed Contacts pilot 1. Create separate `shell_runtime` and `contacts_runtime` roles; keep `ontos_admin` for migrations. 2. Deny each runtime access to the other vertical and grant only an explicit Core subset. 3. Apply option B or C to one Contacts RLS path. -4. Prove denial of unrelated DML/DDL, `SET ROLE`, `BYPASSRLS`, trusted-state writes, forged scope, - cross-tenant access, and retained context. +4. Prove denial of unrelated DML/DDL, `SET ROLE`, `BYPASSRLS`, trusted-state writes, forged scope, cross-tenant access, and retained context. 5. Define provisioning, rotation, observability, rollback, and local bootstrap before expanding. Before implementation, Petr and Jiří must decide: 1. Is the trust root an admin-owned database entry point (B) or a trusted broker/pooler (C)? -2. Is a per-process Core grant manifest acceptable, or should Core access first move behind callable - APIs? +2. Is a per-process Core grant manifest acceptable, or should Core access first move behind callable APIs? 3. Is rollback in place sufficient, or must one release support a dual path using the shared role? diff --git a/app/docs/architecture/DATA_ACCESS.md b/app/docs/architecture/DATA_ACCESS.md index e278bd2d5..860fccaff 100644 --- a/app/docs/architecture/DATA_ACCESS.md +++ b/app/docs/architecture/DATA_ACCESS.md @@ -1,63 +1,29 @@ # Governed Data Access and Operation Scope -This contract governs every public or business read and write. It complements the entrypoint -`tenant`/`system` scope with an independent legal-entity scope and makes CoreSDK the only owner of -trusted operation context, transaction creation, and durable access evidence. +This contract governs every public or business read and write. It complements the entrypoint `tenant`/`system` scope with an independent legal-entity scope and makes CoreSDK the only owner of trusted operation context, transaction creation, and durable access evidence. ## OperationalScope -`OperationalScope` is immutable, server-only Core runtime state. Core constructs it from an -authenticated Shell session or a verified audience-scoped gateway assertion. Browser payloads and -identity headers never establish tenant, principal, auth-binding, or legal-entity identity. The -scope contains only revalidated tenant, principal, optional auth binding, optional legal entity, -authentication metadata, correlation ID, and optional trace ID; it is never persisted as generic -JSON or exposed through browser-safe contracts. +`OperationalScope` is immutable, server-only Core runtime state. Core constructs it from an authenticated Shell session or a verified audience-scoped gateway assertion. Browser payloads and identity headers never establish tenant, principal, auth-binding, or legal-entity identity. The scope contains only revalidated tenant, principal, optional auth binding, optional legal entity, authentication metadata, correlation ID, and optional trace ID; it is never persisted as generic JSON or exposed through browser-safe contracts. Every descriptor declares both dimensions explicitly: - entrypoint scope `tenant` or `system` controls tenant module-state gating; -- legal-entity scope `required`, `optional`, or `forbidden` controls whether a selected legal entity - must be present, is validated when present, or must be absent. - -Tenant scope does not imply legal-entity scope. Omitted, malformed, stale, inactive, cross-tenant, -denied, conditional, or indeterminate context fails closed before module state, permission, Policy, -owner service factory, or private handler resolution. Definite authentication/context failures are -typed separately from retryable database or authorization unavailability. - -Core rechecks the active tenant and principal, verifies an optional auth binding is active and -belongs to that tenant/principal, verifies an optional legal entity is active and belongs to the -tenant, and checks the principal's legal-entity access. System/background operations must use an -explicit system entrypoint and `forbidden` legal-entity scope unless their approved descriptor and -runtime contract state otherwise; they are not reachable through business handler capabilities. - -Mode-specific trusted context is closed and revalidated: sessions require an active user binding -and `better-auth-session:` reference; API keys require the single active key binding and -`better-auth-api-key:` reference; support impersonation uses the target as effective principal and -binding while retaining the active original administrator plus continuing tenant `impersonate` -permission; system work requires a branded registration and `job:{job}:run:{run}` reference with no -binding, impersonator, or legal entity. Raw credentials and provider ownership never enter the -scope, read evidence, gateway claims, or Core tables. - -Identity list endpoints are governed Core reads. Shell may join their authorized binding IDs to -Auth-owned non-secret metadata, including terminal revoked bindings for administration, but strips -the stable provider key ID before encoding a response. The one-key-one-binding database invariant -prevents an API key from selecting another tenant or principal. - -Identity operations are tenant-level and use `legalEntityScope = optional`; resolving their trusted -session context does not require an unrelated legal-entity selection. When a legal entity is present -it remains subject to normal Core revalidation. API-key list responses derive provider cleanup debt -from Core binding status versus Auth enabled state rather than hiding a partially completed -transition. +- legal-entity scope `required`, `optional`, or `forbidden` controls whether a selected legal entity must be present, is validated when present, or must be absent. + +Tenant scope does not imply legal-entity scope. Omitted, malformed, stale, inactive, cross-tenant, denied, conditional, or indeterminate context fails closed before module state, permission, Policy, owner service factory, or private handler resolution. Definite authentication/context failures are typed separately from retryable database or authorization unavailability. + +Core rechecks the active tenant and principal, verifies an optional auth binding is active and belongs to that tenant/principal, verifies an optional legal entity is active and belongs to the tenant, and checks the principal's legal-entity access. System/background operations must use an explicit system entrypoint and `forbidden` legal-entity scope unless their approved descriptor and runtime contract state otherwise; they are not reachable through business handler capabilities. + +Mode-specific trusted context is closed and revalidated: sessions require an active user binding and `better-auth-session:` reference; API keys require the single active key binding and `better-auth-api-key:` reference; support impersonation uses the target as effective principal and binding while retaining the active original administrator plus continuing tenant `impersonate` permission; system work requires a branded registration and `job:{job}:run:{run}` reference with no binding, impersonator, or legal entity. Raw credentials and provider ownership never enter the scope, read evidence, gateway claims, or Core tables. + +Identity list endpoints are governed Core reads. Shell may join their authorized binding IDs to Auth-owned non-secret metadata, including terminal revoked bindings for administration, but strips the stable provider key ID before encoding a response. The one-key-one-binding database invariant prevents an API key from selecting another tenant or principal. + +Identity operations are tenant-level and use `legalEntityScope = optional`; resolving their trusted session context does not require an unrelated legal-entity selection. When a legal entity is present it remains subject to normal Core revalidation. API-key list responses derive provider cleanup debt from Core binding status versus Auth enabled state rather than hiding a partially completed transition. ## Scoped Owner Services -Core owns the top-level transaction. It installs transaction-local `ontos.tenant_id` and, when -present, `ontos.legal_entity_id`, verifies both settings in the same transaction, and only then -constructs the owner's private service factory. Action and read handlers receive immutable scope, -operation identity, collector/evidence methods, and typed owner-local services. They never receive -or import Drizzle, `pg`, a pool, a database executor, transaction creation, commit/rollback, Core -evidence repositories, or another owner's schema/repository. A service object built over a global -pool is invalid. +Core owns the top-level transaction. It installs transaction-local `ontos.tenant_id` and, when present, `ontos.legal_entity_id`, verifies both settings in the same transaction, and only then constructs the owner's private service factory. Action and read handlers receive immutable scope, operation identity, collector/evidence methods, and typed owner-local services. They never receive or import Drizzle, `pg`, a pool, a database executor, transaction creation, commit/rollback, Core evidence repositories, or another owner's schema/repository. A service object built over a global pool is invalid. ## Governed Read Lifecycle @@ -73,47 +39,20 @@ Core runs reads in this exact order: 8. Decode the declared result and build bounded metadata/hash evidence. 9. Commit durable allowed evidence before releasing the result. -Definite authorization or Policy denial runs no handler and writes sanitized denied evidence in a -separate Core-owned transaction. Indeterminate context, permission, Policy, or evidence persistence -fails closed and retryably. Evidence contains no raw query, result rows, provider diagnostics, -authorization internals, or foreign identifiers. Metadata-only is the default; hash-only or an -already-supported redacted mode requires an explicit descriptor policy. +Definite authorization or Policy denial runs no handler and writes sanitized denied evidence in a separate Core-owned transaction. Indeterminate context, permission, Policy, or evidence persistence fails closed and retryably. Evidence contains no raw query, result rows, provider diagnostics, authorization internals, or foreign identifiers. Metadata-only is the default; hash-only or an already-supported redacted mode requires an explicit descriptor policy. -The private permission-target resolver derives module/resource targets only from decoded business -input and immutable scope; transport metadata never chooses an authorization target. Search -providers also declare a private result-target resolver. Core bulk-checks every returned resource -reference and releases no result if any reference is denied or indeterminate. Metadata-only reads -reject hashes, while hash-only reads accept only bounded SHA-256 values and paired fingerprint -metadata. +The private permission-target resolver derives module/resource targets only from decoded business input and immutable scope; transport metadata never chooses an authorization target. Search providers also declare a private result-target resolver. Core bulk-checks every returned resource reference and releases no result if any reference is denied or indeterminate. Metadata-only reads reject hashes, while hash-only reads accept only bounded SHA-256 values and paired fingerprint metadata. -Every Shell-to-MicroVertical provider attempt acquires a fresh assertion for that provider's app -audience. The provider transport receives only the resulting Authorization value and business -payload; receiving BFFs verify the Bearer assertion before invoking `ReadRuntime`. +Every Shell-to-MicroVertical provider attempt acquires a fresh assertion for that provider's app audience. The provider transport receives only the resulting Authorization value and business payload; receiving BFFs verify the Bearer assertion before invoking `ReadRuntime`. ## PostgreSQL Isolation -`DATABASE_ADMIN_URL` is used only for role/schema/migration work. `DATABASE_URL` is the application -pool and must authenticate as a non-superuser role without `BYPASSRLS`. The URLs must not be -identical. Local/test bootstrap creates or updates `ontos_runtime`, grants only schema/table/sequence -usage needed by the application, and verifies its capabilities. +`DATABASE_ADMIN_URL` is used only for role/schema/migration work. `DATABASE_URL` is the application pool and must authenticate as a non-superuser role without `BYPASSRLS`. The URLs must not be identical. Local/test bootstrap creates or updates `ontos_runtime`, grants only schema/table/sequence usage needed by the application, and verifies its capabilities. -Owner tenant tables use enabled and forced RLS. Tenant-only policies compare `tenant_id` with -`current_setting('ontos.tenant_id', true)` for `USING` and `WITH CHECK`. Legal-entity-owned policies -also compare `legal_entity_id` with `current_setting('ontos.legal_entity_id', true)`. Missing or -malformed settings match no rows and permit no writes. Settings use parameterized -`set_config(..., true)`, are verified before owner services exist, and disappear at transaction end. +Owner tenant tables use enabled and forced RLS. Tenant-only policies compare `tenant_id` with `current_setting('ontos.tenant_id', true)` for `USING` and `WITH CHECK`. Legal-entity-owned policies also compare `legal_entity_id` with `current_setting('ontos.legal_entity_id', true)`. Missing or malformed settings match no rows and permit no writes. Settings use parameterized `set_config(..., true)`, are verified before owner services exist, and disappear at transaction end. -Core global catalogs, schedulers, delivery state, and checkpoints deliberately remain Core-private -instead of becoming a business-handler RLS surface because controlled global scans are required. -All Core rows carrying a tenant plus a referenced legal entity, principal, auth binding, Action -invocation, audit/data-access/domain event, evidence, media, outbox, or checkpoint use composite -same-tenant uniqueness and foreign keys. This database invariant remains effective if application -validation is bypassed. +Core global catalogs, schedulers, delivery state, and checkpoints deliberately remain Core-private instead of becoming a business-handler RLS surface because controlled global scans are required. All Core rows carrying a tenant plus a referenced legal entity, principal, auth binding, Action invocation, audit/data-access/domain event, evidence, media, outbox, or checkpoint use composite same-tenant uniqueness and foreign keys. This database invariant remains effective if application validation is bypassed. ## Public Errors -Transport adapters map declared typed failures only: missing or unusable authentication to `401`, -definite permission denial to `403`, semantic Policy denial to its declared `409` or `422`, required -context/authorization/evidence unavailability to retryable `503`, and caught unexpected defects to -a sanitized declared `500`. No adapter constructs an ad hoc response or exposes database or SpiceDB -diagnostics. +Transport adapters map declared typed failures only: missing or unusable authentication to `401`, definite permission denial to `403`, semantic Policy denial to its declared `409` or `422`, required context/authorization/evidence unavailability to retryable `503`, and caught unexpected defects to a sanitized declared `500`. No adapter constructs an ad hoc response or exposes database or SpiceDB diagnostics. diff --git a/app/docs/architecture/DEPLOYMENT.md b/app/docs/architecture/DEPLOYMENT.md index 0220af416..d480eb748 100644 --- a/app/docs/architecture/DEPLOYMENT.md +++ b/app/docs/architecture/DEPLOYMENT.md @@ -1,67 +1,38 @@ # Deployment Architecture and Release Playbook -This playbook is the authoritative release guidance for OntOS application delivery. It covers -deployment configuration, CI/CD, PostgreSQL and SpiceDB changes, runtime packaging, Shell changes, -and every new or changed MicroVertical. - -> [!IMPORTANT] -> Explicit `implementationId`, dependency-closure selection, public-contract hashes, migration-set -> identity, and full artifact metadata are accepted target architecture, not fields in the current -> manifest/catalog schema. Requirements below that name them become mandatory with that contract. -> Until then, releases use one implicit `standard` implementation per `moduleId` and the current -> generated `buildMarker`; do not simulate missing fields with ad hoc configuration. - -Application Composition validation is implemented; publication and live Shell loading are not. -Until #374–#377 wire those paths in, remote URL and generated lazy-registry changes still require -Shell regeneration and redeployment. The composition promotion sequence below is the target flow. - -The rules exist because the first Zerops stage rollout was merged after source-level validation and -then required 43 linear repair commits. Stage had become the first production-shaped integration -test. Future releases must prove the target artifact and the distributed user journey before -promotion. +This playbook is the authoritative release guidance for OntOS application delivery. It covers deployment configuration, CI/CD, PostgreSQL and SpiceDB changes, runtime packaging, Shell changes, and every new or changed MicroVertical. + +> [!IMPORTANT] Explicit `implementationId`, dependency-closure selection, public-contract hashes, migration-set identity, and full artifact metadata are accepted target architecture, not fields in the current manifest/catalog schema. Requirements below that name them become mandatory with that contract. Until then, releases use one implicit `standard` implementation per `moduleId` and the current generated `buildMarker`; do not simulate missing fields with ad hoc configuration. + +Application Composition validation is implemented; publication and live Shell loading are not. Until #374–#377 wire those paths in, remote URL and generated lazy-registry changes still require Shell regeneration and redeployment. The composition promotion sequence below is the target flow. + +The rules exist because the first Zerops stage rollout was merged after source-level validation and then required 43 linear repair commits. Stage had become the first production-shaped integration test. Future releases must prove the target artifact and the distributed user journey before promotion. ## Release invariants These are non-negotiable: -1. **Topology is the delivery inventory.** Every deployable `appId`, service, package path, port, - readiness route, public URL, and migration owner derives from one generated or mechanically - validated topology contract. Application Composition separately governs the approved runtime - module graph and exact contract/remote artifacts; neither tenant state nor a reachable service - may add an artifact. -2. **Build once, promote unchanged.** A release deploys immutable artifacts identified by source SHA - and digest. Do not rebuild the same revision separately for stage and production. -3. **Prove the real artifact.** A successful source build is not a deploy test. CI must build, - materialize, install, start, and probe the same runtime artifact shape used by the provider. -4. **Providers precede consumers.** Migrations and compatible authorization schema precede - MicroVertical services; referenced MicroVertical remotes precede Shell; activation follows all - deployed smoke tests. -5. **Installation is not activation.** Deploy a new MicroVertical dark. Tenant module state is the - authoritative release flag and defaults inactive until canary verification succeeds. -6. **Every overlap is backward compatible.** Database, authorization, manifest, BFF, Module - Federation, and Shell/MicroVertical boundaries must work while old and new versions coexist. -7. **Rollback is prepared before rollout.** Record a previously validated immutable composition and - artifact for every affected delivery unit. Rollback is an explicit audited promotion, never an - automatic persistent fallback, and must not depend on reversing a schema migration. -8. **One failed gate stops promotion.** Preserve the artifact and evidence, reproduce in the parity - environment, fix the failure class, and rerun the release sequence from its first gate. -9. **Continuous product delivery is not customer version pinning.** OntOS controls promotion of - immutable artifacts. Customer Configuration selects permitted modules/implementations and - activation state, never a separate whole-product release line. +1. **Topology is the delivery inventory.** Every deployable `appId`, service, package path, port, readiness route, public URL, and migration owner derives from one generated or mechanically validated topology contract. Application Composition separately governs the approved runtime module graph and exact contract/remote artifacts; neither tenant state nor a reachable service may add an artifact. +2. **Build once, promote unchanged.** A release deploys immutable artifacts identified by source SHA and digest. Do not rebuild the same revision separately for stage and production. +3. **Prove the real artifact.** A successful source build is not a deploy test. CI must build, materialize, install, start, and probe the same runtime artifact shape used by the provider. +4. **Providers precede consumers.** Migrations and compatible authorization schema precede MicroVertical services; referenced MicroVertical remotes precede Shell; activation follows all deployed smoke tests. +5. **Installation is not activation.** Deploy a new MicroVertical dark. Tenant module state is the authoritative release flag and defaults inactive until canary verification succeeds. +6. **Every overlap is backward compatible.** Database, authorization, manifest, BFF, Module Federation, and Shell/MicroVertical boundaries must work while old and new versions coexist. +7. **Rollback is prepared before rollout.** Record a previously validated immutable composition and artifact for every affected delivery unit. Rollback is an explicit audited promotion, never an automatic persistent fallback, and must not depend on reversing a schema migration. +8. **One failed gate stops promotion.** Preserve the artifact and evidence, reproduce in the parity environment, fix the failure class, and rerun the release sequence from its first gate. +9. **Continuous product delivery is not customer version pinning.** OntOS controls promotion of immutable artifacts. Customer Configuration selects permitted modules/implementations and activation state, never a separate whole-product release line. ## Delivery-unit contract A new MicroVertical is not deployable until its delivery contract accounts for all of these fields: -- topology `appId` and dotted Module Contract Identity `moduleId`, kept distinct; add explicit - `implementationId` when the accepted target contract is implemented; +- topology `appId` and dotted Module Contract Identity `moduleId`, kept distinct; add explicit `implementationId` when the accepted target contract is implemented; - package name and workspace-relative owner path; - provider service/setup identity and environment service-ID key; - build and runtime Node/pnpm versions; - declared `PORT`, service-specific port variable, and readiness route; - immutable artifact build/materialization command; -- current immutable `buildMarker`, plus build revision/digest, public-contract hash/version, and - migration-set identity when the target metadata contract is implemented; +- current immutable `buildMarker`, plus build revision/digest, public-contract hash/version, and migration-set identity when the target metadata contract is implemented; - owned PostgreSQL schema, Drizzle journal, migration, grant, and verifier commands; - compatible SpiceDB schema requirements; - public URL and module-manifest URL; @@ -70,35 +41,26 @@ A new MicroVertical is not deployable until its delivery contract accounts for a - change-impact rules; - failure-log collection, smoke checks, and rollback target. -Codesmith or another approved generator must update these surfaces atomically. Until the generator -exists, do not add another copied Contacts block to the workflow, `zerops.yaml`, migration runner, or -validator. Extend and test the generator first. +Codesmith or another approved generator must update these surfaces atomically. Until the generator exists, do not add another copied Contacts block to the workflow, `zerops.yaml`, migration runner, or validator. Extend and test the generator first. -Change planning must fail closed when a changed path under `apps/*`, `packages/*`, or `verticals/*` -cannot be mapped to known delivery units. An unknown new vertical must never produce a no-op deploy. +Change planning must fail closed when a changed path under `apps/*`, `packages/*`, or `verticals/*` cannot be mapped to known delivery units. An unknown new vertical must never produce a no-op deploy. ### Change-impact rules The generated plan must conservatively include: -- an owner migration whenever the owner's Drizzle schema, migrations, migration config, or verifier - changes; +- an owner migration whenever the owner's Drizzle schema, migrations, migration config, or verifier changes; - every consumer when a shared runtime package or public contract changes; - SpiceDB whenever its schema, image, datastore bootstrap, transport, or client contract changes; -- Shell whenever its code/config or contribution ABI changes, including remote URL and generated - lazy-registry changes until the live composition loader is integrated. After that integration, - compatible remote updates move through a new composition revision without redeploying Shell; -- a MicroVertical whenever its owner-local code, manifest, registration, migrations, configuration, - or runtime dependencies change; -- all Node delivery units whenever the common lockfile, workspace dependency policy, runtime - materializer, Node installer, or deployment manifest changes. +- Shell whenever its code/config or contribution ABI changes, including remote URL and generated lazy-registry changes until the live composition loader is integrated. After that integration, compatible remote updates move through a new composition revision without redeploying Shell; +- a MicroVertical whenever its owner-local code, manifest, registration, migrations, configuration, or runtime dependencies change; +- all Node delivery units whenever the common lockfile, workspace dependency policy, runtime materializer, Node installer, or deployment manifest changes. The deployment plan, not a hand-written `case` statement, is the reviewable output. ## Production-parity artifact gate -Before merge or promotion, build from a clean checkout with the frozen lockfile in a target-equivalent -Linux profile: +Before merge or promotion, build from a clean checkout with the frozen lockfile in a target-equivalent Linux profile: 1. use the exact pinned Node and pnpm versions; 2. remove stale workspace `node_modules` links and host-global virtual-store state; @@ -111,9 +73,7 @@ Linux profile: 9. probe readiness and the delivery unit's public contract; 10. publish the source SHA, artifact digest, dependency cohort, and gate result. -The artifact deployed later must match that digest. If the provider cannot accept a prebuilt -artifact, the provider build itself must emit and verify the digest and use an identical, pinned -build profile in every environment. +The artifact deployed later must match that digest. If the provider cannot accept a prebuilt artifact, the provider build itself must emit and verify the digest and use an identical, pinned build profile in every environment. Commands run by agents, developers, and ordinary CI from `app/` use: @@ -121,14 +81,11 @@ Commands run by agents, developers, and ordinary CI from `app/` use: mise exec -- pnpm ``` -Commands embedded in a minimal provider image may use the deployment-pinned Node/pnpm bootstrap -when mise is deliberately absent. This is a narrow deployment-runtime exception, not permission to -run arbitrary local pnpm commands outside mise. +Commands embedded in a minimal provider image may use the deployment-pinned Node/pnpm bootstrap when mise is deliberately absent. This is a narrow deployment-runtime exception, not permission to run arbitrary local pnpm commands outside mise. ## Typed configuration preflight -Configuration validation happens before the first service changes. It must verify, without printing -secrets: +Configuration validation happens before the first service changes. It must verify, without printing secrets: - all required project and service IDs; - administrative and runtime PostgreSQL URLs use distinct identities; @@ -141,9 +98,7 @@ secrets: - required dependency/patch versions and provider CLI version; - readiness paths, timeouts, and retry periods with explicit units. -Do not infer the canonical authentication origin from a reverse-proxied request. Do not use a -runtime database identity for role, database, schema, or migration work. Do not silently fall back -to localhost or another environment. +Do not infer the canonical authentication origin from a reverse-proxied request. Do not use a runtime database identity for role, database, schema, or migration work. Do not silently fall back to localhost or another environment. ## Migration and authorization sequence @@ -159,20 +114,13 @@ Every owner retains its own schema and Drizzle journal. Run the release phase in 6. run each owner verifier and the root exact schema/journal verifier; 7. prove the previous and candidate application versions can use the expanded schema. -Never share a migration journal between owners. Never omit a migration because only an owner-local -path changed. The first v1 `drizzle-kit migrate` against a database migrated before the -[Drizzle v1 upgrade](./DRIZZLE_V1_UPGRADE.md) adds `name` and `applied_at` columns to that owner's -bookkeeping table and backfills `name`; it applies no schema migration and needs no manual step -beyond the administrative identity. Never execute deployment migrations through an assumed workspace pnpm layout after -artifact relocation; use the verified owner-local runtime binary or an explicit migration artifact. +Never share a migration journal between owners. Never omit a migration because only an owner-local path changed. The first v1 `drizzle-kit migrate` against a database migrated before the [Drizzle v1 upgrade](./DRIZZLE_V1_UPGRADE.md) adds `name` and `applied_at` columns to that owner's bookkeeping table and backfills `name`; it applies no schema migration and needs no manual step beyond the administrative identity. Never execute deployment migrations through an assumed workspace pnpm layout after artifact relocation; use the verified owner-local runtime binary or an explicit migration artifact. -Destructive contraction is a later release after all old readers and writers are gone. Ordinary -rollback leaves additive schema changes in place. +Destructive contraction is a later release after all old readers and writers are gone. Ordinary rollback leaves additive schema changes in place. ### SpiceDB -Distinguish Authzed datastore migrations from the OntOS authorization schema. A datastore migration -does not publish a changed permission model. +Distinguish Authzed datastore migrations from the OntOS authorization schema. A datastore migration does not publish a changed permission model. For every authorization-schema change: @@ -183,46 +131,24 @@ For every authorization-schema change: 5. verify representative existing and candidate permissions; 6. retain a compatible rollback plan for application versions and relationship writers. -Bootstrap files are only for an empty installation. They are not the ongoing authorization-schema -deployment mechanism. +Bootstrap files are only for an empty installation. They are not the ongoing authorization-schema deployment mechanism. The fail-closed Action authorization rollout uses an explicit expand/provision/verify/deploy gate: -1. prepare the candidate application/release artifact for the operator command while the previous - runtime remains active; this is separate from the PostgreSQL migration artifact; +1. prepare the candidate application/release artifact for the operator command while the previous runtime remains active; this is separate from the PostgreSQL migration artifact; 2. ensure the fixed stage contexts and their Tenant membership relationships already exist; -3. run `mise exec -- pnpm authorization:provision-current-actions` in the stage-gated artifact to - publish the compatible schema and membership-set executor grants for the complete current Action - catalog across the fixed stage Tenants; -4. verify every Action for the fixed stage Principals and verify representative non-members are - denied; +3. run `mise exec -- pnpm authorization:provision-current-actions` in the stage-gated artifact to publish the compatible schema and membership-set executor grants for the complete current Action catalog across the fixed stage Tenants; +4. verify every Action for the fixed stage Principals and verify representative non-members are denied; 5. only then deploy the runtime that treats missing `action#execute` permission as denial; 6. smoke one provisioned Action and one deliberately unconfigured Action denial. -The command is operator-invoked, idempotent, accepts no scope arguments, and must not be attached to -PostgreSQL migrations, SpiceDB startup, application startup, or automatic deployment. A failure or -catalog mismatch blocks promotion. Rollback restores the previous application artifact while -leaving the additive schema and relationships in place. - -Provisioning is additive, not stale-grant reconciliation. Before narrowing an Action from -`tenant_membership_default` to `explicit`, the operator must prepare its intended narrow grants, -remove the obsolete `action:#executor@tenant:#member` relation for each -affected fixed Tenant, and verify both the intended allowed Principal and a Tenant member who must -now be denied. Removed Actions and revoked role/workload assignments likewise require an explicit, -reviewed removal of their obsolete executor relations. Derive Action object IDs with -`toSpiceDbActionObjectId`; never delete unrelated tuples or rely on rerunning `TOUCH` to revoke -access. Record and verify this policy-data transition before promotion. An application rollback -must not silently restore a revoked grant; any policy restoration needs its own reviewed decision. -The fixed environment's provisioning input records at least one allowed and one denied Principal -assertion for every `explicit` Action. Promotion verifies every fixed context plus the representative -non-member for each `tenant_membership_default` Action; it verifies only those recorded per-Action -assertions for an `explicit` Action. Missing, duplicate, unknown, allow-only, or deny-only explicit -assertion sets fail before schema or relationship writes. +The command is operator-invoked, idempotent, accepts no scope arguments, and must not be attached to PostgreSQL migrations, SpiceDB startup, application startup, or automatic deployment. A failure or catalog mismatch blocks promotion. Rollback restores the previous application artifact while leaving the additive schema and relationships in place. + +Provisioning is additive, not stale-grant reconciliation. Before narrowing an Action from `tenant_membership_default` to `explicit`, the operator must prepare its intended narrow grants, remove the obsolete `action:#executor@tenant:#member` relation for each affected fixed Tenant, and verify both the intended allowed Principal and a Tenant member who must now be denied. Removed Actions and revoked role/workload assignments likewise require an explicit, reviewed removal of their obsolete executor relations. Derive Action object IDs with `toSpiceDbActionObjectId`; never delete unrelated tuples or rely on rerunning `TOUCH` to revoke access. Record and verify this policy-data transition before promotion. An application rollback must not silently restore a revoked grant; any policy restoration needs its own reviewed decision. The fixed environment's provisioning input records at least one allowed and one denied Principal assertion for every `explicit` Action. Promotion verifies every fixed context plus the representative non-member for each `tenant_membership_default` Action; it verifies only those recorded per-Action assertions for an `explicit` Action. Missing, duplicate, unknown, allow-only, or deny-only explicit assertion sets fail before schema or relationship writes. ### Stage/demo bootstrap -Stage bootstrap is an operator action, not a migration, startup hook, or automatic deploy step. It -must remain: +Stage bootstrap is an operator action, not a migration, startup hook, or automatic deploy step. It must remain: - limited to a fixed context set in source control; - explicitly gated to stage; @@ -237,66 +163,43 @@ Every later canonical state change uses a typed Action. ### Database and authorization -Use expand/deploy/contract. During a rolling overlap, both previous and candidate code must tolerate -the expanded PostgreSQL and SpiceDB models. +Use expand/deploy/contract. During a rolling overlap, both previous and candidate code must tolerate the expanded PostgreSQL and SpiceDB models. ### Module contracts and BFFs - Public contracts are versioned, bounded, and JSON-safe. - Normalize values to serializable primitives before public schema validation. -- Test candidate Shell against the previous MicroVertical contract and candidate MicroVertical - against the previous Shell contract. -- A dependency outage produces a typed unavailable/degraded state; it must not corrupt persisted - module state or disable unrelated modules. -- Server-governed schemas stay server-local and use the Core Effect runtime. Do not reuse a client - package's runtime schema object inside the governed server registration. -- Once explicit alternatives are supported, a Customer Configuration resolves exactly one permitted - healthy `implementationId` for each selected `moduleId` and rejects missing, ambiguous, invisible, - or contract-incompatible alternatives. Until then, one implicit `standard` implementation exists. -- Compatibility versions and immutable build revisions are rollout evidence, not customer-selectable - product releases. +- Test candidate Shell against the previous MicroVertical contract and candidate MicroVertical against the previous Shell contract. +- A dependency outage produces a typed unavailable/degraded state; it must not corrupt persisted module state or disable unrelated modules. +- Server-governed schemas stay server-local and use the Core Effect runtime. Do not reuse a client package's runtime schema object inside the governed server registration. +- Once explicit alternatives are supported, a Customer Configuration resolves exactly one permitted healthy `implementationId` for each selected `moduleId` and rejects missing, ambiguous, invisible, or contract-incompatible alternatives. Until then, one implicit `standard` implementation exists. +- Compatibility versions and immutable build revisions are rollout evidence, not customer-selectable product releases. ### Commerce applications -Follow [Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). Storefront Applications and -their local BFF/proxies deploy independently from OntOS. Promotion must verify each tenant-bound -Storefront Client, the separate Portal Account realm, native Commerce Storefront API contracts, and -any declared Medusa compatibility subset. Commerce Operations deploys as a purpose-built staff -consumer of public module contracts, not as Shell/Core business behavior. +Follow [Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). Storefront Applications and their local BFF/proxies deploy independently from OntOS. Promotion must verify each tenant-bound Storefront Client, the separate Portal Account realm, native Commerce Storefront API contracts, and any declared Medusa compatibility subset. Commerce Operations deploys as a purpose-built staff consumer of public module contracts, not as Shell/Core business behavior. ### Module Federation and CSS -- React, Modern runtime, and provider-context packages such as i18n must be exact strict singletons - on both Shell and remotes. -- Promoted compositions pin immutable `mf-manifest.json` references and permit browser execution - only. Routine upgrades wait for a new browser document; they never force-replace a loaded remote. -- Shell/Core SSR renders stable framing and typed placeholders. Any future MicroVertical SSR runs in - a MicroVertical-owned isolated process, not the Shell/Core Node.js process. -- Every app owns a CSS prefix/namespace. A Shell or MicroVertical build must not scan, erase, or - collide with another delivery unit's utility classes. -- A remote is healthy only when its manifest, remote entry, chunks, shared runtime, localized page, - and Shell integration all load successfully. +- React, Modern runtime, and provider-context packages such as i18n must be exact strict singletons on both Shell and remotes. +- Promoted compositions pin immutable `mf-manifest.json` references and permit browser execution only. Routine upgrades wait for a new browser document; they never force-replace a loaded remote. +- Shell/Core SSR renders stable framing and typed placeholders. Any future MicroVertical SSR runs in a MicroVertical-owned isolated process, not the Shell/Core Node.js process. +- Every app owns a CSS prefix/namespace. A Shell or MicroVertical build must not scan, erase, or collide with another delivery unit's utility classes. +- A remote is healthy only when its manifest, remote entry, chunks, shared runtime, localized page, and Shell integration all load successfully. ## Release sequence Use this sequence for a new or changed MicroVertical: -1. **Plan:** generate the impacted delivery-unit graph from topology and capture compatibility, - migration, flag, smoke, and rollback declarations. +1. **Plan:** generate the impacted delivery-unit graph from topology and capture compatibility, migration, flag, smoke, and rollback declarations. 2. **Preflight:** validate configuration and record last-known-good artifacts. 3. **Build:** produce and verify immutable target-shaped artifacts. -4. **Migrate:** expand PostgreSQL, refresh grants, verify schemas, then compatibly update SpiceDB - and complete any required operator-controlled relationship provisioning before deploying a - fail-closed consumer. +4. **Migrate:** expand PostgreSQL, refresh grants, verify schemas, then compatibly update SpiceDB and complete any required operator-controlled relationship provisioning before deploying a fail-closed consumer. 5. **Deploy providers:** deploy affected MicroVerticals in dependency order, initially dark. -6. **Expose providers:** verify readiness, module manifest, BFF, remote assets, and public endpoint; - make endpoint provisioning idempotent by checking its final state. -7. **Promote composition:** validate and explicitly promote one immutable candidate revision. A - compatible MicroVertical update or installation does not redeploy Shell. -8. **Smoke:** open a new browser document pinned to that revision and execute the authenticated - distributed smoke suite. -9. **Canary:** activate the selected module—and its explicit implementation once supported—plus - affected Storefront Clients for one approved tenant/cohort. +6. **Expose providers:** verify readiness, module manifest, BFF, remote assets, and public endpoint; make endpoint provisioning idempotent by checking its final state. +7. **Promote composition:** validate and explicitly promote one immutable candidate revision. A compatible MicroVertical update or installation does not redeploy Shell. +8. **Smoke:** open a new browser document pinned to that revision and execute the authenticated distributed smoke suite. +9. **Canary:** activate the selected module—and its explicit implementation once supported—plus affected Storefront Clients for one approved tenant/cohort. 10. **Observe:** hold expansion until the canary window and required signals are healthy. 11. **Expand:** activate additional tenants gradually. 12. **Close:** record deployed digests, smoke evidence, and the new last-known-good set. @@ -325,8 +228,7 @@ Provider readiness alone is insufficient. The post-deploy release gate exercises - basic responsive layout/CSS geometry; - absence of unexpected browser errors and HTTP 5xx responses. -Run affected unit, integration, database, contract, and browser tests in CI as well. A root `/` -health probe cannot substitute for this suite. +Run affected unit, integration, database, contract, and browser tests in CI as well. A root `/` health probe cannot substitute for this suite. ## Observability @@ -340,12 +242,9 @@ Every deploy and smoke record includes: - previous and candidate versions for rollback; - bounded logs for the failing service and direct dependencies. -Automatically collect failed-service logs. Alert if the administrative migrator remains running -after the migration phase. +Automatically collect failed-service logs. Alert if the administrative migrator remains running after the migration phase. -Never log credentials, signing material, cookies, raw assertions, complete tenant/composition -payloads, or unbounded schema diagnostics. Unexpected defects keep full internal Effect causes at -the owning server boundary with correlation context; public errors remain typed and sanitized. +Never log credentials, signing material, cookies, raw assertions, complete tenant/composition payloads, or unbounded schema diagnostics. Unexpected defects keep full internal Effect causes at the owning server boundary with correlation context; public errors remain typed and sanitized. ## Rollback @@ -355,17 +254,12 @@ Rollback must be executable and tested before rollout: 2. stop further promotion; 3. identify the failed unit and the last successful phase from structured evidence; 4. explicitly promote the previously validated composition revision; -5. restore affected delivery units using the deployment automation's immutable release records. - Composition pins public contract and MF-manifest digests; its `buildMarker` alone is not an - executable artifact identity. The publisher in #374 must bind the composition revision to those - release records before supporting rollback; +5. restore affected delivery units using the deployment automation's immutable release records. Composition pins public contract and MF-manifest digests; its `buildMarker` alone is not an executable artifact identity. The publisher in #374 must bind the composition revision to those release records before supporting rollback; 6. leave additive PostgreSQL and compatible SpiceDB changes in place; 7. rerun the complete authenticated smoke suite; 8. record the rollback artifacts and outcome. -If cleanup or endpoint provisioning returns an error, accept only a recognized idempotent state and -verify the final state. `continue-on-error` without final-state verification is not rollback or -idempotence. +If cleanup or endpoint provisioning returns an error, accept only a recognized idempotent state and verify the final state. `continue-on-error` without final-state verification is not rollback or idempotence. ## Pull-request and release hygiene @@ -382,9 +276,7 @@ Every deploy-affecting PR includes a deployment-impact section containing: - observability fields/dashboard location; - generator changes required for future MicroVerticals. -Separate review concerns when useful—normally deployment generator/infrastructure, compatible -schema, application behavior, and activation—but assemble and prove one immutable release candidate -before merge. Do not merge a release and then use stage to discover one failure per follow-up PR. +Separate review concerns when useful—normally deployment generator/infrastructure, compatible schema, application behavior, and activation—but assemble and prove one immutable release candidate before merge. Do not merge a release and then use stage to discover one failure per follow-up PR. After any failed rehearsal or rollout: @@ -394,39 +286,16 @@ After any failed rehearsal or rollout: 4. fix the entire failure class and add a regression test; 5. rebuild once and rerun the ordered gates from the beginning. -Use the CI provider's rerun or manual dispatch for a genuine retry. Do not create empty commits to -retrigger a pipeline. +Use the CI provider's rerun or manual dispatch for a genuine retry. Do not create empty commits to retrigger a pipeline. ## Historical release evidence -Git history and regression tests own the detailed rollout-failure record. When a failure class -recurs or deployment behavior changes, add a permanent automated contract test instead of extending -a prose commit list. +Git history and regression tests own the detailed rollout-failure record. When a failure class recurs or deployment behavior changes, add a permanent automated contract test instead of extending a prose commit list. ## Fail-closed authorization promotion -Authorization changes deploy schema and data expansion first: the Contacts assertion-redemption -migration and SpiceDB policy precede every provider and the Shell. Run the inventory check, collect -sanitized report-only evidence for one source revision and inventory hash, reduce it with -`pnpm authorization:impact:report`, and validate fixed-context evidence with -`pnpm authorization:readiness:check -- stage`. The command accepts only a fixed environment name; -it loads `topology/authorization-contexts/.json` and the fixed inventory, impact, -observation, and negative-smoke report names. The resulting artifact binds the environment, source -revision, inventory and context hashes, schema/data versions, replay migration, impact report, -smoke evidence, observation bounds, and approval reference. - -Pass `--authorization-environment ` to `pnpm deployment-impact:plan --` for a -promotion plan. `report_only` is valid only before its declared expiry and never in production. -`enforced` requires matching zero-impact, readiness, and negative-smoke artifacts from the exact -build. Abort on an expired window, mixed build evidence, unresolved impact, missing -policy/module/worker/issuer/replay data, or a failed negative smoke. - -Production remains blocked while no approved source-controlled production context exists; the -development/stage provisioner must continue rejecting production and arbitrary tenant or Action -arguments. Issue #173 owns technical implementation and readiness; issue #369 owns the separate -production-promotion approval gate. Issue #169 is broader review context, not approval. Their current -records—not this playbook—determine whether the gates are satisfied. An implementation override -never records Petr/Jiří approval or permits production enforcement. The checked-in stage context -remains `pending`; code-only override is not approval. -Rollback restores the prior application mode only after preserving the exact evidence and must not -remove the expanded schema or durable redemption rows while old/new consumers overlap. +Authorization changes deploy schema and data expansion first: the Contacts assertion-redemption migration and SpiceDB policy precede every provider and the Shell. Run the inventory check, collect sanitized report-only evidence for one source revision and inventory hash, reduce it with `pnpm authorization:impact:report`, and validate fixed-context evidence with `pnpm authorization:readiness:check -- stage`. The command accepts only a fixed environment name; it loads `topology/authorization-contexts/.json` and the fixed inventory, impact, observation, and negative-smoke report names. The resulting artifact binds the environment, source revision, inventory and context hashes, schema/data versions, replay migration, impact report, smoke evidence, observation bounds, and approval reference. + +Pass `--authorization-environment ` to `pnpm deployment-impact:plan --` for a promotion plan. `report_only` is valid only before its declared expiry and never in production. `enforced` requires matching zero-impact, readiness, and negative-smoke artifacts from the exact build. Abort on an expired window, mixed build evidence, unresolved impact, missing policy/module/worker/issuer/replay data, or a failed negative smoke. + +Production remains blocked while no approved source-controlled production context exists; the development/stage provisioner must continue rejecting production and arbitrary tenant or Action arguments. Issue #173 owns technical implementation and readiness; issue #369 owns the separate production-promotion approval gate. Issue #169 is broader review context, not approval. Their current records—not this playbook—determine whether the gates are satisfied. An implementation override never records Petr/Jiří approval or permits production enforcement. The checked-in stage context remains `pending`; code-only override is not approval. Rollback restores the prior application mode only after preserving the exact evidence and must not remove the expanded schema or durable redemption rows while old/new consumers overlap. diff --git a/app/docs/architecture/DRIZZLE_V1_UPGRADE.md b/app/docs/architecture/DRIZZLE_V1_UPGRADE.md index 9ae658f38..ffb4c427f 100644 --- a/app/docs/architecture/DRIZZLE_V1_UPGRADE.md +++ b/app/docs/architecture/DRIZZLE_V1_UPGRADE.md @@ -2,38 +2,20 @@ Status: **applied** -Cohort: `drizzle-orm@1.0.0-rc.5-ab785fc`, `drizzle-kit@1.0.0-rc.5-ab785fc`, `better-auth@1.7.2`, -`@better-auth/api-key@1.7.2`, `@better-auth/drizzle-adapter@1.7.2`, `auth@1.7.2` +Cohort: `drizzle-orm@1.0.0-rc.5-ab785fc`, `drizzle-kit@1.0.0-rc.5-ab785fc`, `better-auth@1.7.2`, `@better-auth/api-key@1.7.2`, `@better-auth/drizzle-adapter@1.7.2`, `auth@1.7.2` Native persistence: `@effect/sql-pg@4.0.0-beta.107` with `effect@4.0.0-beta.107`. -This document records how OntOS moved from the stable `0.45.2`/`0.31.10` pair to the Drizzle v1 -release candidate, which repository surfaces changed, how the three migration histories were -converted without touching any applied migration, and which proofs gate a future Drizzle bump. -[Database Architecture](./DATABASE.md) remains the authoritative rule set; this page is the -upgrade record and operator runbook. +This document records how OntOS moved from the stable `0.45.2`/`0.31.10` pair to the Drizzle v1 release candidate, which repository surfaces changed, how the three migration histories were converted without touching any applied migration, and which proofs gate a future Drizzle bump. [Database Architecture](./DATABASE.md) remains the authoritative rule set; this page is the upgrade record and operator runbook. ## Decision -OntOS deliberately tracks the Drizzle `rc` channel instead of waiting for `1.0.0` stable. The -readiness analysis that preceded this upgrade (pull request `TechsioCZ/ontos#98`) deferred the move -because `drizzle-kit up` produced a false migration for unchanged owners and because the Auth owner -still used Relational Queries v1. Both blockers are resolved here: - -- the false migration is a documented converter defect - ([drizzle-team/drizzle-orm#6020](https://github.com/drizzle-team/drizzle-orm/issues/6020)) that - is corrected once, deterministically, by normalizing SQL fragments in the converted snapshots; -- Better Auth `1.7.2` ships the `@better-auth/drizzle-adapter/relations-v2` entrypoint, so the Auth - owner moves to `defineRelations` with the officially supported adapter. - -The initial upgrade adopted tagged `rc.4`. The native Effect migration in -[PR #494](https://github.com/TechsioCZ/ontos/pull/494) adopts the exact published snapshot -`1.0.0-rc.5-ab785fc` for both Drizzle packages. The `rc.4` Effect driver uses a removed -Effect Schema API and fails with the workspace's Effect version -([drizzle-team/drizzle-orm#6162](https://github.com/drizzle-team/drizzle-orm/issues/6162)). -The selected snapshot contains the upstream API update; it is a pinned branch build, -not a tagged `rc.5` release. Future bumps still require the -[Re-proof checklist](#re-proof-checklist). +OntOS deliberately tracks the Drizzle `rc` channel instead of waiting for `1.0.0` stable. The readiness analysis that preceded this upgrade (pull request `TechsioCZ/ontos#98`) deferred the move because `drizzle-kit up` produced a false migration for unchanged owners and because the Auth owner still used Relational Queries v1. Both blockers are resolved here: + +- the false migration is a documented converter defect ([drizzle-team/drizzle-orm#6020](https://github.com/drizzle-team/drizzle-orm/issues/6020)) that is corrected once, deterministically, by normalizing SQL fragments in the converted snapshots; +- Better Auth `1.7.2` ships the `@better-auth/drizzle-adapter/relations-v2` entrypoint, so the Auth owner moves to `defineRelations` with the officially supported adapter. + +The initial upgrade adopted tagged `rc.4`. The native Effect migration in [PR #494](https://github.com/TechsioCZ/ontos/pull/494) adopts the exact published snapshot `1.0.0-rc.5-ab785fc` for both Drizzle packages. The `rc.4` Effect driver uses a removed Effect Schema API and fails with the workspace's Effect version ([drizzle-team/drizzle-orm#6162](https://github.com/drizzle-team/drizzle-orm/issues/6162)). The selected snapshot contains the upstream API update; it is a pinned branch build, not a tagged `rc.5` release. Future bumps still require the [Re-proof checklist](#re-proof-checklist). ## What changed @@ -48,13 +30,11 @@ not a tagged `rc.5` release. Future bumps still require the | `@better-auth/drizzle-adapter` | indirect | 1.7.2 direct | root, Shell (`/relations-v2` entrypoint) | | `auth` (Better Auth CLI) | 1.6.23 | 1.7.2 | Shell | -This table records the original upgrade, when Party was named Contacts. The current cohort -above supersedes its Drizzle versions. Every owner pins the identical Drizzle pair. +This table records the original upgrade, when Party was named Contacts. The current cohort above supersedes its Drizzle versions. Every owner pins the identical Drizzle pair. ### Migration folder layout (v3) -Each owner history is now one folder per migration instead of numbered SQL files plus -`meta/_journal.json`: +Each owner history is now one folder per migration instead of numbered SQL files plus `meta/_journal.json`: ```text packages/core-runtime/drizzle/ @@ -65,32 +45,19 @@ packages/core-runtime/drizzle/ 20260901102632_/ ``` -Folder names are `<14-digit UTC timestamp>_`; the timestamp is the old journal `when` -value. `drizzle-kit up` produced every folder, and each `migration.sql` is byte-identical to the SQL -file it replaced (verified with `cmp` against `git show HEAD:` for all 20 files across -Core, Auth, and Contacts). Snapshots are DDL snapshots (`version: 8`) with an `id`/`prevIds` chain -that `drizzle-kit check` and `generate` use to detect non-commutative migrations across branches. +Folder names are `<14-digit UTC timestamp>_`; the timestamp is the old journal `when` value. `drizzle-kit up` produced every folder, and each `migration.sql` is byte-identical to the SQL file it replaced (verified with `cmp` against `git show HEAD:` for all 20 files across Core, Auth, and Contacts). Snapshots are DDL snapshots (`version: 8`) with an `id`/`prevIds` chain that `drizzle-kit check` and `generate` use to detect non-commutative migrations across branches. Repository surfaces that referenced the old layout were updated: - `verticals/contacts/tests/unit/schema-contract.test.ts` reads `/migration.sql`; -- `packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` reads the renamed - Core migration folder; -- `scripts/validate-ultramodern-workspace.mts` allowlists the historical migration files that still - carry the pre-rename module identity. +- `packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` reads the renamed Core migration folder; +- `scripts/validate-ultramodern-workspace.mts` allowlists the historical migration files that still carry the pre-rename module identity. ### Snapshot normalization after `drizzle-kit up` -`drizzle-kit up` copies SQL fragments from the v0 snapshots verbatim. The v1 schema reader renders -partial-index predicates and check-constraint expressions without the `"schema"."table".` -qualifier, so an unchanged schema diffs as changed. On OntOS this produced 39 false DDL statements -for Core (4 partial-index rebuilds, 31 check-constraint rewrites) and 12 for Contacts. +`drizzle-kit up` copies SQL fragments from the v0 snapshots verbatim. The v1 schema reader renders partial-index predicates and check-constraint expressions without the `"schema"."table".` qualifier, so an unchanged schema diffs as changed. On OntOS this produced 39 false DDL statements for Core (4 partial-index rebuilds, 31 check-constraint rewrites) and 12 for Contacts. -The converted snapshots were normalized once with the script below, after `up` and before the first -`generate`. It strips only the entity's own `""."".` prefix from index `where` -predicates, check `value` expressions, and expression index columns. Row-level-security policy -predicates are intentionally left alone: the v1 reader keeps them qualified, and stripping them -reintroduces a false `ALTER POLICY` migration. +The converted snapshots were normalized once with the script below, after `up` and before the first `generate`. It strips only the entity's own `""."
".` prefix from index `where` predicates, check `value` expressions, and expression index columns. Row-level-security policy predicates are intentionally left alone: the v1 reader keeps them qualified, and stripping them reintroduces a false `ALTER POLICY` migration. ```js // normalize-v1-snapshots.mjs — run once per owner history after `drizzle-kit up` @@ -139,75 +106,45 @@ for (const root of roots) { console.log(`normalized ${fragments} fragments in ${files} snapshots`); ``` -Result on this repository: `normalized 333 fragments in 13 snapshots`. After normalization every -owner's `db:generate` prints `No schema changes, nothing to migrate`. Snapshots are metadata for -diffing; the normalization changes no SQL and no database object. +Result on this repository: `normalized 333 fragments in 13 snapshots`. After normalization every owner's `db:generate` prints `No schema changes, nothing to migrate`. Snapshots are metadata for diffing; the normalization changes no SQL and no database object. ### Initial rc.4 schema and runtime changes -- **Relational Queries v2.** `apps/shell-super-app/api/auth/db/schema.ts` replaces the four - `relations(...)` declarations with one `authRelations = defineRelations(authDatabaseSchema, ...)` - graph (`user.sessions`, `user.accounts`, `user.apiKeys`, and the `one` reverse edges). Core and - Contacts export `coreRelations` / `contactsRelations` as `defineRelations()` with no - navigational relations yet, which still exposes typed `db.query.
` access. -- **Executor types.** `NodePgDatabase`, `NodePgDatabase`, - and `NodePgDatabase` replace the schema-keyed generics. Every - `drizzle({ client, schema })` call site now passes `relations` instead. -- **Better Auth.** All five `drizzleAdapter` imports (`service.ts`, `api-key-service.ts`, - `impersonation-service.ts`, `stage-demo-bootstrap-runtime-infrastructure.ts`, the e2e fixture) - and `scripts/initialize-local-development.mts` import from - `@better-auth/drizzle-adapter/relations-v2`. The adapter still receives `schema: authDatabaseSchema` - (tables keyed by Better Auth model name) and `transaction: true`. -- **Row-level security.** The deprecated `table.enableRLS()` wrapper `enableGovernedRls` was removed - from `@app/core-runtime`; Contacts tables are declared with `contactsSchema.table.withRLS(...)`. - `tenantRlsPolicies` and `tenantLegalEntityRlsPolicies` are unchanged. -- **Deprecated helpers.** `getTableColumns` became `getColumns`; the Core schema-contract test asserts - the sequence column through `getSQLType()` because v1 reports `dataType` as `bigint int64`. +- **Relational Queries v2.** `apps/shell-super-app/api/auth/db/schema.ts` replaces the four `relations(...)` declarations with one `authRelations = defineRelations(authDatabaseSchema, ...)` graph (`user.sessions`, `user.accounts`, `user.apiKeys`, and the `one` reverse edges). Core and Contacts export `coreRelations` / `contactsRelations` as `defineRelations()` with no navigational relations yet, which still exposes typed `db.query.
` access. +- **Executor types.** `NodePgDatabase`, `NodePgDatabase`, and `NodePgDatabase` replace the schema-keyed generics. Every `drizzle({ client, schema })` call site now passes `relations` instead. +- **Better Auth.** All five `drizzleAdapter` imports (`service.ts`, `api-key-service.ts`, `impersonation-service.ts`, `stage-demo-bootstrap-runtime-infrastructure.ts`, the e2e fixture) and `scripts/initialize-local-development.mts` import from `@better-auth/drizzle-adapter/relations-v2`. The adapter still receives `schema: authDatabaseSchema` (tables keyed by Better Auth model name) and `transaction: true`. +- **Row-level security.** The deprecated `table.enableRLS()` wrapper `enableGovernedRls` was removed from `@app/core-runtime`; Contacts tables are declared with `contactsSchema.table.withRLS(...)`. `tenantRlsPolicies` and `tenantLegalEntityRlsPolicies` are unchanged. +- **Deprecated helpers.** `getTableColumns` became `getColumns`; the Core schema-contract test asserts the sequence column through `getSQLType()` because v1 reports `dataType` as `bigint int64`. ### Better Auth 1.7 account identity -Better Auth 1.7 keys every provider identity on `(issuer, accountId)` and requires a non-null -`account.issuer` column with a unique index over both columns. The Auth owner adds that column in -`20260905002342_add-account-issuer`. The migration is expand-then-tighten inside one transaction: +Better Auth 1.7 keys every provider identity on `(issuer, accountId)` and requires a non-null `account.issuer` column with a unique index over both columns. The Auth owner adds that column in `20260905002342_add-account-issuer`. The migration is expand-then-tighten inside one transaction: 1. add `issuer` as nullable; 2. refuse to continue if any `provider_id` needs URI encoding (OntOS only has `credential`); -3. backfill `local:credential` for credential accounts and `local:oauth:` otherwise, - which is Better Auth's `provider-id` identity strategy; +3. backfill `local:credential` for credential accounts and `local:oauth:` otherwise, which is Better Auth's `provider-id` identity strategy; 4. refuse to continue if two rows share an `(issuer, account_id)` identity; 5. set `NOT NULL` and create `auth_account_issuer_account_id_uk`. -Better Auth 1.6 writers do not supply `issuer`, so the migration also installs a `BEFORE INSERT` -trigger (`auth.account_issuer_compat`) that derives the value with the same rule when a row arrives -without one. That keeps the previous Shell release working against the expanded schema, as the -[Deployment](./DEPLOYMENT.md) sequence requires, so the Auth migration stays expand-only. Drop the -trigger and its function in a later contraction migration once no Better Auth 1.6 writer remains; -Better Auth 1.7 always writes `issuer` explicitly, so the trigger is inert for the new release. +Better Auth 1.6 writers do not supply `issuer`, so the migration also installs a `BEFORE INSERT` trigger (`auth.account_issuer_compat`) that derives the value with the same rule when a row arrives without one. That keeps the previous Shell release working against the expanded schema, as the [Deployment](./DEPLOYMENT.md) sequence requires, so the Auth migration stays expand-only. Drop the trigger and its function in a later contraction migration once no Better Auth 1.6 writer remains; Better Auth 1.7 always writes `issuer` explicitly, so the trigger is inert for the new release. ### New `db:check` script -`pnpm db:check` runs `drizzle-kit check` for Core, Auth, and Contacts. It validates the snapshot -chain and reports non-commutative migrations when two branches both add migrations. Run it after -rebasing a branch that touches any `drizzle/` or `drizzle-auth/` folder. +`pnpm db:check` runs `drizzle-kit check` for Core, Auth, and Contacts. It validates the snapshot chain and reports non-commutative migrations when two branches both add migrations. Run it after rebasing a branch that touches any `drizzle/` or `drizzle-auth/` folder. ## Migration bookkeeping upgrade -The first `drizzle-kit migrate` with v1 against an existing database upgrades each owner's -bookkeeping table in `drizzle` (`__drizzle_migrations_core`, `__drizzle_migrations_auth`, -`__drizzle_migrations_contacts`): +The first `drizzle-kit migrate` with v1 against an existing database upgrades each owner's bookkeeping table in `drizzle` (`__drizzle_migrations_core`, `__drizzle_migrations_auth`, `__drizzle_migrations_contacts`): - adds `name text` and backfills it with the v3 folder name matched by `created_at` millis; - adds `applied_at timestamptz default now()`; pre-upgrade rows keep `applied_at = NULL`; - keeps `id`, `hash`, and `created_at` unchanged. -The v1 migrator applies every migration folder missing from the table, not only folders newer than -the last applied row. This requires the administrative identity that already runs -`pnpm db:migrate`; no manual SQL is needed. +The v1 migrator applies every migration folder missing from the table, not only folders newer than the last applied row. This requires the administrative identity that already runs `pnpm db:migrate`; no manual SQL is needed. ## Initial rc.4 proofs -Environment: Darwin arm64, Node `26.5.0` and pnpm `11.25.0` through `mise exec --`, PostgreSQL 17 -in the local Compose container on port 5433. +Environment: Darwin arm64, Node `26.5.0` and pnpm `11.25.0` through `mise exec --`, PostgreSQL 17 in the local Compose container on port 5433. | Proof | Result | | -------------------------------------------------------- | ---------------------------------------------------------------------------------- | @@ -226,53 +163,26 @@ in the local Compose container on port 5433. Use this sequence for `1.0.0-rc.5`, `1.0.0`, or any later Drizzle bump: -1. Bump `drizzle-orm` and `drizzle-kit` together in the root, Core, Shell, and Party manifests, - plus the Better Auth cohort when its Drizzle peer range moves; run - `mise exec -- pnpm install --no-frozen-lockfile`. -2. Run `pnpm db:generate` and `pnpm db:check`; both must report no changes for unchanged schemas. - A generated folder for an unchanged owner is a converter or reader regression, not a schema - change, and must not be committed. -3. Create a disposable copy of a migrated database (`create database template ontos`), - point `DATABASE_ADMIN_URL`/`DATABASE_URL` at it, and run `pnpm db:migrate` twice followed by - `pnpm db:verify`. Row counts per owner must not change and the second run must be a no-op. +1. Bump `drizzle-orm` and `drizzle-kit` together in the root, Core, Shell, and Party manifests, plus the Better Auth cohort when its Drizzle peer range moves; run `mise exec -- pnpm install --no-frozen-lockfile`. +2. Run `pnpm db:generate` and `pnpm db:check`; both must report no changes for unchanged schemas. A generated folder for an unchanged owner is a converter or reader regression, not a schema change, and must not be committed. +3. Create a disposable copy of a migrated database (`create database template ontos`), point `DATABASE_ADMIN_URL`/`DATABASE_URL` at it, and run `pnpm db:migrate` twice followed by `pnpm db:verify`. Row counts per owner must not change and the second run must be a no-op. 4. Run `pnpm db:migrate` and `pnpm db:verify` against an empty database. -5. Run `pnpm typecheck`, `pnpm lint`, `pnpm db:test`, `pnpm action:test:unit`, `pnpm outbox:test`, - and `pnpm check`. +5. Run `pnpm typecheck`, `pnpm lint`, `pnpm db:test`, `pnpm action:test:unit`, `pnpm outbox:test`, and `pnpm check`. ## Native Effect adoption and rc.5 snapshot re-proof -Core and Party now use `drizzle-orm/effect-postgres` with `@effect/sql-pg`. Their database -factories retain scoped `pg.Pool` ownership, provide `PgClient.fromPool` and `Reactivity` to -`makeWithDefaults`, and expose `EffectPgDatabase` executors. Queries and transaction callbacks -are native Effects. The persistence-attempt wrappers and Effect–Promise–Effect transaction -bridge are removed. Better Auth retains its supported `node-postgres` adapter integration. -The four Drizzle Kit configurations omit the removed `strict` and `verbose` options. - -The current contracts, including raw `execute(sql, 'objects')` reads and native SQL -settlement errors, are defined in [Database Architecture](./DATABASE.md). No migration SQL -changes in this adoption. The latest Core, Party, and Contacts snapshots normalize 40, 81, -and 7 check/index SQL fragments respectively by removing only their own table qualifier. -This applies the same snapshot normalization described above to the current history heads. -Every normalized entity matches the native Kit output; snapshot IDs, ancestry, RLS policies, -and other fields remain unchanged. Without that normalization, Kit emits false constraint and -index rebuilds for unchanged schemas. Those generated migrations must not be applied or committed. +Core and Party now use `drizzle-orm/effect-postgres` with `@effect/sql-pg`. Their database factories retain scoped `pg.Pool` ownership, provide `PgClient.fromPool` and `Reactivity` to `makeWithDefaults`, and expose `EffectPgDatabase` executors. Queries and transaction callbacks are native Effects. The persistence-attempt wrappers and Effect–Promise–Effect transaction bridge are removed. Better Auth retains its supported `node-postgres` adapter integration. The four Drizzle Kit configurations omit the removed `strict` and `verbose` options. + +The current contracts, including raw `execute(sql, 'objects')` reads and native SQL settlement errors, are defined in [Database Architecture](./DATABASE.md). No migration SQL changes in this adoption. The latest Core, Party, and Contacts snapshots normalize 40, 81, and 7 check/index SQL fragments respectively by removing only their own table qualifier. This applies the same snapshot normalization described above to the current history heads. Every normalized entity matches the native Kit output; snapshot IDs, ancestry, RLS policies, and other fields remain unchanged. Without that normalization, Kit emits false constraint and index rebuilds for unchanged schemas. Those generated migrations must not be applied or committed. Re-proof on 2026-09-07 for commit `538e43a9a7b004e28eefc71df6e4a50eb814d51f`: - Frozen dependency installation passed with the exact cohort above. -- All 890 workspace unit/component tests and 61 affected Core, Party, and Shell integration - tests passed, including generated-owner isolation, RLS, Action atomicity, outbox behavior, - repeatable-read snapshots, cancellation, rollback failure, and uncertain commit recovery. +- All 890 workspace unit/component tests and 61 affected Core, Party, and Shell integration tests passed, including generated-owner isolation, RLS, Action atomicity, outbox behavior, repeatable-read snapshots, cancellation, rollback failure, and uncertain commit recovery. - Database schema verifiers passed for Core, Auth, Party, and Contacts and their journals. -- All 19 [CI validation jobs](https://github.com/TechsioCZ/ontos/actions/runs/34147241046) - passed, including database/migration integration, generation and generated-code typechecking, - workspace contracts, lint, typecheck, and Node plus Cloudflare artifact proofs. +- All 19 [CI validation jobs](https://github.com/TechsioCZ/ontos/actions/runs/34147241046) passed, including database/migration integration, generation and generated-code typechecking, workspace contracts, lint, typecheck, and Node plus Cloudflare artifact proofs. - The full local production build passed, including federation types and performance readiness. -These are the native adoption proofs. The historical populated-copy conversion results above -belong to the initial rc.4 upgrade and are not a new snapshot conversion for this bump. +These are the native adoption proofs. The historical populated-copy conversion results above belong to the initial rc.4 upgrade and are not a new snapshot conversion for this bump. -Review follow-up verified `pnpm db:generate` reports no schema changes and `pnpm db:check` -passes for all four histories after snapshot normalization. It also reran migrations twice -against the populated disposable development database and verified the exact schemas and -journals afterward. Fresh-database migration and verification passed in CI. +Review follow-up verified `pnpm db:generate` reports no schema changes and `pnpm db:check` passes for all four histories after snapshot normalization. It also reran migrations twice against the populated disposable development database and verified the exact schemas and journals afterward. Fresh-database migration and verification passed in CI. diff --git a/app/docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md b/app/docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md index 774898efd..4d9c4b6ee 100644 --- a/app/docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md +++ b/app/docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md @@ -1,9 +1,6 @@ # Effect v4 anti-pattern audit -> Historical findings from the original audit. Implementation has changed since this snapshot. -> [Database Architecture](DATABASE.md) owns the current native Effect database and transaction -> model; the Promise bridge proposals below are superseded. Use focused architecture documents -> and executable policy checks to assess current behavior. +> Historical findings from the original audit. Implementation has changed since this snapshot. [Database Architecture](DATABASE.md) owns the current native Effect database and transaction model; the Promise bridge proposals below are superseded. Use focused architecture documents and executable policy checks to assess current behavior. ## Verdict diff --git a/app/docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md b/app/docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md index f8dd185bc..afa504e2f 100644 --- a/app/docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md +++ b/app/docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md @@ -1,42 +1,24 @@ # Effect v4 lint enforcement -Diagnostic-only implementation of [the existing audit](EFFECT_V4_ANTIPATTERN_AUDIT.md). -No application violations are repaired, no new dependencies are installed, and none of the -71 custom rules supplies autofixes or suggestions. All 71 are enabled as errors. +Diagnostic-only implementation of [the existing audit](EFFECT_V4_ANTIPATTERN_AUDIT.md). No application violations are repaired, no new dependencies are installed, and none of the 71 custom rules supplies autofixes or suggestions. All 71 are enabled as errors. ## Verified snapshot -- Source snapshot: main commit `9adca84e`, plus this PR's lint tooling. The report captured at - `1531cfb6` is unchanged by the portable-launcher follow-up. No application-source changes are - included in this PR; upstream application changes were retained during rebase. +- Source snapshot: main commit `9adca84e`, plus this PR's lint tooling. The report captured at `1531cfb6` is unchanged by the portable-launcher follow-up. No application-source changes are included in this PR; upstream application changes were retained during rebase. - Existing lint toolchain: Oxlint 1.79.0, `@oxlint/plugins` 1.79.0, Node 26.8.1 locally. - Scope: `apps verticals packages scripts`, including Party Registry; **753 files linted**. - Effect policy report: **5,286 diagnostics in 517 files**; **70 rules report**, one has zero hits. - Disjoint groups: **3,054 source**, **1,296 tests**, **936 scripts**. Test paths take precedence. -- Dedicated strict tooling typecheck and **164 tests pass**, covering **2,248 fixture source - files**, production defaults, registration, reporting failures, temporary cleanup, nested-script - scope, isolated file-URL discovery, the portable launcher and lint-command scope parity. -- The Effect-only scan completes without a plugin crash and exits 1 intentionally because - application debt is reported, not repaired. - -Before rebase, the package-script gates and scoped formatting passed. Full lint on the original -`e38c97c` source snapshot produced 9,395 errors: 3,862 Effect and 5,533 other-policy diagnostics, -including five unused-disable directives. Those are **historical**, not the rebased totals above. - -**Clean-install CI on `1531cfb6`:** [run 33979642298](https://github.com/TechsioCZ/ontos/actions/runs/33979642298) -passes all non-lint validation jobs, including strict rule types and 162/162 tests, application -Typecheck, Format, Workspace Contract, database integration, generation, and Node/Workerd artifact -proofs. Full lint intentionally reports **12,031 errors on 753 files**; stage deployment is skipped. - -**Local environment:** installed application dependencies still lag main's updated lockfile, so -pnpm package wrappers can report `ERR_PNPM_VERIFY_DEPS_BEFORE_RUN`. No local dependency install was -performed; unchanged installed lint/compiler binaries were invoked directly. The separate clean -CI run provides synchronized verification, but neither run is a successful full `pnpm check` -because application lint debt remains. The Effect-only AST scan does not typecheck application -dependencies. - -Counts are diagnostic occurrences, **not unique audit clusters** or proof of independent bugs. Several rules can report at one source location. Zero hits does not mean a rule is disabled: -`no-runtime-construction-outside-root` has verified positive fixture coverage. +- Dedicated strict tooling typecheck and **164 tests pass**, covering **2,248 fixture source files**, production defaults, registration, reporting failures, temporary cleanup, nested-script scope, isolated file-URL discovery, the portable launcher and lint-command scope parity. +- The Effect-only scan completes without a plugin crash and exits 1 intentionally because application debt is reported, not repaired. + +Before rebase, the package-script gates and scoped formatting passed. Full lint on the original `e38c97c` source snapshot produced 9,395 errors: 3,862 Effect and 5,533 other-policy diagnostics, including five unused-disable directives. Those are **historical**, not the rebased totals above. + +**Clean-install CI on `1531cfb6`:** [run 33979642298](https://github.com/TechsioCZ/ontos/actions/runs/33979642298) passes all non-lint validation jobs, including strict rule types and 162/162 tests, application Typecheck, Format, Workspace Contract, database integration, generation, and Node/Workerd artifact proofs. Full lint intentionally reports **12,031 errors on 753 files**; stage deployment is skipped. + +**Local environment:** installed application dependencies still lag main's updated lockfile, so pnpm package wrappers can report `ERR_PNPM_VERIFY_DEPS_BEFORE_RUN`. No local dependency install was performed; unchanged installed lint/compiler binaries were invoked directly. The separate clean CI run provides synchronized verification, but neither run is a successful full `pnpm check` because application lint debt remains. The Effect-only AST scan does not typecheck application dependencies. + +Counts are diagnostic occurrences, **not unique audit clusters** or proof of independent bugs. Several rules can report at one source location. Zero hits does not mean a rule is disabled: `no-runtime-construction-outside-root` has verified positive fixture coverage. ## Reproduce @@ -50,17 +32,11 @@ pnpm lint:effect --json pnpm lint ``` -`lint:effect --json` includes every diagnostic, every affected file, and all 71 rule totals. -The text report explicitly caps only the top-file display at 20. `pnpm check` includes the rule -gates before application lint; existing reported violations intentionally block it. No `--fix` -command was run. The implementation [README](../../tools/oxlint/effect-native/README.md) describes -fixture development, options, and the fail-closed process harness. +`lint:effect --json` includes every diagnostic, every affected file, and all 71 rule totals. The text report explicitly caps only the top-file display at 20. `pnpm check` includes the rule gates before application lint; existing reported violations intentionally block it. No `--fix` command was run. The implementation [README](../../tools/oxlint/effect-native/README.md) describes fixture development, options, and the fail-closed process harness. ## Audit-to-rule catalog -The audit column is the **primary** section, not an exclusive mapping. Cross-cutting findings -(for example B2 time control, A1 reusable clients, A4 ADTs) can also motivate these rules. -Follow each rule link for its exact detection policy, defaults, exemptions, and limitations. +The audit column is the **primary** section, not an exclusive mapping. Cross-cutting findings (for example B2 time control, A1 reusable clients, A4 ADTs) can also motivate these rules. Follow each rule link for its exact detection policy, defaults, exemptions, and limitations. | Rule | Audit | Total | Source | Tests | Scripts | | -------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----: | -----: | ----: | ------: | @@ -138,50 +114,26 @@ Follow each rule link for its exact detection policy, defaults, exemptions, and ## Boundaries that remain review work -- AST/scope evidence is not a TypeScript semantic checker or cross-file dataflow engine. - Imported barrels, opaque aliases, arbitrary dynamic keys, external schemas and indirect - ownership require explicit configuration or review; syntax alone cannot establish them. -- Sequential-yield and timeout checks are **review candidates**, not proofs of safe concurrency - or end-to-end deadlines. Never parallelize writes/authentication/reconciliation mechanically. -- Schema/tag/secret/temporal-name policies cannot establish complete domain semantics. Nullable - wire encodings may be deliberate; any later codec migration requires round-trip verification. -- Runtime/layer/observability checks cannot prove resource lifetimes, Layer installation, context - propagation, exporter connectivity, redaction completeness, or application-wide composition. -- Effect-shaped port checks cannot create transaction affinity or prove rollback behavior. S1 - still needs transactional integration evidence; a syntactically clean port is insufficient. -- A7 shared contract authority, vocabulary reuse, and schema equivalence remain cross-file - architecture work beyond the local structural/document and schema detectors. -- A8 template checks are lexical: arbitrary generated/dynamically assembled source and real - scaffold quality still need generator tests and emitted-project gates. -- B2 uses the upstream `effect-rstest` harness (`it.effect`/`it.live`/`it.layer`), - enforced by the `no-effect-run-in-tests` and restricted-imports gates. +- AST/scope evidence is not a TypeScript semantic checker or cross-file dataflow engine. Imported barrels, opaque aliases, arbitrary dynamic keys, external schemas and indirect ownership require explicit configuration or review; syntax alone cannot establish them. +- Sequential-yield and timeout checks are **review candidates**, not proofs of safe concurrency or end-to-end deadlines. Never parallelize writes/authentication/reconciliation mechanically. +- Schema/tag/secret/temporal-name policies cannot establish complete domain semantics. Nullable wire encodings may be deliberate; any later codec migration requires round-trip verification. +- Runtime/layer/observability checks cannot prove resource lifetimes, Layer installation, context propagation, exporter connectivity, redaction completeness, or application-wide composition. +- Effect-shaped port checks cannot create transaction affinity or prove rollback behavior. S1 still needs transactional integration evidence; a syntactically clean port is insufficient. +- A7 shared contract authority, vocabulary reuse, and schema equivalence remain cross-file architecture work beyond the local structural/document and schema detectors. +- A8 template checks are lexical: arbitrary generated/dynamically assembled source and real scaffold quality still need generator tests and emitted-project gates. +- B2 uses the upstream `effect-rstest` harness (`it.effect`/`it.live`/`it.layer`), enforced by the `no-effect-run-in-tests` and restricted-imports gates. ## Audit exceptions preserved -Forced React/TanStack/Modern.js/Playwright/Drizzle/Node Promise boundaries, the deliberately -owned outer runner seam, startup `Layer.orDie` after typed-cause logging, JSONB/HttpApi encoding, -external test APIs requiring serialized bodies, malformed rejection-test casts, legitimate -`as const`/`satisfies`, line-preserving `.env` edits, native collections, recursive JSON array -normalization and correctly scoped fibers are not blanket migration targets. Operational -success console output remains allowed. The dropped Rspack injected-global finding stays dropped. +Forced React/TanStack/Modern.js/Playwright/Drizzle/Node Promise boundaries, the deliberately owned outer runner seam, startup `Layer.orDie` after typed-cause logging, JSONB/HttpApi encoding, external test APIs requiring serialized bodies, malformed rejection-test casts, legitimate `as const`/`satisfies`, line-preserving `.env` edits, native collections, recursive JSON array normalization and correctly scoped fibers are not blanket migration targets. Operational success console output remains allowed. The dropped Rspack injected-global finding stays dropped. -Exemptions are bounded by the local evidence/options each rule documents, not a guarantee that -every opaque implementation is classified correctly. Correct a confirmed false positive in the -detector with a regression; do not silence genuine architectural debt with blanket disables. +Exemptions are bounded by the local evidence/options each rule documents, not a guarantee that every opaque implementation is classified correctly. Correct a confirmed false positive in the detector with a regression; do not silence genuine architectural debt with blanket disables. ## Verification design -- Real Oxlint processes run all positive and negative inputs; exact counts are asserted where - declared. Explicit file lists and file-count checks prevent ignored-directory inputs from - masquerading as verified tests. Declaration syntax is tested in ordinary `.ts` files. -- Production-default checks stage copies outside `tools/**/tests` ancestry so fixture paths - do not accidentally select a test-only scope. Option overrides remain separate evidence. -- Loader errors, malformed output, unexpected diagnostics, inconsistent exits, empty-file - reports and stderr failures fail the harness rather than being reported as zero violations. -- Registration imports the actual plugin/config and checks complete rule coverage, error - severity, preserved typed lint settings, and absence of fixer/suggestion metadata. -- Temporary workspaces are owned, cleaned on success/failure/normal termination, and covered - by early/partial-failure and termination regressions. SIGKILL/host loss cannot be cleaned - synchronously; use an isolated temporary root when running under an external supervisor. -- Existing application tests and application fixes are outside this change. Full `pnpm check` - cannot pass while intentionally reported lint debt remains. +- Real Oxlint processes run all positive and negative inputs; exact counts are asserted where declared. Explicit file lists and file-count checks prevent ignored-directory inputs from masquerading as verified tests. Declaration syntax is tested in ordinary `.ts` files. +- Production-default checks stage copies outside `tools/**/tests` ancestry so fixture paths do not accidentally select a test-only scope. Option overrides remain separate evidence. +- Loader errors, malformed output, unexpected diagnostics, inconsistent exits, empty-file reports and stderr failures fail the harness rather than being reported as zero violations. +- Registration imports the actual plugin/config and checks complete rule coverage, error severity, preserved typed lint settings, and absence of fixer/suggestion metadata. +- Temporary workspaces are owned, cleaned on success/failure/normal termination, and covered by early/partial-failure and termination regressions. SIGKILL/host loss cannot be cleaned synchronously; use an isolated temporary root when running under an external supervisor. +- Existing application tests and application fixes are outside this change. Full `pnpm check` cannot pass while intentionally reported lint debt remains. diff --git a/app/docs/architecture/ERRORS.md b/app/docs/architecture/ERRORS.md index fbbea05c1..2d157bba4 100644 --- a/app/docs/architecture/ERRORS.md +++ b/app/docs/architecture/ERRORS.md @@ -2,10 +2,7 @@ This document defines the error contract from backend Effect programs, through HTTP, into generated Backend for Frontend (BFF) clients and frontend feature code. -Governed context, read denial/evidence, and isolation failures follow -[Governed Data Access and Operation Scope](./DATA_ACCESS.md). Context or authorization uncertainty -is a declared retryable `503`; business handlers and adapters must not receive database executors or -leak database/SpiceDB diagnostics. +Governed context, read denial/evidence, and isolation failures follow [Governed Data Access and Operation Scope](./DATA_ACCESS.md). Context or authorization uncertainty is a declared retryable `503`; business handlers and adapters must not receive database executors or leak database/SpiceDB diagnostics. ## Non-Negotiable Rules @@ -39,23 +36,13 @@ Internal domain and infrastructure errors may be more detailed than the public c Module entrypoint failures from [Module Entrypoints and Tenant State](./MODULE_ENTRYPOINTS.md) remain typed and sanitized across every boundary. A definite tenant-state denial normally maps to a declared `403`; an unavailable/indeterminate gate check maps to a declared retryable `503`. Frontend integrations must handle both explicitly before any private implementation or remote is loaded. -Shell composition uses `401` for a missing session, `409` when legal-entity selection is required, -`403` for definite module/resource denial, `404` for safely undiscoverable targets, retryable `503` -for catalog/state/context/authorization/provider uncertainty, and a redacted declared `500` only -after logging an unexpected Effect cause. HTTP status and Problem Details `status` must match. +Shell composition uses `401` for a missing session, `409` when legal-entity selection is required, `403` for definite module/resource denial, `404` for safely undiscoverable targets, retryable `503` for catalog/state/context/authorization/provider uncertainty, and a redacted declared `500` only after logging an unexpected Effect cause. HTTP status and Problem Details `status` must match. Unexpected defects are not expected failures. At the outer HTTP seam, log the full Effect cause with correlation context, then convert it to a declared, non-sensitive typed `InternalServerError` with status `500`. No defect may escape as an unstructured backend response. Generated Action BFF endpoints must also map the complete Core Action error union. `ActionPolicyDenied` carries a stable Policy reason code and safe human-readable reason, but Core deliberately assigns no HTTP status: the endpoint maps the Policy's declared semantics to the correct public Problem Details schema, such as `403` for authorization-like denial, `409` for current-state conflict, or `422` for semantic ineligibility. `ActionPolicyEvaluationError` represents a sanitized evaluator defect or unavailable required capability and must map to the endpoint's declared operational failure, commonly a retryable `503` when appropriate. Neither error may fall through to an exception, generic Action endpoint, or ad hoc response. -For the Shell-user MicroVertical Action identity boundary, the generated verifier keeps expected -failures typed as missing, invalid, expired, scope-invalid, configuration, or verification -unavailable errors. The owning endpoint must map missing, invalid, expired, and scope-invalid -credentials to its declared `401` Problem Details schema and attach `WWW-Authenticate: Bearer`. -Configuration or verification capability failures map to a declared retryable `503`. Never expose -the assertion, a JWK, signature diagnostics, or claim contents in Problem Details or logs. A verified -assertion supplies authentication context only; SpiceDB denial remains `403` and Policy failures -retain their endpoint-specific semantics. +For the Shell-user MicroVertical Action identity boundary, the generated verifier keeps expected failures typed as missing, invalid, expired, scope-invalid, configuration, or verification unavailable errors. The owning endpoint must map missing, invalid, expired, and scope-invalid credentials to its declared `401` Problem Details schema and attach `WWW-Authenticate: Bearer`. Configuration or verification capability failures map to a declared retryable `503`. Never expose the assertion, a JWK, signature diagnostics, or claim contents in Problem Details or logs. A verified assertion supplies authentication context only; SpiceDB denial remains `403` and Policy failures retain their endpoint-specific semantics. ## Status Code Semantics @@ -74,40 +61,15 @@ Choose the status from the meaning of the failure, not from a generic domain-err | `503` | A required capability is temporarily unavailable and retry may succeed later. | | `504` | A required upstream operation did not complete before its deadline. | -Identity endpoints apply the same meanings exhaustively. Missing or unusable Shell credentials use -`401` with a Bearer challenge; the API-key exchange uses an API-key challenge. A definite permission -denial or active credential bound to a forbidden tenant/principal/legal entity is `403`; lifecycle -state races are `409`; missing runtime records are `404`; ineligible targets are `422`; a missing -required idempotency key is `428`; provider throttling is `429`. Structurally invalid operation -payloads are `400`. Database, -SpiceDB, resolver, evidence, or provider uncertainty is retryable `503`, while only caught defects -at the outer handler seam become sanitized `500`. Problem Details never include keys, hashes, -cookies, provider diagnostics, identifiers, or signature details. - -`ActionAlreadyCommitted` is a terminal idempotency conflict (`409`) at identity transports, not a -retryable capability outage. The internal stopped-impersonation recovery path treats that exact -outcome as successful checkpoint replay, then retries deletion of its Auth-owned recovery record. -If any work after provider restoration remains pending, Shell forwards the restored cookie first -and returns the declared retryable `503` without exposing recovery data. -Requested and started checkpoint failures preserve their typed Action error: definite permission -denial maps to `403`, invalid identity state maps to `422`, and authorization or persistence -uncertainty maps to `503`. A stopped checkpoint that fails after mechanical termination is reported -only as pending recovery and never reactivates the impersonated session. +Identity endpoints apply the same meanings exhaustively. Missing or unusable Shell credentials use `401` with a Bearer challenge; the API-key exchange uses an API-key challenge. A definite permission denial or active credential bound to a forbidden tenant/principal/legal entity is `403`; lifecycle state races are `409`; missing runtime records are `404`; ineligible targets are `422`; a missing required idempotency key is `428`; provider throttling is `429`. Structurally invalid operation payloads are `400`. Database, SpiceDB, resolver, evidence, or provider uncertainty is retryable `503`, while only caught defects at the outer handler seam become sanitized `500`. Problem Details never include keys, hashes, cookies, provider diagnostics, identifiers, or signature details. + +`ActionAlreadyCommitted` is a terminal idempotency conflict (`409`) at identity transports, not a retryable capability outage. The internal stopped-impersonation recovery path treats that exact outcome as successful checkpoint replay, then retries deletion of its Auth-owned recovery record. If any work after provider restoration remains pending, Shell forwards the restored cookie first and returns the declared retryable `503` without exposing recovery data. Requested and started checkpoint failures preserve their typed Action error: definite permission denial maps to `403`, invalid identity state maps to `422`, and authorization or persistence uncertainty maps to `503`. A stopped checkpoint that fails after mechanical termination is reported only as pending recovery and never reactivates the impersonated session. Use other RFC 9110 statuses when they are a more accurate semantic match. Do not disguise authentication or authorization failures as validation errors, and do not use `500` for declared business rejections. ## Core Action Permission Failures -Core keeps its Action errors transport-neutral. A future Action BFF endpoint -must exhaustively map `ActionPermissionDenied` to a declared `403` Problem -Details schema and `ActionPermissionCheckError` to a declared `503` Problem -Details schema. The denial exposes only its stable code and safe reason. The -absence of an executor relationship is a definite `NO_PERMISSION` denial, not -an unavailable configuration state. The check error covers timeout, -unavailability, authentication or schema failure, and any conditional or -otherwise indeterminate SpiceDB decision; it must never be reclassified as a -permission denial or an unconfigured-Action allow. Do not introduce a generic -Action HTTP endpoint to perform this mapping. +Core keeps its Action errors transport-neutral. A future Action BFF endpoint must exhaustively map `ActionPermissionDenied` to a declared `403` Problem Details schema and `ActionPermissionCheckError` to a declared `503` Problem Details schema. The denial exposes only its stable code and safe reason. The absence of an executor relationship is a definite `NO_PERMISSION` denial, not an unavailable configuration state. The check error covers timeout, unavailability, authentication or schema failure, and any conditional or otherwise indeterminate SpiceDB decision; it must never be reclassified as a permission denial or an unconfigured-Action allow. Do not introduce a generic Action HTTP endpoint to perform this mapping. ## Problem Details @@ -120,18 +82,9 @@ Every error response must contain a Problem Details body whose schema is declare Add structured extension members only when clients need them to recover, such as safe field issues, a retry hint, or a stable domain reason code. Keep those extensions typed in Effect Schema. -Contract modules construct these schemas with the browser-safe `makeProblemDetailsSchema` and -`makeRetryableProblemDetailsSchema` helpers from `@app/shared-contracts/problem-details`. The -dedicated package entrypoint has no owner-local handler, environment, JOSE, database, or runtime -dependency. The helper couples the literal body status, HttpApi status annotation, and -`application/problem+json` representation. The retryable constructor deliberately adds only -`retryable: true`; other safe recovery data must be supplied as concrete Effect Schema fields. -Reserved Problem Details fields, arbitrary records, `Schema.Unknown`, and `Schema.Any` are not -extension points. +Contract modules construct these schemas with the browser-safe `makeProblemDetailsSchema` and `makeRetryableProblemDetailsSchema` helpers from `@app/shared-contracts/problem-details`. The dedicated package entrypoint has no owner-local handler, environment, JOSE, database, or runtime dependency. The helper couples the literal body status, HttpApi status annotation, and `application/problem+json` representation. The retryable constructor deliberately adds only `retryable: true`; other safe recovery data must be supplied as concrete Effect Schema fields. Reserved Problem Details fields, arbitrary records, `Schema.Unknown`, and `Schema.Any` are not extension points. -These helpers are transport-contract infrastructure, not a catalog of business errors. Every -contract still chooses its endpoint-specific tag, status, typed extensions, and visibly ordered -error collection. Do not derive universal tags or endpoint semantics from status codes. +These helpers are transport-contract infrastructure, not a catalog of business errors. Every contract still chooses its endpoint-specific tag, status, typed extensions, and visibly ordered error collection. Do not derive universal tags or endpoint semantics from status codes. ## Generated Client Contract @@ -162,9 +115,4 @@ Before completing backend or BFF client work, verify: ## Single-use gateway assertions -The receiving owner verifies signature, issuer, audience, expiry, version, `jti`, and trusted -principal claims before attempting redemption. Atomic duplicate redemption is a typed unusable -credential and maps to the same sanitized `401` family as another invalid Bearer assertion, with -`WWW-Authenticate: Bearer`. Redemption storage failure is a typed unavailable result and maps to -retryable `503`. Neither response exposes the assertion, `jti`, principal, tenant, or storage -diagnostic. +The receiving owner verifies signature, issuer, audience, expiry, version, `jti`, and trusted principal claims before attempting redemption. Atomic duplicate redemption is a typed unusable credential and maps to the same sanitized `401` family as another invalid Bearer assertion, with `WWW-Authenticate: Bearer`. Redemption storage failure is a typed unavailable result and maps to retryable `503`. Neither response exposes the assertion, `jti`, principal, tenant, or storage diagnostic. diff --git a/app/docs/architecture/MICROVERTICALS.md b/app/docs/architecture/MICROVERTICALS.md index c39c1b4e5..93baa5b6f 100644 --- a/app/docs/architecture/MICROVERTICALS.md +++ b/app/docs/architecture/MICROVERTICALS.md @@ -2,19 +2,11 @@ Each MicroVertical is a complete, independently deployable business module. It owns its domain model, database schema and migrations, repositories, Effect services, Backend for Frontend (BFF) contract and implementation, generated BFF client, and feature UI. -The UltraModern topology `appId` identifies that deployment. Its OntOS `moduleId` identifies the -business capability and owns Actions, resources, events, Outbox contracts, Policies, and tenant -module state. Follow [OntOS Module Manifests](./MODULE_MANIFESTS.md); never infer one identity from -the other. +The UltraModern topology `appId` identifies that deployment. Its OntOS `moduleId` identifies the business capability and owns Actions, resources, events, Outbox contracts, Policies, and tenant module state. Follow [OntOS Module Manifests](./MODULE_MANIFESTS.md); never infer one identity from the other. -For Customer Configuration alternatives, `moduleId` is the stable Module Contract Identity and -`implementationId` identifies one explicit catalogued executable implementation. Different public -semantics require a different `moduleId`; invisible same-identity forks are forbidden. Follow -[Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). +For Customer Configuration alternatives, `moduleId` is the stable Module Contract Identity and `implementationId` identifies one explicit catalogued executable implementation. Different public semantics require a different `moduleId`; invisible same-identity forks are forbidden. Follow [Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). -The current generated manifest/catalog does not yet implement `implementationId`; the only safe -current state is one implicit `standard` implementation per `moduleId`. Do not encode alternatives -with ad hoc fields or branches. Extend the generator and validation contract first. +The current generated manifest/catalog does not yet implement `implementationId`; the only safe current state is one implicit `standard` implementation per `moduleId`. Do not encode alternatives with ad hoc fields or branches. Extend the generator and validation contract first. ## Seam Model @@ -32,26 +24,19 @@ The vertical seam between MicroVerticals is non-negotiable: - Every MicroVertical must be deployable to its own server or process independently of every other MicroVertical. - Moving a MicroVertical from a shared host to a separate host must require deployment configuration or adapter selection only. It must not require changes to consuming business logic. - A MicroVertical must not import another MicroVertical's implementation, access its database or repositories, call its internal Effect services, or participate in its database transaction. -- Shell/Core and other MicroVerticals must not import another deployment's `vertical.manifest.ts` - or `vertical.registration.ts`. The serialized, composition-approved module contract is the - metadata seam; executable registration remains inside its owning deployment. +- Shell/Core and other MicroVerticals must not import another deployment's `vertical.manifest.ts` or `vertical.registration.ts`. The serialized, composition-approved module contract is the metadata seam; executable registration remains inside its owning deployment. - Shared packages may contain stable contracts and genuinely cross-cutting infrastructure. They must not become a back door for sharing MicroVertical business logic or persistence models. - Executable Policies owned by a MicroVertical are private, owner-local business behavior. Another MicroVertical must not import, register, or execute them. The only cross-module Policy reference exception is the narrow global Policy contract implemented and owned by Shell/Core; an Action may reference a global Policy without gaining access to Core repositories or another module's services. - Synchronous communication may cross the seam only through the provider's published, contract-derived Effect client. - Every module entrypoint crosses through the structured Shell/Core gateway and tenant-state rules in [Module Entrypoints and Tenant State](./MODULE_ENTRYPOINTS.md). Raw remote loads, direct private route/handler imports, and eager private implementations are forbidden. -- Application Composition, not topology or tenant state, is the runtime authority for the approved - module graph and exact artifact revisions. First-party remote UI executes only in the browser; - independently deployed MicroVertical code never executes inside the Shell/Core Node.js process. +- Application Composition, not topology or tenant state, is the runtime authority for the approved module graph and exact artifact revisions. First-party remote UI executes only in the browser; independently deployed MicroVertical code never executes inside the Shell/Core Node.js process. - Asynchronous communication may cross the seam only through Outbox Messages and their published schemas, using the lifecycle in [Outbox Worker Architecture](./OUTBOX_WORKERS.md). - Every synchronous request must propagate tenant, principal or service identity, and correlation context. The receiving MicroVertical authenticates and authorizes the request independently; co-location never implies trust. - Contract adapters must have equivalent observable behavior whether communication is in-process or over the network. The published client is the calling MicroVertical's interface to the provider. The provider's backend implementation remains private. -Each provider also follows [Governed Data Access and Operation Scope](./DATA_ACCESS.md): its public -operation descriptor chooses legal-entity scope explicitly, while its private handler receives only -owner-local services constructed after Core validates context and installs transaction scope. A -deployment seam never grants database or executor access. +Each provider also follows [Governed Data Access and Operation Scope](./DATA_ACCESS.md): its public operation descriptor chooses legal-entity scope explicitly, while its private handler receives only owner-local services constructed after Core validates context and installs transaction scope. A deployment seam never grants database or executor access. ## Horizontal Seam: A Virtual Effect BFF Interface @@ -107,70 +92,24 @@ Follow [Frontend Architecture Rules](../frontend/FRONTEND.md) for the complete f ## Staff Authentication Boundary -Staff authentication is a cross-cutting Shell/Core capability, never a MicroVertical. The -Shell owns staff credentials, Better Auth sessions and cookies, the strict Effect -authentication BFF, and the private `auth` schema. Core owns only non-secret -principal auth bindings and active principal/tenant resolution. Do not create an -Auth vertical, remote, package, delivery unit, or Module Federation boundary. - -Commerce Portal Accounts are the deliberate separate-realm exception, not an Auth MicroVertical: -Commerce owns their distinct BetterAuth configuration/schema, cookies, sessions, account lifecycle, -and owner-local Principal/Party linkage. They never enter the Shell staff realm. Storefront Clients -are separately bound service Principals and never identify portal users. Follow -[Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). - -One Better Auth user may have active bindings to multiple tenant-scoped Principals. Exactly one -nullable active tenant ID on the current Better Auth session selects which eligible Principal and -Tenant become trusted context for reads, gateway assertions, and Actions. Core Principal Auth -Bindings remain the tenant-access authority: the selected session field grants no permission and -must be revalidated against an active binding, Principal, and Tenant on every session resolution. -This does not introduce a global Principal, Better Auth Organization/member tables, an Auth -MicroVertical, or a generic context store. - -API-key callers terminate at Shell using `X-API-Key`. Better Auth verifies the credential and -returns its private stable key ID; Core resolves exactly one active binding; Shell then issues the -same 300-second assertion for one explicit MicroVertical audience. The key ID remains private join -data and the raw key never crosses Shell. Separate keys are required for separate tenant/principal -bindings. - -Support impersonation is tenant-local. The assertion and every receiving operation identify the -target as effective principal and the original administrator as impersonator; authorization and -Policies use the target. Both identities and support permission are revalidated. Trusted system -jobs bypass neither boundary: they are constructed inside Core from a branded workload registration -and active configured `system` or explicitly approved `service` principal, and are not gateway or -HTTP capabilities. - -Stopping impersonation remains available when either identity or support permission changed after -start. Auth writes a bounded non-secret recovery record before the started checkpoint completes, -retains it through provider restoration or expiry, always forwards the restored session cookie, and -retries the stopped Action checkpoint from the original session. -This recovery table is private Auth mechanics and never becomes a MicroVertical contract or generic -identity store. Recovery relaxes only the historical active-session validation needed to describe -the stopped event; the restricted Action still requires its explicit SpiceDB permission. - -Authenticated Shell composition also requires exactly one active, tenant-owned, authorized legal -entity persisted on that session. Tenant changes clear the legal entity; stale, cross-tenant, -inactive, or newly denied selections fail closed. Browser switch payloads contain only the requested -ID. The Shell assertion includes the revalidated legal-entity ID, while every receiver authorizes -module/resource/Action access independently. +Staff authentication is a cross-cutting Shell/Core capability, never a MicroVertical. The Shell owns staff credentials, Better Auth sessions and cookies, the strict Effect authentication BFF, and the private `auth` schema. Core owns only non-secret principal auth bindings and active principal/tenant resolution. Do not create an Auth vertical, remote, package, delivery unit, or Module Federation boundary. + +Commerce Portal Accounts are the deliberate separate-realm exception, not an Auth MicroVertical: Commerce owns their distinct BetterAuth configuration/schema, cookies, sessions, account lifecycle, and owner-local Principal/Party linkage. They never enter the Shell staff realm. Storefront Clients are separately bound service Principals and never identify portal users. Follow [Commerce Application Boundaries](./COMMERCE_APPLICATIONS.md). + +One Better Auth user may have active bindings to multiple tenant-scoped Principals. Exactly one nullable active tenant ID on the current Better Auth session selects which eligible Principal and Tenant become trusted context for reads, gateway assertions, and Actions. Core Principal Auth Bindings remain the tenant-access authority: the selected session field grants no permission and must be revalidated against an active binding, Principal, and Tenant on every session resolution. This does not introduce a global Principal, Better Auth Organization/member tables, an Auth MicroVertical, or a generic context store. + +API-key callers terminate at Shell using `X-API-Key`. Better Auth verifies the credential and returns its private stable key ID; Core resolves exactly one active binding; Shell then issues the same 300-second assertion for one explicit MicroVertical audience. The key ID remains private join data and the raw key never crosses Shell. Separate keys are required for separate tenant/principal bindings. + +Support impersonation is tenant-local. The assertion and every receiving operation identify the target as effective principal and the original administrator as impersonator; authorization and Policies use the target. Both identities and support permission are revalidated. Trusted system jobs bypass neither boundary: they are constructed inside Core from a branded workload registration and active configured `system` or explicitly approved `service` principal, and are not gateway or HTTP capabilities. + +Stopping impersonation remains available when either identity or support permission changed after start. Auth writes a bounded non-secret recovery record before the started checkpoint completes, retains it through provider restoration or expiry, always forwards the restored session cookie, and retries the stopped Action checkpoint from the original session. This recovery table is private Auth mechanics and never becomes a MicroVertical contract or generic identity store. Recovery relaxes only the historical active-session validation needed to describe the stopped event; the restricted Action still requires its explicit SpiceDB permission. + +Authenticated Shell composition also requires exactly one active, tenant-owned, authorized legal entity persisted on that session. Tenant changes clear the legal entity; stale, cross-tenant, inactive, or newly denied selections fail closed. Browser switch payloads contain only the requested ID. The Shell assertion includes the revalidated legal-entity ID, while every receiver authorizes module/resource/Action access independently. ### Shell-user Action identity -For a Shell-authenticated user calling an Action owned by an independently deployed -MicroVertical, the Shell resolves the current Better Auth session and issues one short-lived, -audience-scoped EdDSA assertion. The Shell alone owns the private Ed25519 signing JWK. The -receiving BFF receives only a public JWKS and independently verifies the signature, protected -header, issuer, exact topology app ID audience, times, version, subject consistency, and trusted -principal schema for every request. - -The assertion is authentication context, not authorization. It contains only the safe -`TrustedPrincipalContext` fields and never contains credentials, cookies, session tokens, display -data, Action keys, permissions, Policy decisions, or business payload. After verification, Core's -Action runtime still performs the Action-specific SpiceDB permission check and executable Policy -evaluation. Co-location with the Shell never bypasses this boundary. - -Prepare an existing MicroVertical once with -`mise exec -- pnpm scaffold:microvertical-action-boundary -- --vertical ` before its BFF accepts -Shell-user Action calls. The generated server verifier and client acquisition adapter embed the -vertical's authoritative topology app ID. Actions remain independently generated, and adding an -Action must never require a new Shell endpoint or a hand-maintained audience registry. +For a Shell-authenticated user calling an Action owned by an independently deployed MicroVertical, the Shell resolves the current Better Auth session and issues one short-lived, audience-scoped EdDSA assertion. The Shell alone owns the private Ed25519 signing JWK. The receiving BFF receives only a public JWKS and independently verifies the signature, protected header, issuer, exact topology app ID audience, times, version, subject consistency, and trusted principal schema for every request. + +The assertion is authentication context, not authorization. It contains only the safe `TrustedPrincipalContext` fields and never contains credentials, cookies, session tokens, display data, Action keys, permissions, Policy decisions, or business payload. After verification, Core's Action runtime still performs the Action-specific SpiceDB permission check and executable Policy evaluation. Co-location with the Shell never bypasses this boundary. + +Prepare an existing MicroVertical once with `mise exec -- pnpm scaffold:microvertical-action-boundary -- --vertical ` before its BFF accepts Shell-user Action calls. The generated server verifier and client acquisition adapter embed the vertical's authoritative topology app ID. Actions remain independently generated, and adding an Action must never require a new Shell endpoint or a hand-maintained audience registry. diff --git a/app/docs/architecture/MODULE_ENTRYPOINTS.md b/app/docs/architecture/MODULE_ENTRYPOINTS.md index 47b6c6ace..3af42af4e 100644 --- a/app/docs/architecture/MODULE_ENTRYPOINTS.md +++ b/app/docs/architecture/MODULE_ENTRYPOINTS.md @@ -1,19 +1,12 @@ # Module Entrypoints and Tenant State -Entrypoint `tenant`/`system` scope is independent from the descriptor's required/optional/forbidden -legal-entity scope. Both must be explicit. Missing, malformed, denied, or indeterminate operation -context fails closed before private implementation resolution as defined by -[Governed Data Access and Operation Scope](./DATA_ACCESS.md). +Entrypoint `tenant`/`system` scope is independent from the descriptor's required/optional/forbidden legal-entity scope. Both must be explicit. Missing, malformed, denied, or indeterminate operation context fails closed before private implementation resolution as defined by [Governed Data Access and Operation Scope](./DATA_ACCESS.md). -This document defines the Core-owned invariant for loading or dispatching OntOS Business Module -entrypoints. It applies to Actions, pages, public components, module APIs, search providers, -reports, and Outbox Workers. The gate is separate from authentication, SpiceDB authorization, and -business Policy; passing module state never grants another kind of access. +This document defines the Core-owned invariant for loading or dispatching OntOS Business Module entrypoints. It applies to Actions, pages, public components, module APIs, search providers, reports, and Outbox Workers. The gate is separate from authentication, SpiceDB authorization, and business Policy; passing module state never grants another kind of access. ## Structured entrypoints -Every entrypoint is an immutable Effect Schema-backed value containing a stable entrypoint key, -owning module key, role, access class, and explicit scope. +Every entrypoint is an immutable Effect Schema-backed value containing a stable entrypoint key, owning module key, role, access class, and explicit scope. | Role | Permitted access | | ------------------------------------ | ------------------------------------------------------------ | @@ -22,15 +15,9 @@ owning module key, role, access class, and explicit scope. | `page`, `public_component`, `search` | `read` or an explicit `historical_read` | | `api`, `report` | an explicitly selected `read`, `historical_read`, or `write` | -Tenant entrypoints are created only with the tenant constructor. Core capabilities use the system -constructor explicitly; a `core.*` prefix does not imply a bypass. A system entrypoint bypasses -tenant module-state acquisition only and still passes every applicable authentication, -permission, Policy, transaction, and evidence control. +Tenant entrypoints are created only with the tenant constructor. Core capabilities use the system constructor explicitly; a `core.*` prefix does not imply a bypass. A system entrypoint bypasses tenant module-state acquisition only and still passes every applicable authentication, permission, Policy, transaction, and evidence control. -Descriptors and private implementations are different surfaces. Public descriptors may be -imported through approved package exports. Private handlers, routes, Worker registrations, -search/report implementations, and vertical tables remain owner-local. A gateway accepts a -deferred Effect or loader thunk; it never receives an eagerly resolved private implementation. +Descriptors and private implementations are different surfaces. Public descriptors may be imported through approved package exports. Private handlers, routes, Worker registrations, search/report implementations, and vertical tables remain owner-local. A gateway accepts a deferred Effect or loader thunk; it never receives an eagerly resolved private implementation. ## Authoritative matrix @@ -45,24 +32,13 @@ deferred Effect or loader thunk; it never receives an eagerly resolved private i | `archived` | deny | allow | deny | deny | | missing row | deny | deny | deny | deny | -This is the only matrix. Runtime adapters call the Core decision function instead of maintaining -local state lists. `historical_read` is explicit and never a fallback from a denied normal read. -Normal Shell navigation includes installed, authorized `active`, `read_only`, and `deprecated` -modules. The latter two remain readable and visibly non-writable. Definite permission denial omits -normal navigation; authorization uncertainty preserves an otherwise eligible item as disabled. +This is the only matrix. Runtime adapters call the Core decision function instead of maintaining local state lists. `historical_read` is explicit and never a fallback from a denied normal read. Normal Shell navigation includes installed, authorized `active`, `read_only`, and `deprecated` modules. The latter two remain readable and visibly non-writable. Definite permission denial omits normal navigation; authorization uncertainty preserves an otherwise eligible item as disabled. -Missing state is a definite denial. An unavailable database read, malformed persisted state, -undeclared snapshot key, absent trusted tenant context, or other indeterminate check is a -sanitized typed unavailable failure. Core is transport-neutral. Public BFFs normally map definite -denial to declared `403` Problem Details and check unavailability to a retryable declared `503`. +Missing state is a definite denial. An unavailable database read, malformed persisted state, undeclared snapshot key, absent trusted tenant context, or other indeterminate check is a sanitized typed unavailable failure. Core is transport-neutral. Public BFFs normally map definite denial to declared `403` Problem Details and check unavailability to a retryable declared `503`. ## Request snapshots and query budget -At a trusted Shell, SSR, route, or BFF boundary, collect every descriptor the request may use, -deduplicate and sort its tenant module keys, read them in one indexed query, decode each state once, -and create an immutable request snapshot covering the exact key set. Every later decision is pure -in-memory evaluation. Undeclared keys fail closed without an implicit lookup. Empty and system-only -compositions perform no state query. +At a trusted Shell, SSR, route, or BFF boundary, collect every descriptor the request may use, deduplicate and sort its tenant module keys, read them in one indexed query, decode each state once, and create an immutable request snapshot covering the exact key set. Every later decision is pure in-memory evaluation. Undeclared keys fail closed without an implicit lookup. Empty and system-only compositions perform no state query. | Runtime composition | Module-state database work | | ---------------------------------------------------------------------- | ------------------------------------------------------------ | @@ -73,86 +49,32 @@ compositions perform no state query. | One business Action attempt | One early indexed read plus one transaction-aware recheck | | One Outbox Worker claim cycle | Zero additional queries beyond the existing claim query/join | -Snapshots are request-scoped, never process-global, browser-authoritative, TTL-based, or -distributed caches. The next independent request observes state again. A Shell decision does not -replace the independent BFF or Action check at the next trust boundary. Telemetry may contain batch -size, acquisition duration, snapshot reuse, scope, access, and outcome, but not payloads, -credentials, raw persistence causes, or private implementation identifiers. +Snapshots are request-scoped, never process-global, browser-authoritative, TTL-based, or distributed caches. The next independent request observes state again. A Shell decision does not replace the independent BFF or Action check at the next trust boundary. Telemetry may contain batch size, acquisition duration, snapshot reuse, scope, access, and outcome, but not payloads, credentials, raw persistence causes, or private implementation identifiers. ## Runtime ordering -Actions validate payload and trusted context, acquire/check state, and only then hash the request, -create an invocation, call SpiceDB, evaluate Policies, or resolve the handler. A business Action -rechecks `write` under the Core transaction after locking its invocation and tenant and before -collector creation or handler resolution. A pre-invocation denial creates no evidence. A locked -recheck failure rolls back and leaves the invocation open for existing retry semantics. The -explicit system entrypoint for `core.modules.change-tenant-module-state` remains recoverable. - -Outbox claim eligibility evaluates the consumer with `background` semantics inside the existing -atomic claim query. Producer state never authorizes a consumer. Ineligible or missing state leaves -delivery pending with no claim or attempt. Private Worker handler resolution follows a successful -eligible claim. - -The active immutable Application Composition revision is the runtime authority for which module and -Shell contributions may be resolved. One browser document remains pinned to one revision. Tenant -state may disable or revoke a module immediately, but it never selects another artifact and the -runtime never hot-swaps an already loaded container. - -Page/public-component composition uses the approved Shell lazy adapter: collect the full descriptor -set, prepare one snapshot, evaluate every load, then call loader thunks. Raw `loadRemote(...)` -strings, eager remote imports, remote SSR inside Shell/Core, and one state request per component are -forbidden. The current generated lazy registry is a compatibility bridge until the composition -loader is integrated. Module APIs need verified trusted tenant context and the server gateway; -write APIs delegate to registered Actions. - -The Shell first establishes exactly one trusted tenant and active legal entity. Composition then -uses one tenant-state batch and one module-permission batch. Every direct target independently -rechecks installation/reference, selected context, lifecycle, and permission before a generated -lazy registry is consulted. Only a `resolved` outcome may execute a remote thunk. - -Generated exact page routes may use safe canonical named-parameter templates such as -`/contacts/customers/:id/edit`; their owner and Shell filesystem routes use `[id]`. Dynamic templates are -not normal navigation items. The generated connector selects only its declared parameter names and -bounds each string value before calling the generic page loader. The generic loader keeps that plain -record separate from the resolved target and never adds it to the module contract, target-resolution -BFF input, trusted principal context, tenant/legal-entity context, module-state gate, or permission -decision. Only after authentication, legal-entity selection, exact page resolution, lifecycle and -permission success, approved lazy-client lookup, and successful remote loading may the Shell pass -the record to the owner component. The owner treats every value as untrusted business input and -validates it again before any domain read or Action. - -Search filters safe providers through the same context/state/module checks, then bulk-filters -ResourceRefs by resource permission. Core repeats result-level resource authorization before a -generated provider response can leave the receiving BFF. Zero providers/results is successful, -mixed provider success is partial `200`, and total provider failure is retryable. Resource detail -and timeline providers run only after catalog/type/state/module/resource gates and a fresh -audience-scoped assertion for each attempt. Media attachment remains unavailable even when declared -in a manifest until Codesmith generates and registers its Action; no provider mutation callback is -part of the read gateway. +Actions validate payload and trusted context, acquire/check state, and only then hash the request, create an invocation, call SpiceDB, evaluate Policies, or resolve the handler. A business Action rechecks `write` under the Core transaction after locking its invocation and tenant and before collector creation or handler resolution. A pre-invocation denial creates no evidence. A locked recheck failure rolls back and leaves the invocation open for existing retry semantics. The explicit system entrypoint for `core.modules.change-tenant-module-state` remains recoverable. + +Outbox claim eligibility evaluates the consumer with `background` semantics inside the existing atomic claim query. Producer state never authorizes a consumer. Ineligible or missing state leaves delivery pending with no claim or attempt. Private Worker handler resolution follows a successful eligible claim. + +The active immutable Application Composition revision is the runtime authority for which module and Shell contributions may be resolved. One browser document remains pinned to one revision. Tenant state may disable or revoke a module immediately, but it never selects another artifact and the runtime never hot-swaps an already loaded container. + +Page/public-component composition uses the approved Shell lazy adapter: collect the full descriptor set, prepare one snapshot, evaluate every load, then call loader thunks. Raw `loadRemote(...)` strings, eager remote imports, remote SSR inside Shell/Core, and one state request per component are forbidden. The current generated lazy registry is a compatibility bridge until the composition loader is integrated. Module APIs need verified trusted tenant context and the server gateway; write APIs delegate to registered Actions. + +The Shell first establishes exactly one trusted tenant and active legal entity. Composition then uses one tenant-state batch and one module-permission batch. Every direct target independently rechecks installation/reference, selected context, lifecycle, and permission before a generated lazy registry is consulted. Only a `resolved` outcome may execute a remote thunk. + +Generated exact page routes may use safe canonical named-parameter templates such as `/contacts/customers/:id/edit`; their owner and Shell filesystem routes use `[id]`. Dynamic templates are not normal navigation items. The generated connector selects only its declared parameter names and bounds each string value before calling the generic page loader. The generic loader keeps that plain record separate from the resolved target and never adds it to the module contract, target-resolution BFF input, trusted principal context, tenant/legal-entity context, module-state gate, or permission decision. Only after authentication, legal-entity selection, exact page resolution, lifecycle and permission success, approved lazy-client lookup, and successful remote loading may the Shell pass the record to the owner component. The owner treats every value as untrusted business input and validates it again before any domain read or Action. + +Search filters safe providers through the same context/state/module checks, then bulk-filters ResourceRefs by resource permission. Core repeats result-level resource authorization before a generated provider response can leave the receiving BFF. Zero providers/results is successful, mixed provider success is partial `200`, and total provider failure is retryable. Resource detail and timeline providers run only after catalog/type/state/module/resource gates and a fresh audience-scoped assertion for each attempt. Media attachment remains unavailable even when declared in a manifest until Codesmith generates and registers its Action; no provider mutation callback is part of the read gateway. ## Generator and registration enforcement -Codesmith output is the starting point for Actions, MicroVertical pages, and Workers and includes -the governed descriptor. Worker catalogs and route manifests preserve it. Vertical Runtime -Registration reserves private lazy bindings for public components, search, and reports beside -direct typed public descriptors. +Codesmith output is the starting point for Actions, MicroVertical pages, and Workers and includes the governed descriptor. Worker catalogs and route manifests preserve it. Vertical Runtime Registration reserves private lazy bindings for public components, search, and reports beside direct typed public descriptors. -API, public-component, search, and report business artifacts may not be introduced until an -approved generator can patch registration atomically and an approved gateway adapter exists. -Extend Codesmith first with disposable compile, overwrite, traversal, and no-partial-write tests. -Repository checks reject missing/mismatched registration, raw remote loads, private cross-vertical -imports, direct private handler access, and public exports of private implementations. +API, public-component, search, and report business artifacts may not be introduced until an approved generator can patch registration atomically and an approved gateway adapter exists. Extend Codesmith first with disposable compile, overwrite, traversal, and no-partial-write tests. Repository checks reject missing/mismatched registration, raw remote loads, private cross-vertical imports, direct private handler access, and public exports of private implementations. ## Authorization classification and inventory -Every descriptor must declare exactly one authorization classification. `public` is an intentional -authorization result, not the route-discovery `public` or `indexable` flag. Protected descriptors -use `authenticated_principal`, `context_permission` with a stable permission, `action_execution` -with a provisioning intent, `owner_local_background`, or API-only `capability_issuance` with its -credential kind. Role-incompatible and excess fields fail decoding and generation. - -`pnpm authorization:inventory:check` is the single repository derivation pass. It reconciles -generated route metadata with runtime descriptors, current Action registrations, Outbox workers, -and both Shell gateway issuers, then writes the non-secret deterministic artifact at -`.codex/reports/authorization/protected-entrypoints.json`. Duplicate, missing, stale, ambiguous, -or unclassified entries fail the check. +Every descriptor must declare exactly one authorization classification. `public` is an intentional authorization result, not the route-discovery `public` or `indexable` flag. Protected descriptors use `authenticated_principal`, `context_permission` with a stable permission, `action_execution` with a provisioning intent, `owner_local_background`, or API-only `capability_issuance` with its credential kind. Role-incompatible and excess fields fail decoding and generation. + +`pnpm authorization:inventory:check` is the single repository derivation pass. It reconciles generated route metadata with runtime descriptors, current Action registrations, Outbox workers, and both Shell gateway issuers, then writes the non-secret deterministic artifact at `.codex/reports/authorization/protected-entrypoints.json`. Duplicate, missing, stale, ambiguous, or unclassified entries fail the check. diff --git a/app/docs/architecture/MODULE_MANIFESTS.md b/app/docs/architecture/MODULE_MANIFESTS.md index 930851aa6..2ca6369ec 100644 --- a/app/docs/architecture/MODULE_MANIFESTS.md +++ b/app/docs/architecture/MODULE_MANIFESTS.md @@ -1,146 +1,65 @@ # OntOS Module Manifests -An OntOS Module Manifest is a validated capability contract. It is data, not an executable plugin -or an UltraModern deployment inventory. A V0 MicroVertical deployment currently admits exactly one -`business_module`. The schema vocabulary reserves `foundational_module` and `system_module`, but -current authored-manifest validation does not deploy them through this MicroVertical path. +An OntOS Module Manifest is a validated capability contract. It is data, not an executable plugin or an UltraModern deployment inventory. A V0 MicroVertical deployment currently admits exactly one `business_module`. The schema vocabulary reserves `foundational_module` and `system_module`, but current authored-manifest validation does not deploy them through this MicroVertical path. ## Identity -- `appId` is the hyphenated UltraModern topology identity of a deployment. It remains the Module - Federation remote identity, deployment lookup key, and exact Shell gateway JWT audience. -- `moduleId` is the stable dotted OntOS capability identity. It owns Actions, Policies, resources, - events, Outbox producers and consumers, and `core.tenant_module_states.module_key`. -- Target `implementationId` is the stable explicit identity of one catalogued executable - implementation of a `moduleId`, for example `standard` or `akros`. Compatible alternatives may - share a `moduleId`; different public semantics require a different `moduleId`. +- `appId` is the hyphenated UltraModern topology identity of a deployment. It remains the Module Federation remote identity, deployment lookup key, and exact Shell gateway JWT audience. +- `moduleId` is the stable dotted OntOS capability identity. It owns Actions, Policies, resources, events, Outbox producers and consumers, and `core.tenant_module_states.module_key`. +- Target `implementationId` is the stable explicit identity of one catalogued executable implementation of a `moduleId`, for example `standard` or `akros`. Compatible alternatives may share a `moduleId`; different public semantics require a different `moduleId`. - These identities may happen to contain equal text, but their roles never become interchangeable. For example, deployment `property-registry` may publish module `property.registry`. -The `implementationId` split is an accepted target contract from ADR-0017, not a claim about the -current schema. The current V0 manifest/catalog still admits one implementation per `moduleId` and -does not yet serialize or select `implementationId`. Until Codesmith, Effect Schemas, topology, -catalog validation, Customer Configuration, and tests are extended together, treat the sole -implementation as implicit `standard`; do not hand-add an unvalidated field or simulate selection -with customer branches, environment flags, duplicate `moduleId` values, or allowlist aliases. +The `implementationId` split is an accepted target contract from ADR-0017, not a claim about the current schema. The current V0 manifest/catalog still admits one implementation per `moduleId` and does not yet serialize or select `implementationId`. Until Codesmith, Effect Schemas, topology, catalog validation, Customer Configuration, and tests are extended together, treat the sole implementation as implicit `standard`; do not hand-add an unvalidated field or simulate selection with customer branches, environment flags, duplicate `moduleId` values, or allowlist aliases. ## Five layers -1. Generated topology and an environment overlay enumerate deployable services and their delivery - metadata. Topology is delivery inventory, not runtime composition authority. -2. The owner-authored `vertical.manifest.ts` contains an Effect Schema-validated value referencing - real typed Actions, Effect API values, Module Federation component values, payload Schemas, and - plain public descriptors. -3. The owning build emits a deterministic versioned JSON deployment contract, including only safe - semantic Shell contribution bindings. -4. A governed Application Composition revision pins the approved deployment contract and Module - Federation manifest for each installed module. Candidate validation rejects cross-module - contradictions before explicit promotion; a reachable service cannot install or promote itself. -5. `vertical.registration.ts` binds private executable Actions, pages, components, APIs, search, - reports, and workers for the owning process. Only safe descriptors may be projected into the - deployment contract. - -Tenant activation is separate from installation. Application Composition installs an approved -capability; tenant module state decides whether that installed module is active for a tenant and -never selects an artifact version. +1. Generated topology and an environment overlay enumerate deployable services and their delivery metadata. Topology is delivery inventory, not runtime composition authority. +2. The owner-authored `vertical.manifest.ts` contains an Effect Schema-validated value referencing real typed Actions, Effect API values, Module Federation component values, payload Schemas, and plain public descriptors. +3. The owning build emits a deterministic versioned JSON deployment contract, including only safe semantic Shell contribution bindings. +4. A governed Application Composition revision pins the approved deployment contract and Module Federation manifest for each installed module. Candidate validation rejects cross-module contradictions before explicit promotion; a reachable service cannot install or promote itself. +5. `vertical.registration.ts` binds private executable Actions, pages, components, APIs, search, reports, and workers for the owning process. Only safe descriptors may be projected into the deployment contract. + +Tenant activation is separate from installation. Application Composition installs an approved capability; tenant module state decides whether that installed module is active for a tenant and never selects an artifact version. ## Network and artifact contract -Every MicroVertical deployment serves the immutable document at -`/.well-known/ontos-module-manifest.json`. The document uses schema version `2`, media type -`application/json`, `Cache-Control: no-cache`, a strong build-marker ETag, and is limited to 1 MiB. -The publisher observes it with a bounded fetch and exact deployment `appId` matching, then supplies -that evidence to pure candidate validation before promotion. Contract URLs must use HTTPS except -loopback HTTP during development. Candidate validation requires HTTPS unless the publisher supplies -`environment: 'development'` in trusted evidence, never in the candidate itself. Omitted environment -evidence keeps HTTPS required for both deployment contracts and Federation manifests. -Credentials, fragments, unsafe schemes, and duplicate normalized -URLs are forbidden. The current Shell loader still uses the generated topology allowlist as a -compatibility bridge until Application Composition publication and runtime loading are wired in -follow-up work. - -The document may describe identity, activation, public Actions/API/components, -resources, public events, search, reports, and schema-free Outbox subscriptions. It must never -contain a function, Effect program, React component, handler, Policy, migration, executable route -definition, repository, database metadata, source path, import/export specifier, fixture, test, -secret, or arbitrary private runtime value. The sole routing exception is the normalized -root-relative `routePath` on a governed Shell page contribution. It identifies that contribution's -canonical authenticated Shell location; it is not an owner route definition or remote source. - -Shell contributions bind stable navigation/page, public-component, API-backed resource detail and -timeline, search, report, and media targets to descriptors already owned by the same manifest. -They may contain semantic keys, ordering, grouping metadata, and the page contribution's canonical -root-relative `routePath`, but never absolute URLs, import specifiers, remote strings, functions, -schemas, executable routes, or source paths. Candidate promotion validates the complete proposed -composition and rejects it when one binding is missing, duplicated, cross-owned, or role/access -incompatible. Runtime discovery then settles each promoted deployment independently: an invalid -deployment is reported as incompatible without removing unrelated healthy deployments. +Every MicroVertical deployment serves the immutable document at `/.well-known/ontos-module-manifest.json`. The document uses schema version `2`, media type `application/json`, `Cache-Control: no-cache`, a strong build-marker ETag, and is limited to 1 MiB. The publisher observes it with a bounded fetch and exact deployment `appId` matching, then supplies that evidence to pure candidate validation before promotion. Contract URLs must use HTTPS except loopback HTTP during development. Candidate validation requires HTTPS unless the publisher supplies `environment: 'development'` in trusted evidence, never in the candidate itself. Omitted environment evidence keeps HTTPS required for both deployment contracts and Federation manifests. Credentials, fragments, unsafe schemes, and duplicate normalized URLs are forbidden. The current Shell loader still uses the generated topology allowlist as a compatibility bridge until Application Composition publication and runtime loading are wired in follow-up work. + +The document may describe identity, activation, public Actions/API/components, resources, public events, search, reports, and schema-free Outbox subscriptions. It must never contain a function, Effect program, React component, handler, Policy, migration, executable route definition, repository, database metadata, source path, import/export specifier, fixture, test, secret, or arbitrary private runtime value. The sole routing exception is the normalized root-relative `routePath` on a governed Shell page contribution. It identifies that contribution's canonical authenticated Shell location; it is not an owner route definition or remote source. + +Shell contributions bind stable navigation/page, public-component, API-backed resource detail and timeline, search, report, and media targets to descriptors already owned by the same manifest. They may contain semantic keys, ordering, grouping metadata, and the page contribution's canonical root-relative `routePath`, but never absolute URLs, import specifiers, remote strings, functions, schemas, executable routes, or source paths. Candidate promotion validates the complete proposed composition and rejects it when one binding is missing, duplicated, cross-owned, or role/access incompatible. Runtime discovery then settles each promoted deployment independently: an invalid deployment is reported as incompatible without removing unrelated healthy deployments. ## Import and execution boundaries -Shell/Core and ordinary MicroVertical consumers must not statically import another deployment's -`vertical.manifest.ts`, `vertical.registration.ts`, or private source. Synchronous calls use the -provider's generated Effect BFF client, public components use generated Module Federation wrappers, -and asynchronous communication uses published schema-only Outbox contracts. Executable Actions, -Policies, workers, migrations, routes, repositories, search implementations, and report -implementations stay owner-local. - -The strict candidate catalog rejects unsupported schema versions, deployment/manifest identity -mismatch, duplicate app or module IDs, mismatched Outbox consumer ownership or entrypoints, and -duplicate worker keys before promotion. Runtime discovery excludes every claimant involved in a -global identity or worker-key contradiction while preserving unrelated healthy deployments. Each -installed deployment remains visible with an authoritative `disabled` or `revoked` state, or a -typed transient `timeout`, `unavailable`, or `incompatible` diagnostic. Authoritative state takes -precedence over a stale fetched contract. A degraded runtime result is never cached; discovery is -retried, and only a fully healthy result is cached for its immutable allowlist/composition revision. -No persistent last-known-good contract is selected automatically. A subscription may name a -producer that is not installed; it remains dormant until matching messages can exist. - -Required Core capabilities and the Shell contribution ABI are explicit versioned compatibility -claims in Application Composition. External system readiness and module-owned setup remain private -implementation concerns and are not generic activation gates. +Shell/Core and ordinary MicroVertical consumers must not statically import another deployment's `vertical.manifest.ts`, `vertical.registration.ts`, or private source. Synchronous calls use the provider's generated Effect BFF client, public components use generated Module Federation wrappers, and asynchronous communication uses published schema-only Outbox contracts. Executable Actions, Policies, workers, migrations, routes, repositories, search implementations, and report implementations stay owner-local. + +The strict candidate catalog rejects unsupported schema versions, deployment/manifest identity mismatch, duplicate app or module IDs, mismatched Outbox consumer ownership or entrypoints, and duplicate worker keys before promotion. Runtime discovery excludes every claimant involved in a global identity or worker-key contradiction while preserving unrelated healthy deployments. Each installed deployment remains visible with an authoritative `disabled` or `revoked` state, or a typed transient `timeout`, `unavailable`, or `incompatible` diagnostic. Authoritative state takes precedence over a stale fetched contract. A degraded runtime result is never cached; discovery is retried, and only a fully healthy result is cached for its immutable allowlist/composition revision. No persistent last-known-good contract is selected automatically. A subscription may name a producer that is not installed; it remains dormant until matching messages can exist. + +Required Core capabilities and the Shell contribution ABI are explicit versioned compatibility claims in Application Composition. External system readiness and module-owned setup remain private implementation concerns and are not generic activation gates. ## Application Composition contract -The provider-neutral Effect Schema and pure candidate validator are defined by -[ADR-0020](../../../docs/adr/0020-governed-application-composition.md). Publication, promotion, and -live Shell loading are separate follow-up slices; this contract alone does not change runtime -loading. +The provider-neutral Effect Schema and pure candidate validator are defined by [ADR-0020](../../../docs/adr/0020-governed-application-composition.md). Publication, promotion, and live Shell loading are separate follow-up slices; this contract alone does not change runtime loading. -The validator checks the revision's format; it does not assign or reserve revision identities. -The publisher in [#374](https://github.com/TechsioCZ/ontos/issues/374) must bind each revision to -immutable canonical bytes and reject conflicting publication before advancing the active pointer. +The validator checks the revision's format; it does not assign or reserve revision identities. The publisher in [#374](https://github.com/TechsioCZ/ontos/issues/374) must bind each revision to immutable canonical bytes and reject conflicting publication before advancing the active pointer. -A continuously delivered Application Composition owns a dependency-closed DAG of Foundational and -Business Module Contract Identities and their permitted implementations. Core validates that graph -without learning its business meaning. Installation, activation, and entrypoint execution preserve -dependency closure: a module activates only when one selected implementation and every required -dependency are installed, compatible, healthy, and active. Customer Configurations select only -modules and explicit implementations permitted by the composition. +A continuously delivered Application Composition owns a dependency-closed DAG of Foundational and Business Module Contract Identities and their permitted implementations. Core validates that graph without learning its business meaning. Installation, activation, and entrypoint execution preserve dependency closure: a module activates only when one selected implementation and every required dependency are installed, compatible, healthy, and active. Customer Configurations select only modules and explicit implementations permitted by the composition. -Each composition entry carries exact deployment identity, immutable artifact URLs plus digests, -public-contract identity, allowed Shell contributions, required Core capabilities, Shell ABI, and -shared-singleton requirements. The composition rejects missing, duplicate, incompatible, or -unobserved identities before promotion. +Each composition entry carries exact deployment identity, immutable artifact URLs plus digests, public-contract identity, allowed Shell contributions, required Core capabilities, Shell ABI, and shared-singleton requirements. The composition rejects missing, duplicate, incompatible, or unobserved identities before promotion. -Dependency enforcement never authorizes private imports, shared repositories, shared business -transactions, or direct table access. Typed API, public event, and Outbox communication preserves -the deployment seams. A dependency outage produces an explicit unavailable/degraded result for the -affected entrypoint without rewriting persisted module states; unrelated modules remain operable. +Dependency enforcement never authorizes private imports, shared repositories, shared business transactions, or direct table access. Typed API, public event, and Outbox communication preserves the deployment seams. A dependency outage produces an explicit unavailable/degraded result for the affected entrypoint without rewriting persisted module states; unrelated modules remain operable. ## Generator order -After UltraModern creates a topology-backed vertical, run the module-contract Codesmith generator -before any business generator: +After UltraModern creates a topology-backed vertical, run the module-contract Codesmith generator before any business generator: ```bash mise exec -- pnpm scaffold:module-contract -- --vertical property-registry --module property.registry ``` -All later business generators read the generated module-ID marker and patch only explicit -generator-owned slots. They fail without a consistent package, topology entry, manifest, and private -registration. +All later business generators read the generated module-ID marker and patch only explicit generator-owned slots. They fail without a consistent package, topology entry, manifest, and private registration. Use the category generator before authoring each supported public artifact: @@ -152,32 +71,12 @@ mise exec -- pnpm scaffold:search-provider -- --vertical property-registry --nam mise exec -- pnpm scaffold:report -- --vertical property-registry --name unit-inventory --resource unit --authorization context_permission --permission resource.read ``` -The page name is a stable lower-kebab identity, while `--url` is an optional complete -root-relative canonical-path override. When `--url` is omitted, Codesmith derives -`//` from the validated MicroVertical slug. For example, -`--vertical contacts --page customers` produces canonical `/contacts/customers`; the locale-aware Shell -router exposes it as `/cs/contacts/customers` or `/en/contacts/customers`. Never include a locale prefix in -`--url`. A generated page is private and non-indexable, contains only its localized title, and is -loaded only after the authenticated Shell/Core gateway resolves that exact governed page -entrypoint. Private metadata alone is not an authentication mechanism. - -An explicit page URL may mix lowercase kebab-case static segments with unique named parameter -segments such as `/contacts/customers/:id/edit`. A parameter name starts with a lowercase letter and -continues with letters or digits. Optional, repeated, wildcard, catch-all, encoded, query, fragment, -origin, empty, dot, trailing-slash, and locale-prefixed forms are invalid. The serialized `routePath` -retains the canonical `:id` spelling as bounded plain data; Codesmith maps it deterministically to -the `[id]` filesystem segment used by both owner and Shell routers. Templates that differ only by a -parameter name at one position, and static/dynamic siblings, are routing collisions. - -Dynamic page contributions remain in `pages`, component ownership, private registration, Module -Federation exposure, and the generated Shell lazy-client allowlist. They do not create ordinary -`navigation` contributions because a route template is not a usable destination. The manifest never -contains route values, loader functions, imports, private source paths, or executable matching code. +The page name is a stable lower-kebab identity, while `--url` is an optional complete root-relative canonical-path override. When `--url` is omitted, Codesmith derives `//` from the validated MicroVertical slug. For example, `--vertical contacts --page customers` produces canonical `/contacts/customers`; the locale-aware Shell router exposes it as `/cs/contacts/customers` or `/en/contacts/customers`. Never include a locale prefix in `--url`. A generated page is private and non-indexable, contains only its localized title, and is loaded only after the authenticated Shell/Core gateway resolves that exact governed page entrypoint. Private metadata alone is not an authentication mechanism. + +An explicit page URL may mix lowercase kebab-case static segments with unique named parameter segments such as `/contacts/customers/:id/edit`. A parameter name starts with a lowercase letter and continues with letters or digits. Optional, repeated, wildcard, catch-all, encoded, query, fragment, origin, empty, dot, trailing-slash, and locale-prefixed forms are invalid. The serialized `routePath` retains the canonical `:id` spelling as bounded plain data; Codesmith maps it deterministically to the `[id]` filesystem segment used by both owner and Shell routers. Templates that differ only by a parameter name at one position, and static/dynamic siblings, are routing collisions. + +Dynamic page contributions remain in `pages`, component ownership, private registration, Module Federation exposure, and the generated Shell lazy-client allowlist. They do not create ordinary `navigation` contributions because a route template is not a usable destination. The manifest never contains route values, loader functions, imports, private source paths, or executable matching code. ## Documentation authority -Repository-level product semantics and the app-local implementation contract agree that OntOS -Business Modules preserve independently deployable MicroVertical seams. Proposed historical ADRs -remain decision history and do not override this app-local implementation rule. If future product -vocabulary and implementation guidance diverge, update both explicitly rather than silently -selecting one generation. +Repository-level product semantics and the app-local implementation contract agree that OntOS Business Modules preserve independently deployable MicroVertical seams. Proposed historical ADRs remain decision history and do not override this app-local implementation rule. If future product vocabulary and implementation guidance diverge, update both explicitly rather than silently selecting one generation. diff --git a/app/docs/architecture/OUTBOX_WORKERS.md b/app/docs/architecture/OUTBOX_WORKERS.md index 8a2ecafdf..7592b0a30 100644 --- a/app/docs/architecture/OUTBOX_WORKERS.md +++ b/app/docs/architecture/OUTBOX_WORKERS.md @@ -4,56 +4,28 @@ Outbox Workers are module-owned asynchronous entrypoints for committed Outbox Me ## Process and dependency ownership -Each consuming MicroVertical runs its workers in a dedicated Node process. The process imports only -that MicroVertical's generated server-side registry, while Core supplies the generic polling and -delivery runtime. Worker code therefore stays physically inside its owner and may require the -owner's Effect repositories and services. The owner composes those requirements in its worker -layer, using the same server-side capabilities available to its Actions; Core never imports or -publishes the private implementations. - -Matching uses the complete schema-free subscription snapshot from the validated installed-module -deployment catalog. Core's matcher receives that complete snapshot explicitly and creates -deliveries before independently deployed owner processes claim them. A worker process holds only -its own private registrations and must prove they match its deployment descriptors. No generator -scans unrelated vertical source or rewrites a shared source-time subscription registry. This split -prevents the first polling process from marking a message with only its local handlers and starving -other independently hosted consumers. - -`scaffold:outbox-worker -- --authorization owner_local_background` creates the owner-local process host and adds `dev:worker` and -`worker:start` scripts to the consumer package when needed. Run one consumer with -`mise exec -- pnpm --filter @app/ worker:start`; the normal `mise exec -- pnpm dev` -command starts every generated worker host alongside the applications. Each process performs one -cycle immediately and then polls every 1,000 ms. These optional scalar environment values may -override the safe defaults for a deployment: +Each consuming MicroVertical runs its workers in a dedicated Node process. The process imports only that MicroVertical's generated server-side registry, while Core supplies the generic polling and delivery runtime. Worker code therefore stays physically inside its owner and may require the owner's Effect repositories and services. The owner composes those requirements in its worker layer, using the same server-side capabilities available to its Actions; Core never imports or publishes the private implementations. + +Matching uses the complete schema-free subscription snapshot from the validated installed-module deployment catalog. Core's matcher receives that complete snapshot explicitly and creates deliveries before independently deployed owner processes claim them. A worker process holds only its own private registrations and must prove they match its deployment descriptors. No generator scans unrelated vertical source or rewrites a shared source-time subscription registry. This split prevents the first polling process from marking a message with only its local handlers and starving other independently hosted consumers. + +`scaffold:outbox-worker -- --authorization owner_local_background` creates the owner-local process host and adds `dev:worker` and `worker:start` scripts to the consumer package when needed. Run one consumer with `mise exec -- pnpm --filter @app/ worker:start`; the normal `mise exec -- pnpm dev` command starts every generated worker host alongside the applications. Each process performs one cycle immediately and then polls every 1,000 ms. These optional scalar environment values may override the safe defaults for a deployment: - `OUTBOX_WORKER_POLL_INTERVAL_MS` — interval from 10 through 3,600,000 ms; default `1000`. -- `OUTBOX_WORKER_MAX_DELIVERIES` — maximum deliveries claimed per cycle from 1 through 1,000; - default `100`. -- `OUTBOX_WORKER_CLAIM_OWNER` — stable process identity up to 200 characters; the process derives - one from the MicroVertical, process ID, and a random process nonce by default. +- `OUTBOX_WORKER_MAX_DELIVERIES` — maximum deliveries claimed per cycle from 1 through 1,000; default `100`. +- `OUTBOX_WORKER_CLAIM_OWNER` — stable process identity up to 200 characters; the process derives one from the MicroVertical, process ID, and a random process nonce by default. -Invalid values fail process startup instead of silently selecting an unsafe cadence. `SIGINT` and -`SIGTERM` interrupt the polling fiber and release the scoped PostgreSQL pool. Multiple instances -are safe because matching is idempotent and delivery claiming is lease-protected; handler effects -remain at-least-once and must still be idempotent. +Invalid values fail process startup instead of silently selecting an unsafe cadence. `SIGINT` and `SIGTERM` interrupt the polling fiber and release the scoped PostgreSQL pool. Multiple instances are safe because matching is idempotent and delivery claiming is lease-protected; handler effects remain at-least-once and must still be idempotent. ## Published Contract Boundary - A producer publishes one schema-only package subpath per exact topic. It contains the Effect payload schema, producer module key, and topic constant—never an Action, factory, repository, handler, transport, database client, or BFF implementation. -- Producer, consumer, and worker ownership use dotted OntOS module IDs. Deployment app IDs are not - Outbox business identities. +- Producer, consumer, and worker ownership use dotted OntOS module IDs. Deployment app IDs are not Outbox business identities. - A consumer imports that published subpath and its own Core descriptor API. It never deep-imports another MicroVertical's source or executes another MicroVertical's implementation. - Generate producer messages with `mise exec -- pnpm scaffold:outbox-message` and consumers with `mise exec -- pnpm scaffold:outbox-worker -- --authorization owner_local_background`. Generated worker registries stay server-side and are not Module Federation or BFF surfaces. ## Immutable Matching -An Outbox Message is an immutable broadcast source linked to one committed Domain Event. At first -observation, Core matches the message against the complete installed subscription catalog by exact -producer module and exact topic. In one transaction it creates at most one delivery per message -and worker and sets `matched_at`, including when no workers match. Re-observation is idempotent. -Deploying a new worker does not backfill already matched messages in V0. Each process also verifies -that every owner-local registration has an identical catalog entry before it can match or claim -work. +An Outbox Message is an immutable broadcast source linked to one committed Domain Event. At first observation, Core matches the message against the complete installed subscription catalog by exact producer module and exact topic. In one transaction it creates at most one delivery per message and worker and sets `matched_at`, including when no workers match. Re-observation is idempotent. Deploying a new worker does not backfill already matched messages in V0. Each process also verifies that every owner-local registration has an identical catalog entry before it can match or claim work. Each Worker declares a structured tenant `worker`/`background` entrypoint governed by [Module Entrypoints and Tenant State](./MODULE_ENTRYPOINTS.md). Claim eligibility uses the central matrix inside the existing atomic claim query. Only `active` consumers are eligible; every other or missing state leaves work unattempted and retryable. The producer's current module state never authorizes the consumer entrypoint, and handler resolution occurs only after an eligible claim. @@ -81,8 +53,4 @@ Checkpoint advancement must not skip an earlier matching delivery in `pending`, ## Authorization inventory -Generated workers must declare `owner_local_background`; no other authorization class is valid for -the `worker` role. The inventory checker reconciles the registered worker, its owner deployment, -and its generated descriptor. Worker execution remains independently gated by the active tenant -module state and exact owner-local registration. Report-only rollout never turns a missing owner, -disabled module, unavailable state check, or foreign deployment into an allow. +Generated workers must declare `owner_local_background`; no other authorization class is valid for the `worker` role. The inventory checker reconciles the registered worker, its owner deployment, and its generated descriptor. Worker execution remains independently gated by the active tenant module state and exact owner-local registration. Report-only rollout never turns a missing owner, disabled module, unavailable state check, or foreign deployment into an allow. diff --git a/app/docs/architecture/PARTY_REGISTRY.md b/app/docs/architecture/PARTY_REGISTRY.md index c74b20a9d..b02799333 100644 --- a/app/docs/architecture/PARTY_REGISTRY.md +++ b/app/docs/architecture/PARTY_REGISTRY.md @@ -1,9 +1,6 @@ # Party Registry -This document defines current implementation rules for the `party.registry` Foundational Module. The -durable decision is [ADR-0018](../../../docs/adr/0018-party-registry-operational-boundaries.md). -General Action, governed Read, database, ResourceRef, event, outbox, authorization, and -MicroVertical rules still apply. +This document defines current implementation rules for the `party.registry` Foundational Module. The durable decision is [ADR-0018](../../../docs/adr/0018-party-registry-operational-boundaries.md). General Action, governed Read, database, ResourceRef, event, outbox, authorization, and MicroVertical rules still apply. ## Ownership @@ -49,11 +46,9 @@ party.registry/ └── PartyAlias ``` -Every ResourceRef carries Tenant, module identity, resource type, and resource identity. Public -contracts never accept a raw identifier when a ResourceRef is required. +Every ResourceRef carries Tenant, module identity, resource type, and resource identity. Public contracts never accept a raw identifier when a ResourceRef is required. -`PartyRef` and `LegalEntityRef` are different types. A handler must not construct one from the other. -A public contract that can refer to either uses a tagged union: +`PartyRef` and `LegalEntityRef` are different types. A handler must not construct one from the other. A public contract that can refer to either uses a tagged union: ```ts type OrganizationSubjectRef = @@ -61,13 +56,11 @@ type OrganizationSubjectRef = | { readonly kind: 'legal_entity'; readonly legalEntity: LegalEntityRef }; ``` -Do not add this union to a contract that only needs one side. Counterparty always uses a PartyRef and -a LegalEntityRef explicitly. +Do not add this union to a contract that only needs one side. Counterparty always uses a PartyRef and a LegalEntityRef explicitly. ## Action and Read scope -All state changes use declared Actions and require idempotency unless the general Action rules -explicitly justify otherwise. +All state changes use declared Actions and require idempotency unless the general Action rules explicitly justify otherwise. | Capability | Legal Entity scope | Permission target | Required authority | | ------------------------------------- | ------------------ | ---------------------------- | ----------------------------------- | @@ -81,19 +74,13 @@ explicitly justify otherwise. | Counterparty create/read/search | required | Legal Entity or Counterparty | read/manage that commercial context | | Counterparty Role add/end | required | Counterparty | manage that commercial context | -`legalEntityScope: optional` means trusted session context may contain a selected Legal Entity. It -does not scope the Party fact or grant authority. The Action payload never supplies or overrides -trusted Tenant or Legal Entity context. +`legalEntityScope: optional` means trusted session context may contain a selected Legal Entity. It does not scope the Party fact or grant authority. The Action payload never supplies or overrides trusted Tenant or Legal Entity context. -A caller authorized only for one Legal Entity does not receive tenant-wide Party Search. It reaches a -Party through an authorized Counterparty Read and receives the explicitly declared minimum Party -projection required by that contract. Adding a field to that projection is an authorization and -privacy change, not a serializer convenience. +A caller authorized only for one Legal Entity does not receive tenant-wide Party Search. It reaches a Party through an authorized Counterparty Read and receives the explicitly declared minimum Party projection required by that contract. Adding a field to that projection is an authorization and privacy change, not a serializer convenience. ## Canonical persistence -Use owner-local PostgreSQL tables. No Party invariant depends on Core Search, Neo4j, a cache, or a -consumer database. +Use owner-local PostgreSQL tables. No Party invariant depends on Core Search, Neo4j, a cache, or a consumer database. The initial logical table set is: @@ -112,12 +99,9 @@ party.party_merges party.party_aliases ``` -Exact names may follow the repository's generated naming rules. The semantic separation is -required even when an implementation co-locates supporting records. +Exact names may follow the repository's generated naming rules. The semantic separation is required even when an implementation co-locates supporting records. -Every tenant-owned table has an explicit Tenant column, a tenant-qualified unique key for its -Resource identity, enabled and forced RLS, owner-local foreign keys, and no cross-MicroVertical -foreign key. +Every tenant-owned table has an explicit Tenant column, a tenant-qualified unique key for its Resource identity, enabled and forced RLS, owner-local foreign keys, and no cross-MicroVertical foreign key. Required uniqueness invariants include: @@ -130,14 +114,11 @@ Strong identifier claim unique (tenant_id, identifier_type_key, namespace, no Current preferred contact type/purpose-specific partial uniqueness where the type allows one ``` -A `party_identifier_claims` row exists only when the Identifier Type and verification/provenance -state permit an exclusive identity claim. An unverified or non-exclusive identifier assertion may -exist without a claim and cannot create an automatic MATCHED outcome. +A `party_identifier_claims` row exists only when the Identifier Type and verification/provenance state permit an exclusive identity claim. An unverified or non-exclusive identifier assertion may exist without a claim and cannot create an automatic MATCHED outcome. ## Party Candidate -A Party Candidate is an immutable request snapshot used before an existing or new Party is chosen. -It may contain: +A Party Candidate is an immutable request snapshot used before an existing or new Party is chosen. It may contain: - asserted Party Type or UNRESOLVED; - names or labels with provenance; @@ -147,12 +128,9 @@ It may contain: - Evidence Artifact references; - caller intent and policy version. -A Party Candidate is not a Party and has no Party ID. A Source Record Reference identifies a record -inside one External Business System or migration dataset. It is neither an Official Identifier nor -evidence that a new real-world subject exists. +A Party Candidate is not a Party and has no Party ID. A Source Record Reference identifies a record inside one External Business System or migration dataset. It is neither an Official Identifier nor evidence that a new real-world subject exists. -When matching is ambiguous, the Duplicate Candidate case stores the canonical decoded Candidate -snapshot and the evaluated evidence. It does not retain raw secrets or unbounded provider payloads. +When matching is ambiguous, the Duplicate Candidate case stores the canonical decoded Candidate snapshot and the evaluated evidence. It does not retain raw secrets or unbounded provider payloads. ## Atomic Party create @@ -202,21 +180,11 @@ PartyCreate(candidate) Domain Events, linked Outbox Messages, and invocation success atomically ``` -CoreSDK opens the one canonical transaction and constructs an owner-local -`PartyIdentifierClaimService` bound to it. Before reading claims, the service sorts every normalized -claim key and acquires transaction-scoped database locks in that deterministic order. An equivalent -conflict-tolerant single-transaction primitive is acceptable only when it provides the same observable -serialization. The service then reads or attaches claims without exposing a database executor and -without allowing the handler to begin, commit, roll back, or retry a transaction. +CoreSDK opens the one canonical transaction and constructs an owner-local `PartyIdentifierClaimService` bound to it. Before reading claims, the service sorts every normalized claim key and acquires transaction-scoped database locks in that deterministic order. An equivalent conflict-tolerant single-transaction primitive is acceptable only when it provides the same observable serialization. The service then reads or attaches claims without exposing a database executor and without allowing the handler to begin, commit, roll back, or retry a transaction. -A competing Action waits for the same claim-key locks and then observes the committed owner before it -decides. A uniqueness conflict after those locks indicates a broken invariant, not an instruction for -the business handler to open a fresh transaction. Database unavailability or an indeterminate commit -follows the existing Core-owned Action reconciliation lifecycle. +A competing Action waits for the same claim-key locks and then observes the committed owner before it decides. A uniqueness conflict after those locks indicates a broken invariant, not an instruction for the business handler to open a fresh transaction. Database unavailability or an indeterminate commit follows the existing Core-owned Action reconciliation lifecycle. -A preflight fuzzy search may improve user experience, but the transaction repeats every invariant -read against the Party Registry operational store. A result from Core Search is never sufficient to -create, match, or reject a Party. +A preflight fuzzy search may improve user experience, but the transaction repeats every invariant read against the Party Registry operational store. A result from Core Search is never sufficient to create, match, or reject a Party. The create Action result is: @@ -226,35 +194,17 @@ MATCHED_EXISTING(partyRef, decisionRef) AMBIGUOUS(caseRef, decisionRef) ``` -Insufficient evidence that the Candidate represents one real-world subject is a typed domain -rejection and persists no Party Match Decision or Duplicate Candidate case. Identifier conflicts -that require durable review produce the committed `AMBIGUOUS` result instead of returning an Action -failure whose transaction would roll back the case. +Insufficient evidence that the Candidate represents one real-world subject is a typed domain rejection and persists no Party Match Decision or Duplicate Candidate case. Identifier conflicts that require durable review produce the committed `AMBIGUOUS` result instead of returning an Action failure whose transaction would roll back the case. -`NO_MATCH` is an internal matching result, not proof that an insert will remain safe after the -transaction begins. +`NO_MATCH` is an internal matching result, not proof that an insert will remain safe after the transaction begins. ### Commit, publication, and recovery -`PartyMatchDecision` is the durable result reference for Party Create. It records the Action -Invocation, Candidate fingerprint, Match Rule version, operation, matching outcome, and exact -`committedCreateOutcome` for CREATE/REVIEW_CREATE. CREATE records CREATED, MATCHED_EXISTING or -AMBIGUOUS independently of matching's MATCHED vocabulary. Create has exactly one of `partyRef` or -`caseRef`; matching-only NO_MATCH has neither. REVIEW_MATCH retains MATCHED. Legacy rows are -explicitly LEGACY: do not infer whether an old MATCHED row came from Create or matching. The decision commits in the same transaction as the resulting Party or Duplicate -Candidate case. - -No search descriptor, projection update, consumer notification, or external publication occurs -before commit. The successful Action commits its Party-owned state, Audit and Data Access evidence, -Domain Events, linked Outbox Messages, Party Match Decision, and invocation success marker -atomically. Outbox Workers publish projections and integration effects only after that commit. - -If the database acknowledgement is indeterminate, the caller uses the standard Action commit -resolution operation with the Action Invocation identity. A `succeeded` invocation proves commit; -the caller then performs a governed Party Match Decision Read by Action Invocation or caller -idempotency identity to recover the same `partyRef`, `caseRef`, and outcome. It never reruns create -because Party Search did or did not return a result. A repeated request with the same idempotency key -and request hash must resolve to the same committed decision without executing the handler again. +`PartyMatchDecision` is the durable result reference for Party Create. It records the Action Invocation, Candidate fingerprint, Match Rule version, operation, matching outcome, and exact `committedCreateOutcome` for CREATE/REVIEW_CREATE. CREATE records CREATED, MATCHED_EXISTING or AMBIGUOUS independently of matching's MATCHED vocabulary. Create has exactly one of `partyRef` or `caseRef`; matching-only NO_MATCH has neither. REVIEW_MATCH retains MATCHED. Legacy rows are explicitly LEGACY: do not infer whether an old MATCHED row came from Create or matching. The decision commits in the same transaction as the resulting Party or Duplicate Candidate case. + +No search descriptor, projection update, consumer notification, or external publication occurs before commit. The successful Action commits its Party-owned state, Audit and Data Access evidence, Domain Events, linked Outbox Messages, Party Match Decision, and invocation success marker atomically. Outbox Workers publish projections and integration effects only after that commit. + +If the database acknowledgement is indeterminate, the caller uses the standard Action commit resolution operation with the Action Invocation identity. A `succeeded` invocation proves commit; the caller then performs a governed Party Match Decision Read by Action Invocation or caller idempotency identity to recover the same `partyRef`, `caseRef`, and outcome. It never reruns create because Party Search did or did not return a result. A repeated request with the same idempotency key and request hash must resolve to the same committed decision without executing the handler again. ## Party assertion semantics @@ -279,15 +229,12 @@ Rules: 1. `recordedAt` never substitutes for `validFrom`. 2. Ending a fact does not delete its assertion. 3. Correction retracts or supersedes a wrong assertion; it is not an in-place value overwrite. -4. A legitimate new real-world value ends the old period and adds a new assertion where the fact - type is historical. +4. A legitimate new real-world value ends the old period and adds a new assertion where the fact type is historical. 5. Formal validity, authoritative verification, freshness, and matching strength remain separate. -6. Raw provider payloads stay with the adapter or Evidence Artifact boundary. Party Registry stores - bounded normalized evidence and references. +6. Raw provider payloads stay with the adapter or Evidence Artifact boundary. Party Registry stores bounded normalized evidence and references. 7. A current projection is derived from accepted assertion state and effective time. -Use the same vocabulary in code, schemas, events, and user-facing audit explanations. Avoid generic -`updated`, `removed`, or `verified` fields whose exact meaning cannot be determined from the type. +Use the same vocabulary in code, schemas, events, and user-facing audit explanations. Avoid generic `updated`, `removed`, or `verified` fields whose exact meaning cannot be determined from the type. ## Party Type @@ -299,28 +246,11 @@ ORGANIZATION UNRESOLVED ``` -UNRESOLVED means one evidenced real-world subject whose person-versus-organization type is unknown. -It is not an import staging row, anonymous Principal, missing-name placeholder, or Duplicate -Candidate case. - -Subject eligibility (`party-concrete-subject.v1`) and type support (`party-subject-type.v1`) -are independent versioned decisions. Every Create and Matching Candidate, type enrichment and type -Correction must include bounded typed subject evidence. The supported V1 manual boundary is an -explicit ACTOR_ATTESTATION made through an authorized owner Action: DIRECT_INTERACTION or -REVIEWED_DOCUMENT, a subject key, evidence reference, statement, and observed subject meaning. -The accepting Action supplies the authenticated Principal and invocation; caller provenance labels -never establish a registry authority. A reference only locates supporting material; arbitrary reference -spelling is allowed. Document/registry records without such an attestation remain unsupported as -standalone subject proof until their owner provides an authorized resolver. No Evidence Artifact -service is assumed. ARES prefill itself supplies no manual attestation or authoritative type evidence. - -Eligibility requires evidence of exactly one concrete subject. Technical records and managed Legal -Entities are rejected. PERSON requires an observation of a human, ORGANIZATION of an external -organization; contradictory observations are rejected. CONCRETE_SUBJECT supports UNRESOLVED only. -Neither display-name length nor an official identifier establishes existence or type. Evidence -meaning and both rule versions participate in the Candidate fingerprint and durable evaluation; -accepted assertions retain the evaluation with the trusted actor. Reviewer selection cannot waive -these thresholds. Historical cases lacking this evidence require material new evidence. +UNRESOLVED means one evidenced real-world subject whose person-versus-organization type is unknown. It is not an import staging row, anonymous Principal, missing-name placeholder, or Duplicate Candidate case. + +Subject eligibility (`party-concrete-subject.v1`) and type support (`party-subject-type.v1`) are independent versioned decisions. Every Create and Matching Candidate, type enrichment and type Correction must include bounded typed subject evidence. The supported V1 manual boundary is an explicit ACTOR_ATTESTATION made through an authorized owner Action: DIRECT_INTERACTION or REVIEWED_DOCUMENT, a subject key, evidence reference, statement, and observed subject meaning. The accepting Action supplies the authenticated Principal and invocation; caller provenance labels never establish a registry authority. A reference only locates supporting material; arbitrary reference spelling is allowed. Document/registry records without such an attestation remain unsupported as standalone subject proof until their owner provides an authorized resolver. No Evidence Artifact service is assumed. ARES prefill itself supplies no manual attestation or authoritative type evidence. + +Eligibility requires evidence of exactly one concrete subject. Technical records and managed Legal Entities are rejected. PERSON requires an observation of a human, ORGANIZATION of an external organization; contradictory observations are rejected. CONCRETE_SUBJECT supports UNRESOLVED only. Neither display-name length nor an official identifier establishes existence or type. Evidence meaning and both rule versions participate in the Candidate fingerprint and durable evaluation; accepted assertions retain the evaluation with the trusted actor. Reviewer selection cannot waive these thresholds. Historical cases lacking this evidence require material new evidence. Allowed transitions: @@ -348,18 +278,15 @@ Each Identifier Type declares: - verification/provenance required for that claim; - matching rules permitted to consume it. -Do not persist `OTHER`, generic `VAT_ID`, connector IDs, or Source Record References as Official -Identifiers. +Do not persist `OTHER`, generic `VAT_ID`, connector IDs, or Source Record References as Official Identifiers. -`CZ_DIC` is the Czech tax identifier. Current VAT registration, payer status, reverse-charge -eligibility, and tax treatment remain outside Party Registry. +`CZ_DIC` is the Czech tax identifier. Current VAT registration, payer status, reverse-charge eligibility, and tax treatment remain outside Party Registry. ## Contact Points Initial Contact Point Types are `EMAIL`, `PHONE`, and structured `ADDRESS`. -Contact Points are contactability facts, not credentials or identity keys. The same normalized email -or phone may belong to several Parties. Matching may use them only under explicit Match Rules. +Contact Points are contactability facts, not credentials or identity keys. The same normalized email or phone may belong to several Parties. Matching may use them only under explicit Match Rules. ADDRESS may carry compatible purposes: @@ -370,12 +297,9 @@ DELIVERY CORRESPONDENCE ``` -BILLING and DELIVERY are reusable Party-level defaults only when independent of a Legal Entity, -Counterparty, contract, or transaction. Context-specific preferences remain with that context. A -completed document owns the exact address snapshot it used. +BILLING and DELIVERY are reusable Party-level defaults only when independent of a Legal Entity, Counterparty, contract, or transaction. Context-specific preferences remain with that context. A completed document owns the exact address snapshot it used. -Any searchable Contact Point requires an explicit privacy classification and Read permission. Do -not index inactive, retracted, or disputed values as current facts. +Any searchable Contact Point requires an explicit privacy classification and Read permission. Do not index inactive, retracted, or disputed values as current facts. ## Party Relationships @@ -387,12 +311,9 @@ Initial production type: CONTACT_PERSON_OF PERSON -> ORGANIZATION ``` -`EMPLOYEE_OF` remains deferred until a concrete external-organization use case proves it is not a -second employee/HR lifecycle. `BRANCH_OF` and `OTHER` are not production types. +`EMPLOYEE_OF` remains deferred until a concrete external-organization use case proves it is not a second employee/HR lifecycle. `BRANCH_OF` and `OTHER` are not production types. -Relationship endpoints and type are immutable. Changing either ends or corrects the old assertion -and creates a new relationship. Relationship periods may be open-ended but cannot overlap when the -type forbids overlap. +Relationship endpoints and type are immutable. Changing either ends or corrects the old assertion and creates a new relationship. Relationship periods may be open-ended but cannot overlap when the type forbids overlap. ## Counterparty @@ -402,8 +323,7 @@ A Counterparty is one durable commercial or contractual context: Counterparty = Party × Legal Entity ``` -The tuple is unique per Tenant. A Counterparty is created only from provenance-backed evidence of a -commercial or contractual relationship. Knowing or displaying a Party is insufficient. +The tuple is unique per Tenant. A Counterparty is created only from provenance-backed evidence of a commercial or contractual relationship. Knowing or displaying a Party is insufficient. Initial role types are: @@ -412,13 +332,9 @@ CUSTOMER SUPPLIER ``` -Each role is a separate time-bounded period. Several roles may coexist. Ending one role does not end -another, the Counterparty, or the Party. A Counterparty may have no current role when the underlying -commercial context is still evidenced or retained historically. +Each role is a separate time-bounded period. Several roles may coexist. Ending one role does not end another, the Counterparty, or the Party. A Counterparty may have no current role when the underlying commercial context is still evidenced or retained historically. -`BUSINESS_PARTNER` is not a role. The Counterparty already represents the generic commercial or -contractual context. Future distributor, reseller, accounting-office, or other capacities require -named types with their own preconditions. +`BUSINESS_PARTNER` is not a role. The Counterparty already represents the generic commercial or contractual context. Future distributor, reseller, accounting-office, or other capacities require named types with their own preconditions. ## Matching @@ -440,9 +356,7 @@ Rule order: 4. weak signals -> candidate ranking only; 5. no qualifying evidence -> NO_MATCH. -Weak signals include names, unverified email/phone, address similarity, and provider classification. -No numeric score may override an authoritative conflict. An ML model may rank review candidates but -cannot produce canonical identity authority. +Weak signals include names, unverified email/phone, address similarity, and provider classification. No numeric score may override an authoritative conflict. An ML model may rank review candidates but cannot produce canonical identity authority. ## Duplicate Candidate cases @@ -466,19 +380,11 @@ DISMISSED_AS_NON_SUBJECT CONFIRMED_DUPLICATE_PARTIES ``` -`CREATE_NEW` is available only when a transactional recheck proves that every qualifying strong claim -is still unclaimed, or when the Candidate legitimately has no strong claim and the explicit -create-without-strong-identifier policy allows creation. It is forbidden while any qualifying strong -claim is owned by an existing Party. A reviewer cannot drop authoritative evidence merely to make -creation pass. +`CREATE_NEW` is available only when a transactional recheck proves that every qualifying strong claim is still unclaimed, or when the Candidate legitimately has no strong claim and the explicit create-without-strong-identifier policy allows creation. It is forbidden while any qualifying strong claim is owned by an existing Party. A reviewer cannot drop authoritative evidence merely to make creation pass. -A case whose strong claims resolve to one or several existing Parties must instead match an existing -Party, correct/retract/reassign the wrong claim through an authorized Party Correction and then match, -confirm duplicate existing Parties for the separate merge flow, request evidence, or dismiss the input -as not representing a subject. +A case whose strong claims resolve to one or several existing Parties must instead match an existing Party, correct/retract/reassign the wrong claim through an authorized Party Correction and then match, confirm duplicate existing Parties for the separate merge flow, request evidence, or dismiss the input as not representing a subject. -`MATCH_EXISTING` consumes an explicit canonical `selectedPartyRef`; it does not rerun ordinary matching -without the review decision: +`MATCH_EXISTING` consumes an explicit canonical `selectedPartyRef`; it does not rerun ordinary matching without the review decision: ```text ResolveDuplicateCandidateMatch(caseRef, selectedPartyRef, expectedRevision) @@ -501,24 +407,15 @@ ResolveDuplicateCandidateMatch(caseRef, selectedPartyRef, expectedRevision) Domain Events, Outbox Messages, and invocation success atomically ``` -A weak-evidence case with no strong claims may still be resolved to the selected Party when the -Identity Reviewer has the required authority and the current Match Rule permits reviewed matching. The -explicit selection is part of the resolution Action input and evidence; it is never inferred again from -the unchanged Candidate. +A weak-evidence case with no strong claims may still be resolved to the selected Party when the Identity Reviewer has the required authority and the current Match Rule permits reviewed matching. The explicit selection is part of the resolution Action input and evidence; it is never inferred again from the unchanged Candidate. -`CREATE_NEW` uses a separate resolution Action with no selected Party. It acquires the same claim-key -locks, repeats canonical claim resolution, and may create only when every qualifying claim is still -unclaimed or the approved no-strong-identifier policy applies. The case decision alone never bypasses -uniqueness. +`CREATE_NEW` uses a separate resolution Action with no selected Party. It acquires the same claim-key locks, repeats canonical claim resolution, and may create only when every qualifying claim is still unclaimed or the approved no-strong-identifier policy applies. The case decision alone never bypasses uniqueness. -Creating or reusing the case and its AMBIGUOUS Party Match Decision is a committed successful Action -outcome. Resolution is a separate Action. Repeated identical evidence reuses the prior open or -resolved case unless a new fact, policy version, or Candidate meaning changes the decision input. +Creating or reusing the case and its AMBIGUOUS Party Match Decision is a committed successful Action outcome. Resolution is a separate Action. Repeated identical evidence reuses the prior open or resolved case unless a new fact, policy version, or Candidate meaning changes the decision input. ## Correction -Correction applies only when a previously accepted Party-owned assertion was wrong at the time it -was asserted. It records: +Correction applies only when a previously accepted Party-owned assertion was wrong at the time it was asserted. It records: - corrected assertion; - correction reason; @@ -528,14 +425,11 @@ was asserted. It records: - policy version; - affected current projections and emitted event. -Enrichment of a previously unknown value and legitimate real-world change are not corrections. -Correction does not merge two Parties. +Enrichment of a previously unknown value and legitimate real-world change are not corrections. Correction does not merge two Parties. ## Merge -Production merge remains disabled for the initial implementation. The schemas and contracts may be -prepared, but no Action is published as executable until the following behavior is tested end to -end: +Production merge remains disabled for the initial implementation. The schemas and contracts may be prepared, but no Action is published as executable until the following behavior is tested end to end: 1. same-Tenant duplicate confirmation; 2. deterministic survivor selection; @@ -560,17 +454,13 @@ PartyMerged { } ``` -The event contains identities, not mutable Party payload copies. Consumers resolve current state -through public Party Registry contracts. +The event contains identities, not mutable Party payload copies. Consumers resolve current state through public Party Registry contracts. -A consumer that owns at most one profile per Party must provide real behavior for collision -detection and reconciliation. A descriptor or marker without tested behavior does not make merge -safe. +A consumer that owns at most one profile per Party must provide real behavior for collision detection and reconciliation. A descriptor or marker without tested behavior does not make merge safe. ## Search -OntOS Core Search owns the physical projection and query runtime. Party Registry publishes safe -search descriptors and lifecycle events. +OntOS Core Search owns the physical projection and query runtime. Party Registry publishes safe search descriptors and lifecycle events. V1 Party Search fields: @@ -578,45 +468,27 @@ V1 Party Search fields: - active Official Identifiers; - active EMAIL and PHONE Contact Points when the caller has the required permission. -V1 Counterparty Search adds required Legal Entity scope and current CUSTOMER/SUPPLIER filters. -Archived Parties are excluded by default and may be included explicitly. Party Alias hits resolve to -the canonical Party and never appear as a second current Party. +V1 Counterparty Search adds required Legal Entity scope and current CUSTOMER/SUPPLIER filters. Archived Parties are excluded by default and may be included explicitly. Party Alias hits resolve to the canonical Party and never appear as a second current Party. -Search remains eventually consistent. Reads by ResourceRef, exact identifier claims, create -uniqueness, correction, and merge resolution use the canonical Party Registry store. +Search remains eventually consistent. Reads by ResourceRef, exact identifier claims, create uniqueness, correction, and merge resolution use the canonical Party Registry store. ## External evidence -ARES is an External Evidence Provider reached through an owner-local Direct Provider Adapter or an -approved Symmy Connector and an explicit Integration Route. +ARES is an External Evidence Provider reached through an owner-local Direct Provider Adapter or an approved Symmy Connector and an explicit Integration Route. -The read side returns bounded normalized evidence with source and observed time. It does not mutate -Party state. Applying evidence invokes Party Registry Actions fact by fact. +The read side returns bounded normalized evidence with source and observed time. It does not mutate Party state. Applying evidence invokes Party Registry Actions fact by fact. -September V1 keeps six ARES decisions: PREFILL_ONLY, APPLY_ENRICHMENT, NO_CHANGE, -NEEDS_CONFIRMATION, CORRECTION_CANDIDATE, IDENTITY_AMBIGUITY. Enrichment requires explicit user -confirmation. A policy decision is not a committed receipt; each successful standard Action has its -own receipt and failed multi-fact application stops with the completed subset. +September V1 keeps six ARES decisions: PREFILL_ONLY, APPLY_ENRICHMENT, NO_CHANGE, NEEDS_CONFIRMATION, CORRECTION_CANDIDATE, IDENTITY_AMBIGUITY. Enrichment requires explicit user confirmation. A policy decision is not a committed receipt; each successful standard Action has its own receipt and failed multi-fact application stops with the completed subset. -Canonical ARES application excludes Create. Candidate prefill returns proposed data for a separate -explicit Matching/Create call, without manufacturing subject attestation. For historical-error -suspicion, a governed current assertion may carry prior ARES provenance for the same ICO and same -non-null provider revision, observed at or before the assertion's validFrom. A conflicting fresh -observation on that unchanged revision can nominate the exact assertion for Correction review. -A newer provider revision or a difference alone cannot. This bounded suspicion never proves error -or executes Correction: a reviewer must establish the historical error through the existing -Correction Action. Unsupported historical address correction stays review-only. +Canonical ARES application excludes Create. Candidate prefill returns proposed data for a separate explicit Matching/Create call, without manufacturing subject attestation. For historical-error suspicion, a governed current assertion may carry prior ARES provenance for the same ICO and same non-null provider revision, observed at or before the assertion's validFrom. A conflicting fresh observation on that unchanged revision can nominate the exact assertion for Correction review. A newer provider revision or a difference alone cannot. This bounded suspicion never proves error or executes Correction: a reviewer must establish the historical error through the existing Correction Action. Unsupported historical address correction stays review-only. -Initial delivery may support read-only ARES lookup for ICO. Automatic conflict correction, merge, or -bulk field overwrite is excluded. +Initial delivery may support read-only ARES lookup for ICO. Automatic conflict correction, merge, or bulk field overwrite is excluded. -Connector Registry owns provider-issued record correlations. It does not own ICO, CZ_DIC, Party -identity, or the accepted Party state. +Connector Registry owns provider-issued record correlations. It does not own ICO, CZ_DIC, Party identity, or the accepted Party state. ## Contacts replacement -The repository's current Contacts implementation is not a production System of Record. Replace it -with a breaking change: +The repository's current Contacts implementation is not a production System of Record. Replace it with a breaking change: ```text current Contacts customer/contact identity @@ -633,8 +505,7 @@ Required implementation sequence: 5. remove legacy Contacts customer and subordinate-contact identity ownership; 6. update tests and fixtures to create Party state through public contracts. -Do not build repository-only backfill, dual-write, compatibility aliases, or long-lived migration -mapping. Create a migration only when a verified live External Business System or dataset exists. +Do not build repository-only backfill, dual-write, compatibility aliases, or long-lived migration mapping. Create a migration only when a verified live External Business System or dataset exists. ## Required focused validation @@ -644,8 +515,7 @@ The initial implementation is not complete until these behaviors pass: - projection lag cannot produce a duplicate Party; - a conflicting authoritative identifier commits one ambiguity case and one Party Match Decision; - an insufficient-subject-evidence rejection commits neither a case nor a decision; -- an indeterminate Party Create commit recovers the same outcome through invocation resolution and - Party Match Decision Read; +- an indeterminate Party Create commit recovers the same outcome through invocation resolution and Party Match Decision Read; - a repeated idempotent Party Create never executes the handler again and resolves the same result; - unverified shared email or phone never auto-matches; - cross-Tenant identifier equality never resolves or conflicts across Tenants; @@ -660,7 +530,6 @@ The initial implementation is not complete until these behaviors pass: - all Party tables enforce Tenant isolation with enabled and forced RLS; - Contacts no longer owns shared person/organization identity after the breaking replacement. -Run the smallest affected dependency cone for each implementation increment. File presence, a -manifest declaration, or a generated marker is not evidence that these behaviors work. +Run the smallest affected dependency cone for each implementation increment. File presence, a manifest declaration, or a generated marker is not evidence that these behaviors work. ARES dispatch preserves the original confirmed observation and uses its `servedAt` as the logical as-of `decidedAt` in the command provenance envelope. This keeps the command payload and idempotency hash stable across delivery attempts; it is not the execution timestamp. Assertion `recordedAt` and Core invocation/audit time record actual acceptance. Both the original confirmation and refreshed observation must remain fresh; a new refresh cannot revive an expired confirmation. Failed or indeterminate receipts require standard commit resolution before retry. diff --git a/app/docs/architecture/VALUE_OBJECTS.md b/app/docs/architecture/VALUE_OBJECTS.md index 54c7ca153..221cb92ea 100644 --- a/app/docs/architecture/VALUE_OBJECTS.md +++ b/app/docs/architecture/VALUE_OBJECTS.md @@ -1,8 +1,6 @@ # Value Objects -Use a value object when a domain concept is defined entirely by its attributes and has no -independent identity or lifecycle. Two value objects with the same normalized attributes are equal -even when they were created separately. +Use a value object when a domain concept is defined entirely by its attributes and has no independent identity or lifecycle. Two value objects with the same normalized attributes are equal even when they were created separately. ## Entity or value object @@ -21,30 +19,18 @@ Model it as an entity or Resource when any of these are true: - several owners intentionally share and observe changes to the same instance; or - another module must refer to it through a ResourceRef and public owner contract. -Do not introduce identity merely to normalize storage or avoid repeating fields. Conversely, do not -embed a mutable shared concept as a value object when updates must be coordinated across owners. +Do not introduce identity merely to normalize storage or avoid repeating fields. Conversely, do not embed a mutable shared concept as a value object when updates must be coordinated across owners. ## Ownership and persistence -The owning module defines a value object's schema, normalization, validation, and serialization. -Persist it with its owner, either in the owner's table or an owner-private child table. A child -table does not automatically make the value an entity. +The owning module defines a value object's schema, normalization, validation, and serialization. Persist it with its owner, either in the owner's table or an owner-private child table. A child table does not automatically make the value an entity. -Across a MicroVertical seam, transmit a value snapshot through a published schema when the consumer -needs the data as observed at that moment. Use a ResourceRef only when the consumer needs the stable -identity owned by another module. +Across a MicroVertical seam, transmit a value snapshot through a published schema when the consumer needs the data as observed at that moment. Use a ResourceRef only when the consumer needs the stable identity owned by another module. -When historical accuracy matters, store the accepted snapshot on the historical record even if an -independently addressable source entity also exists. An Order, invoice, or evidence record must not -silently change because a current profile was edited later. +When historical accuracy matters, store the accepted snapshot on the historical record even if an independently addressable source entity also exists. An Order, invoice, or evidence record must not silently change because a current profile was edited later. ## Address example -An address is normally a value object owned by the record that uses it: billing address, delivery -address, registered office snapshot, or Contact Point value. Store normalized structured fields and -replace the address as one value. Two equal addresses do not imply one shared business object. +An address is normally a value object owned by the record that uses it: billing address, delivery address, registered office snapshot, or Contact Point value. Store normalized structured fields and replace the address as one value. Two equal addresses do not imply one shared business object. -Promote a place to an entity, such as a Location, only when that concrete place needs stable -identity, its own lifecycle or permissions, independent relationships, or deliberate sharing across -modules. One module then owns the Location and other modules use its ResourceRef and public -contracts. Historical documents still retain the address snapshot accepted at the time. +Promote a place to an entity, such as a Location, only when that concrete place needs stable identity, its own lifecycle or permissions, independent relationships, or deliberate sharing across modules. One module then owns the Location and other modules use its ResourceRef and public contracts. Historical documents still retain the address snapshot accepted at the time. diff --git a/app/docs/frontend/FRONTEND.md b/app/docs/frontend/FRONTEND.md index e35aaf4b7..31ad80e99 100644 --- a/app/docs/frontend/FRONTEND.md +++ b/app/docs/frontend/FRONTEND.md @@ -49,9 +49,7 @@ data hook → generated Effect BFF client → BFF endpoint → Action runtime `@techsio/ui-kit` is the source of truth for components, tokens, typography, spacing, colors, icons, forms, accessibility, and interaction patterns. -Treat Figma as a wireframe for information hierarchy, component arrangement, and interaction intent. -Do not copy its styling or introduce visual values from the design file. The installed -`@techsio/ui-kit` components and tokens remain the visual and accessibility authority. +Treat Figma as a wireframe for information hierarchy, component arrangement, and interaction intent. Do not copy its styling or introduce visual values from the design file. The installed `@techsio/ui-kit` components and tokens remain the visual and accessibility authority. Before creating UI: @@ -183,12 +181,7 @@ Keep state in the lowest appropriate owner: - Server data belongs in loaders or query caches. - Cross-feature interactive state belongs in an application store. -The authenticated Shell is server-composed. Its layout receives plain navigation and legal-entity -view models, keeps search persistent, and uses full document reloads after successful tenant or -legal-entity switches. Direct module, search, and ResourceRef routes map typed loader results to -explicit selection-required, empty, partial, forbidden, not-found, unavailable/retry, and resolved -states. Disabled module and media affordances remain semantic, non-interactive content with an -accessible explanation; inaccessible items are never guessed into links. +The authenticated Shell is server-composed. Its layout receives plain navigation and legal-entity view models, keeps search persistent, and uses full document reloads after successful tenant or legal-entity switches. Direct module, search, and ResourceRef routes map typed loader results to explicit selection-required, empty, partial, forbidden, not-found, unavailable/retry, and resolved states. Disabled module and media affordances remain semantic, non-interactive content with an accessible explanation; inaccessible items are never guessed into links. ## Hooks and React Effects diff --git a/app/docs/integrations/ares.md b/app/docs/integrations/ares.md index 6265d8800..928905456 100644 --- a/app/docs/integrations/ares.md +++ b/app/docs/integrations/ares.md @@ -2,15 +2,11 @@ Research verified: 2026-09-01 -> [!IMPORTANT] -> This document owns the ARES provider protocol, normalized evidence, and adapter resilience. Party -> identity, matching, correction, and canonical writes follow -> [Party Registry](../architecture/PARTY_REGISTRY.md). ARES never writes Party state directly. +> [!IMPORTANT] This document owns the ARES provider protocol, normalized evidence, and adapter resilience. Party identity, matching, correction, and canonical writes follow [Party Registry](../architecture/PARTY_REGISTRY.md). ARES never writes Party state directly. ## Ownership -ARES is an External Evidence Provider. It can supply observations about a Czech economic subject, -but it is not the System of Record for an OntOS Party. +ARES is an External Evidence Provider. It can supply observations about a Czech economic subject, but it is not the System of Record for an OntOS Party. The `party.registry` MicroVertical owns: @@ -27,9 +23,7 @@ The owner-local Direct Provider Adapter owns: - provider error mapping and diagnostics; - translation into the bounded provider-neutral evidence envelope. -Connector Registry owns a provider-issued record correlation when OntOS must retain one. ARES -record identifiers are not Party Official Identifiers merely because they are stable at the -provider. +Connector Registry owns a provider-issued record correlation when OntOS must retain one. ARES record identifiers are not Party Official Identifiers merely because they are stable at the provider. ## Supported V1 route @@ -40,9 +34,7 @@ GET https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/{ico} Accept: application/json ``` -The public provider contract is documented by the -[official OpenAPI document](https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/v3/api-docs) and -[Swagger UI](https://ares.gov.cz/swagger-ui/). +The public provider contract is documented by the [official OpenAPI document](https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/v3/api-docs) and [Swagger UI](https://ares.gov.cz/swagger-ui/). Input rules: @@ -50,8 +42,7 @@ Input rules: 2. require exactly eight decimal digits; 3. preserve leading zeroes; 4. do not guess or pad shorter input; -5. never accept Tenant, Principal, Legal Entity, Party, or authorization identity from the lookup - payload. +5. never accept Tenant, Principal, Legal Entity, Party, or authorization identity from the lookup payload. The browser calls a generated governed Read. Only the private server-side adapter calls ARES. @@ -78,9 +69,7 @@ AresSubjectEvidence { } ``` -The exact Effect Schemas belong to the owning MicroVertical. They must reject unknown unbounded -payload retention and preserve enough source metadata to explain when and from where each -observation was obtained. +The exact Effect Schemas belong to the owning MicroVertical. They must reject unknown unbounded payload retention and preserve enough source metadata to explain when and from where each observation was obtained. Provider fields commonly used by the consolidated subject route include: @@ -99,8 +88,7 @@ primarniZdroj icoId ``` -Their presence in the provider response does not authorize Party Registry to apply them. The Party -contract owns the allowlist and fact-specific authority policy. +Their presence in the provider response does not authorize Party Registry to apply them. The Party contract owns the allowlist and fact-specific authority policy. ## Read outcomes @@ -118,12 +106,9 @@ PROVIDER_TIMEOUT PROVIDER_RESPONSE_INVALID ``` -`NOT_FOUND` is a valid provider result. Timeout, denial, throttling, transport failure, response -decode failure, and unavailable provider are failures and must never be interpreted as `NOT_FOUND` -or `NO_MATCH`. +`NOT_FOUND` is a valid provider result. Timeout, denial, throttling, transport failure, response decode failure, and unavailable provider are failures and must never be interpreted as `NOT_FOUND` or `NO_MATCH`. -The BFF maps expected failures exhaustively to the repository's typed Problem Details contract. It -does not expose raw provider bodies, secrets, stack traces, or internal URLs. +The BFF maps expected failures exhaustively to the repository's typed Problem Details contract. It does not expose raw provider bodies, secrets, stack traces, or internal URLs. ## Adapter resilience @@ -136,18 +121,12 @@ The Direct Provider Adapter must: - cache only successful immutable evidence envelopes for a bounded period; - expose observation and cache age to the caller; - never cache validation, denial, not-found, decode, or transport failures as successful evidence; -- respect the current - [official operating conditions](https://ares.gov.cz/stranky/podminky-provozu); -- keep authentication and CORS assumptions private to the adapter so provider changes do not alter - Party contracts. +- respect the current [official operating conditions](https://ares.gov.cz/stranky/podminky-provozu); +- keep authentication and CORS assumptions private to the adapter so provider changes do not alter Party contracts. -A cache changes transport cost, not fact authority. Cached evidence remains external evidence with -its original `observedAt` and must pass the same Party policy as a fresh response. +A cache changes transport cost, not fact authority. Cached evidence remains external evidence with its original `observedAt` and must pass the same Party policy as a fresh response. -The implemented adapter uses a three-second timeout covering response headers and body decoding, -at most two bounded exponential retries, a five-minute successful-result cache capped at 256 -entries, same-IČO request coalescing, and four concurrent provider requests. These implementation -settings do not change the dated external research above. +The implemented adapter uses a three-second timeout covering response headers and body decoding, at most two bounded exponential retries, a five-minute successful-result cache capped at 256 entries, same-IČO request coalescing, and four concurrent provider requests. These implementation settings do not change the dated external research above. ## Canonical apply boundary @@ -169,27 +148,16 @@ Examples: - a valid accepted IČO uses the standard Official Identifier Add Action; - a previously unknown accepted business name uses Party enrichment; - an accepted registered address uses the standard Contact Point Action; -- a conflict with a current authoritative assertion produces confirmation, ambiguity, or Party - Correction work; +- a conflict with a current authoritative assertion produces confirmation, ambiguity, or Party Correction work; - no provider response performs Party Merge. -V1 may prefill an explicit user-confirmed Party flow. Unattended bulk apply, automatic correction, -automatic merge, and whole-response overwrite remain excluded until their fact-specific policies and -behavioral conflict tests exist. +V1 may prefill an explicit user-confirmed Party flow. Unattended bulk apply, automatic correction, automatic merge, and whole-response overwrite remain excluded until their fact-specific policies and behavioral conflict tests exist. -The implemented coordinator refreshes governed canonical state before applying selected facts. -Each accepted fact records bounded observation and decision evidence separately from the trusted -accepting Principal. Independent stable Action idempotency keys support explicit partial outcomes -and recovery: a retry skips already-satisfied facts and continues missing facts rather than -overwriting current assertions. There is no ARES-specific mutation Action or cross-module shared -transaction. +The implemented coordinator refreshes governed canonical state before applying selected facts. Each accepted fact records bounded observation and decision evidence separately from the trusted accepting Principal. Independent stable Action idempotency keys support explicit partial outcomes and recovery: a retry skips already-satisfied facts and continues missing facts rather than overwriting current assertions. There is no ARES-specific mutation Action or cross-module shared transaction. ## Optional provider research -ARES also exposes source-specific public-register and trade-licensing routes and code-list routes. -They are not part of the V1 Party lookup contract. Add one only when a concrete owning business fact -requires it, then preserve its source-specific meaning and history rather than flattening it into the -consolidated subject response. +ARES also exposes source-specific public-register and trade-licensing routes and code-list routes. They are not part of the V1 Party lookup contract. Add one only when a concrete owning business fact requires it, then preserve its source-specific meaning and history rather than flattening it into the consolidated subject response. Useful official references: diff --git a/app/module-federation.shared.ts b/app/module-federation.shared.ts new file mode 100644 index 000000000..ec7011ea1 --- /dev/null +++ b/app/module-federation.shared.ts @@ -0,0 +1,42 @@ +type SharedRuntimeVersions = Readonly< + Record< + '@modern-js/plugin-i18n/runtime' | '@modern-js/runtime' | '@tanstack/react-router' | 'react' | 'react-dom', + string + > +>; + +/** Both delivery units must use the same singleton sharing policy. */ +export const createSharedRuntimeConfig = (versions: SharedRuntimeVersions) => ({ + '@modern-js/plugin-i18n/runtime': { + import: '@modern-js/plugin-i18n/runtime/no-react-i18next', + requiredVersion: versions['@modern-js/plugin-i18n/runtime'], + singleton: true, + strictVersion: true, + treeShaking: false, + }, + '@modern-js/runtime': { + requiredVersion: versions['@modern-js/runtime'], + singleton: true, + treeShaking: false, + }, + '@tanstack/react-router': { + requiredVersion: versions['@tanstack/react-router'], + singleton: true, + treeShaking: false, + }, + react: { + requiredVersion: versions.react, + singleton: true, + treeShaking: false, + }, + 'react-dom': { + requiredVersion: versions['react-dom'], + singleton: true, + treeShaking: false, + }, + 'react-dom/client': { + requiredVersion: versions['react-dom'], + singleton: true, + treeShaking: false, + }, +}); diff --git a/app/oxfmt.config.ts b/app/oxfmt.config.ts index 62dc36bb3..a31cca208 100644 --- a/app/oxfmt.config.ts +++ b/app/oxfmt.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'oxfmt'; -import ultracite from 'ultracite/oxfmt'; export default defineConfig({ - extends: [ultracite], + printWidth: 120, + trailingComma: 'all', ignorePatterns: [ '.agents', '.codex/skills', diff --git a/app/oxlint.config.ts b/app/oxlint.config.ts index baf614ad2..d1e782a6e 100644 --- a/app/oxlint.config.ts +++ b/app/oxlint.config.ts @@ -1,10 +1,19 @@ -import { testRestrictedImports } from './tools/oxlint/effect-native/shared/test-restricted-imports.ts'; import { defineConfig } from 'oxlint'; import core from 'ultracite/oxlint/core'; import { jsPluginSettings, selectJsPlugins } from 'ultracite/oxlint/js-plugins'; import react from 'ultracite/oxlint/react'; -const jsPlugins = selectJsPlugins(['github', 'sonarjs', 'react-doctor']); +import { testRestrictedImports } from './tools/oxlint/effect-native/shared/test-restricted-imports.ts'; + +const selectedJsPlugins = selectJsPlugins(['github', 'sonarjs', 'react-doctor']); +const jsPlugins = { + ...selectedJsPlugins, + // Load GitHub's published rule-only entrypoint, not its ESLint configuration aggregator. + // The aggregator eagerly imports eslint-plugin-import and the ESLint runner; the rules do not. + jsPlugins: selectedJsPlugins.jsPlugins.map((plugin) => + plugin.name === 'github' ? { ...plugin, specifier: 'eslint-plugin-github/lib/plugin.js' } : plugin, + ), +}; const antiSlopRules = { 'anti-slop/no-chained-type-assertions': 'error', @@ -42,7 +51,9 @@ const effectNativeRules: NonNullable[0]['rules'] 'effect-native/no-dotenv-loading': 'error', 'effect-native/no-driver-failure-inspection': [ 'error', - { decoderPaths: ['packages/core-runtime/src/database/postgres-failure.ts'] }, + { + decoderPaths: ['packages/core-runtime/src/database/postgres-failure.ts'], + }, ], 'effect-native/no-duplicate-literal-vocabulary': 'error', // These factories compose a scoped pool, its native SQL client, and Drizzle once. @@ -185,7 +196,10 @@ export default defineConfig({ name: 'anti-slop-effect', specifier: './tools/oxlint/anti-slop/effect/index.ts', }, - { name: 'effect-native', specifier: './tools/oxlint/effect-native/index.ts' }, + { + name: 'effect-native', + specifier: './tools/oxlint/effect-native/index.ts', + }, ], options: { denyWarnings: true, @@ -224,10 +238,7 @@ export default defineConfig({ { // This guarded test-only entrypoint composes real services with boundary fakes. // database-access:check rejects imports of it from production source. - files: [ - 'packages/core-runtime/src/testing/**/*.ts', - 'apps/shell-super-app/tests/e2e/auth-fixture.ts', - ], + files: ['packages/core-runtime/src/testing/**/*.ts', 'apps/shell-super-app/tests/e2e/auth-fixture.ts'], rules: { 'anti-slop-effect/no-service-constructor-imports': 'off', }, @@ -293,10 +304,7 @@ export default defineConfig({ }, { // Test registration deliberately returns an ignored promise, and test synchronization may use `.then`. - files: [ - '**/*.{test,spec,test-d,spec-d}.{ts,tsx,js,jsx}', - '**/__tests__/**/*.{ts,tsx,js,jsx}', - ], + files: ['**/*.{test,spec,test-d,spec-d}.{ts,tsx,js,jsx}', '**/__tests__/**/*.{ts,tsx,js,jsx}'], rules: { 'github/no-then': 'off', // Ultracite's JS-plugin preset applies the same test-data exception; repeat it because @@ -306,8 +314,16 @@ export default defineConfig({ 'error', { allowForKnownSafeCalls: [ - { from: 'package', name: ['it', 'test'], package: '@playwright/test' }, - { from: 'package', name: ['it', 'test'], package: '@rstest/core' }, + { + from: 'package', + name: ['it', 'test'], + package: '@playwright/test', + }, + { + from: 'package', + name: ['it', 'test'], + package: '@rstest/core', + }, { from: 'package', name: ['it', 'test'], package: 'node:test' }, ], }, @@ -477,10 +493,7 @@ export default defineConfig({ { // These aliases name stable domain boundaries even when their current representation is // identical to another type; removing the names would couple public/runtime APIs to storage. - files: [ - 'apps/shell-super-app/src/api/auth-client.ts', - 'packages/core-runtime/src/actions/runtime.ts', - ], + files: ['apps/shell-super-app/src/api/auth-client.ts', 'packages/core-runtime/src/actions/runtime.ts'], rules: { 'sonarjs/redundant-type-aliases': 'off', }, @@ -652,9 +665,7 @@ export default defineConfig({ { // The edit page keeps one cohesive mutation/detail workflow; splitting it would move // authorization and retry state across component boundaries during this lint-only migration. - files: [ - 'verticals/party-registry/src/routes/**/contacts/customers/**/contacts/**/edit/page.tsx', - ], + files: ['verticals/party-registry/src/routes/**/contacts/customers/**/contacts/**/edit/page.tsx'], rules: { 'react-doctor/no-giant-component': 'off', }, diff --git a/app/package.json b/app/package.json index ad4077745..eff5bc68e 100644 --- a/app/package.json +++ b/app/package.json @@ -34,8 +34,8 @@ "action:test:unit": "pnpm --filter @app/core-runtime action:test:unit", "action:test:integration": "pnpm --filter @app/core-runtime action:test:integration", "outbox:test": "pnpm --filter @app/core-runtime outbox:test:unit && pnpm --filter @app/core-runtime outbox:test:integration", - "build": "pnpm -r --filter \"./verticals/*\" run build && pnpm --filter \"./apps/shell-super-app\" run build && pnpm mf:types && pnpm performance:readiness", - "cloudflare:build": "pnpm -r --filter \"./verticals/*\" run cloudflare:build && pnpm --filter \"./apps/shell-super-app\" run cloudflare:build && ULTRAMODERN_MF_TYPES_ARCHIVE=dist-cloudflare/@mf-types.zip pnpm mf:types && pnpm cloudflare-output:verify && pnpm cloudflare:ssr-proof", + "build": "node ./scripts/ultramodern-typecheck.mts --build packages/shared-contracts/tsconfig.json && node ./scripts/ultramodern-typecheck.mts --build packages/shared-design-tokens/tsconfig.json && pnpm -r --filter \"./verticals/*\" run build && pnpm --filter \"./apps/shell-super-app\" run build && pnpm mf:types && pnpm performance:readiness", + "cloudflare:build": "node ./scripts/ultramodern-typecheck.mts --build packages/shared-contracts/tsconfig.json && node ./scripts/ultramodern-typecheck.mts --build packages/shared-design-tokens/tsconfig.json && pnpm -r --filter \"./verticals/*\" run cloudflare:build && pnpm --filter \"./apps/shell-super-app\" run cloudflare:build && pnpm mf:types --target cloudflare && pnpm cloudflare-output:verify && pnpm cloudflare:ssr-proof", "cloudflare:deploy": "pnpm -r --filter \"./verticals/*\" run cloudflare:deploy && pnpm --filter \"./apps/shell-super-app\" run cloudflare:deploy", "cloudflare:proof": "node ./scripts/proof-cloudflare-version.mts --out .codex/reports/cloudflare-version-proof/public-url-proof.json", "cloudflare-output:verify": "node ./scripts/verify-cloudflare-output.mts", @@ -67,10 +67,10 @@ "module-entrypoints:check": "node ./scripts/check-module-entrypoint-boundaries.mts", "check:module-contracts": "node ./scripts/check-ontos-module-contracts.mts", "typecheck": "node ./scripts/ultramodern-typecheck.mts --build tsconfig.json", - "check": "pnpm format:check && pnpm typecheck:lint-rules && pnpm test:lint-rules && pnpm lint && pnpm action:test:unit && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm database-access:check && pnpm module-entrypoints:check && pnpm check:module-contracts && pnpm contract:check && pnpm performance:readiness && pnpm quality:check", + "check": "pnpm typecheck:lint-rules && pnpm test:lint-rules && pnpm action:test:unit && pnpm database-access:check && pnpm module-entrypoints:check && pnpm check:module-contracts && pnpm quality:check && pnpm format:check && pnpm lint && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm contract:check && pnpm performance:readiness", "database-access:check": "node ./scripts/check-database-access-boundaries.mts", - "format": "oxfmt . '!repos/**'", - "format:check": "oxfmt --check . '!repos/**'", + "format": "oxfmt .", + "format:check": "oxfmt --check .", "lint": "oxlint apps verticals packages scripts tools/oxlint/effect-native/tests/*.test.mts", "lint:fix": "oxlint apps verticals packages scripts tools/oxlint/effect-native/tests/*.test.mts --fix", "skills:install": "node ./scripts/bootstrap-agent-skills.mts", @@ -79,7 +79,7 @@ "agents:refs:check": "node ./scripts/setup-agent-reference-repos.mts --check", "api:check": "node ./scripts/check-ultramodern-api-boundaries.mts", "i18n:boundaries": "node ./scripts/check-ultramodern-i18n-boundaries.mts", - "postinstall": "node ./scripts/bootstrap-agent-skills.mts --postinstall && oxfmt . '!repos/**'", + "postinstall": "node ./scripts/bootstrap-agent-skills.mts --postinstall && oxfmt .", "authorization:provision-current-actions": "node ./scripts/provision-current-action-authorization.mts", "authorization:inventory:check": "node ./scripts/check-module-entrypoint-boundaries.mts", "authorization:impact:report": "node ./scripts/report-fail-closed-authorization-impact.mts", @@ -87,39 +87,39 @@ "quality:audit": "node ./scripts/quality-audit.mts", "quality:audit:gate": "node ./scripts/quality-audit-gate.mts", "quality:check": "pnpm quality:audit && pnpm quality:audit:gate", - "quality:audit:test": "rstest --project scripts scripts/tests/quality-audit.test.mts scripts/tests/quality-audit-model.test.mts scripts/tests/quality-audit-runtime-model.test.mts scripts/tests/quality-audit-gate.test.mts scripts/tests/quality-cli-lifecycle.test.mts scripts/tests/quality-audit-count-domain.test.mts" + "quality:audit:test": "rstest --project scripts scripts/tests/quality-audit.test.mts scripts/tests/quality-audit-model.test.mts scripts/tests/quality-audit-runtime-model.test.mts scripts/tests/quality-audit-gate.test.mts scripts/tests/quality-cli-lifecycle.test.mts scripts/tests/quality-audit-count-domain.test.mts", + "build:analyze": "cross-env RSDOCTOR=true pnpm build" }, "dependencies": { "@authzed/authzed-node": "1.6.1", "better-auth": "1.7.2", "drizzle-orm": "1.0.0-rc.5-ab785fc", "pg": "8.22.0", - "@effect/sql-pg": "4.0.0-beta.107" + "@effect/sql-pg": "4.0.0-rc.112" }, "devDependencies": { "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc", "@rstest/core": "0.11.11", - "@effect/platform-node": "4.0.0-beta.107", - "@effect/tsgo": "0.19.0", + "@effect/platform-node": "4.0.0-rc.112", + "@effect/tsgo": "0.41.0", "@noble/hashes": "2.2.0", - "@modern-js/code-tools": "npm:@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12", + "@modern-js/code-tools": "npm:@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4", "@modern-js/codesmith": "2.6.9", - "@modern-js/create": "npm:@bleedingdev/modern-js-create@3.8.2-ultramodern.12", - "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12", - "@oxlint/plugins": "1.79.0", - "@types/node": "20.19.43", + "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4", + "@oxlint/plugins": "1.81.0", + "@types/node": "^26.4.1", "@types/pg": "8.20.0", "@typescript/native": "npm:typescript@7.0.2", - "@typescript/native-preview": "npm:typescript@7.0.2", - "effect": "4.0.0-beta.107", + "@typescript/native-preview": "7.0.0-dev.20260707.2", + "effect": "4.0.0-rc.112", "esbuild": "0.28.1", "jose": "6.2.5", "lefthook": "^2.1.10", - "miniflare": "4.20260708.1", - "oxfmt": "0.64.0", - "oxlint": "1.79.0", + "miniflare": "4.20260730.0", + "oxfmt": "0.66.0", + "oxlint": "1.81.0", "oxc-parser": "0.147.0", - "ultracite": "7.10.7", + "ultracite": "7.11.0", "@nkzw/eslint-plugin": "2.0.0", "eslint-plugin-github": "6.1.2", "eslint-plugin-perfectionist": "5.10.1", @@ -131,7 +131,11 @@ "jsonc-parser": "3.3.1", "fallow": "3.22.0", "@vercel/nft": "0.29.2", - "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12" + "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4", + "@modern-js/ultramodern-create": "npm:@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4", + "cross-env": "10.1.0", + "@effect/opentelemetry": "4.0.0-rc.112", + "@modern-js/app-tools-extensions": "npm:@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4" }, "engines": { "node": ">=26", diff --git a/app/packages/core-runtime/package.json b/app/packages/core-runtime/package.json index 21805f093..1bf8492b8 100644 --- a/app/packages/core-runtime/package.json +++ b/app/packages/core-runtime/package.json @@ -32,14 +32,14 @@ }, "dependencies": { "@authzed/authzed-node": "1.6.1", - "@effect/platform-node": "4.0.0-beta.107", + "@effect/platform-node": "4.0.0-rc.112", "drizzle-orm": "1.0.0-rc.5-ab785fc", - "effect": "4.0.0-beta.107", + "effect": "4.0.0-rc.112", "pg": "8.22.0", - "@effect/sql-pg": "4.0.0-beta.107" + "@effect/sql-pg": "4.0.0-rc.112" }, "devDependencies": { - "@types/node": "^20.19.43", + "@types/node": "^26.4.1", "@types/pg": "8.20.0", "drizzle-kit": "1.0.0-rc.5-ab785fc", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc", diff --git a/app/packages/core-runtime/rstest.config.ts b/app/packages/core-runtime/rstest.config.ts index 671f8b7a8..0543deff4 100644 --- a/app/packages/core-runtime/rstest.config.ts +++ b/app/packages/core-runtime/rstest.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ projects: [ - { include: ['tests/unit/**/*.test.ts'], name: 'unit', testEnvironment: 'node' }, + { + include: ['tests/unit/**/*.test.ts'], + name: 'unit', + testEnvironment: 'node', + }, { include: ['tests/integration/**/*.test.ts'], name: 'integration', diff --git a/app/packages/core-runtime/scripts/verify-db-schema.mts b/app/packages/core-runtime/scripts/verify-db-schema.mts index 4d57569e2..be160af83 100644 --- a/app/packages/core-runtime/scripts/verify-db-schema.mts +++ b/app/packages/core-runtime/scripts/verify-db-schema.mts @@ -1,7 +1,8 @@ -import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; // @effect-diagnostics processEnv:off globalConsole:off strictEffectProvide:off -- Existing compatibility boundary; expires: 2026-12-31. import { getTableName, sql } from 'drizzle-orm'; +import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; import { Effect, Layer, Schema } from 'effect'; + import type { CatalogEntry } from '../src/db/catalog.ts'; import { compareApplicationCatalog } from '../src/db/catalog.ts'; import { CoreDatabase, CoreDatabaseLive } from '../src/db/client.ts'; @@ -30,12 +31,9 @@ import { workerCheckpoints, } from '../src/db/schema.ts'; -class DatabaseVerificationError extends Schema.TaggedError()( - 'DatabaseVerificationError', - { - reason: Schema.String, - }, -) {} +class DatabaseVerificationError extends Schema.TaggedError()('DatabaseVerificationError', { + reason: Schema.String, +}) {} const CatalogRowSchema = Schema.Struct({ kind: Schema.Literals(['migration', 'table']), @@ -81,7 +79,9 @@ const verifyRuntimeRole = Effect.gen(function* verifyRuntimeRoleEffect() { .pipe( Effect.mapError( () => - new DatabaseVerificationError({ reason: 'Unable to verify the PostgreSQL runtime role' }), + new DatabaseVerificationError({ + reason: 'Unable to verify the PostgreSQL runtime role', + }), ), ); const [role] = runtimeRole; @@ -131,17 +131,13 @@ const verifySearchIsolation = Effect.gen(function* verifySearchIsolationEffect() ), ); const [searchIsolationRow] = searchIsolation; - const expectedSearchPolicies = operations.map( - (operation) => `core_${tableName}_tenant_${operation}`, - ); + const expectedSearchPolicies = operations.map((operation) => `core_${tableName}_tenant_${operation}`); if ( searchIsolationRow === undefined || !searchIsolationRow.relrowsecurity || !searchIsolationRow.relforcerowsecurity || searchIsolationRow.policy_names.length !== expectedSearchPolicies.length || - searchIsolationRow.policy_names.some( - (policy, index) => policy !== expectedSearchPolicies[index], - ) + searchIsolationRow.policy_names.some((policy, index) => policy !== expectedSearchPolicies[index]) ) { return yield* new DatabaseVerificationError({ reason: 'Core Search must enforce forced tenant RLS with complete owner-operation policies', @@ -227,9 +223,7 @@ const verifyCatalog = Effect.gen(function* verifyCatalogEffect() { if ( migrationBookkeepingTables.length !== expectedMigrationBookkeepingTables.length || - migrationBookkeepingTables.some( - (tableName, index) => tableName !== expectedMigrationBookkeepingTables[index], - ) + migrationBookkeepingTables.some((tableName, index) => tableName !== expectedMigrationBookkeepingTables[index]) ) { return yield* new DatabaseVerificationError({ reason: `Expected Drizzle migration bookkeeping tables [${expectedMigrationBookkeepingTables.join(', ')}], found [${migrationBookkeepingTables.join(', ')}]`, @@ -298,7 +292,10 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { ) .pipe( Effect.mapError( - () => new DatabaseVerificationError({ reason: 'Unable to verify same-tenant constraints' }), + () => + new DatabaseVerificationError({ + reason: 'Unable to verify same-tenant constraints', + }), ), ); const presentCompositeConstraints = constraintRows @@ -334,9 +331,7 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { searchProjectionGenerations, searchProjectionRebuilds, workerCheckpoints, - ].map((table) => - verifyTypedQuery(getTableName(table), () => database.executor.select().from(table).limit(0)), - ); + ].map((table) => verifyTypedQuery(getTableName(table), () => database.executor.select().from(table).limit(0))); for (const query of typedQueries) { yield* query; diff --git a/app/packages/core-runtime/src/actions/collector.ts b/app/packages/core-runtime/src/actions/collector.ts index a8af4848a..b7498965c 100644 --- a/app/packages/core-runtime/src/actions/collector.ts +++ b/app/packages/core-runtime/src/actions/collector.ts @@ -1,10 +1,7 @@ import { Effect, Match, Schema, Predicate } from 'effect'; -import { - DataAccessEventSchema, - DomainEventSchema, - OutboxMessageSchema, - createDomainEventReference, -} from './events.ts'; + +import { ActionCollectorError } from './errors.ts'; +import { DataAccessEventSchema, DomainEventSchema, OutboxMessageSchema, createDomainEventReference } from './events.ts'; import type { ActionAccessEvidencePolicy, ActionEvidenceSnapshot, @@ -17,14 +14,8 @@ import type { DomainEventReference, OutboxMessage, } from './events.ts'; -import { ActionCollectorError } from './errors.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -53,8 +44,12 @@ const cloneAndFreeze = (value: Value): Value => freezeJson(structuredClon const JsonObjectSchema = Schema.Record(Schema.String, Schema.Json); const JsonObjectJsonStringSchema = Schema.fromJsonString(JsonObjectSchema); const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); -const RuntimeActionKeyEvidenceSchema = Schema.Struct({ actionKey: Schema.Unknown }); -const RuntimeResultHashEvidenceSchema = Schema.Struct({ resultHash: Schema.Unknown }); +const RuntimeActionKeyEvidenceSchema = Schema.Struct({ + actionKey: Schema.Unknown, +}); +const RuntimeResultHashEvidenceSchema = Schema.Struct({ + resultHash: Schema.Unknown, +}); const metadataOnlyPolicyFields = ( policy: Extract, @@ -96,53 +91,37 @@ const hasUnsupportedResultEvidence = (event: DataAccessEvent): boolean => (event.evidenceCaptureMode === 'redacted_payload' && (event.resultFingerprintHash !== undefined || event.resultFingerprintSchema !== undefined)); -const validateDataAccessInvariant = ( - event: DataAccessEvent, -): Effect.Effect => { +const validateDataAccessInvariant = (event: DataAccessEvent): Effect.Effect => { if (hasIncompleteRedactedEvidence(event)) { return Effect.fail( - invalidCollectorInput( - 'A redacted Data Access Event requires a redaction profile and evidence payload', - ), + invalidCollectorInput('A redacted Data Access Event requires a redaction profile and evidence payload'), ); } if (hasUnexpectedRedactionProfile(event)) { - return Effect.fail( - invalidCollectorInput('A redaction profile is allowed only for redacted Data Access Events'), - ); + return Effect.fail(invalidCollectorInput('A redaction profile is allowed only for redacted Data Access Events')); } if (hasMetadataResultEvidence(event)) { - return Effect.fail( - invalidCollectorInput('Metadata-only Data Access evidence cannot contain result evidence'), - ); + return Effect.fail(invalidCollectorInput('Metadata-only Data Access evidence cannot contain result evidence')); } if (hasInvalidHashEvidence(event)) { return Effect.fail( - invalidCollectorInput( - 'Hash-only Data Access evidence requires a paired result fingerprint and schema', - ), + invalidCollectorInput('Hash-only Data Access evidence requires a paired result fingerprint and schema'), ); } if (hasUnsupportedResultEvidence(event)) { - return Effect.fail( - invalidCollectorInput('The Action runtime does not accept this result evidence shape'), - ); + return Effect.fail(invalidCollectorInput('The Action runtime does not accept this result evidence shape')); } - const targetParts = [ - event.targetModuleKey, - event.targetResourceType, - event.targetResourceId, - ].filter((part) => part !== undefined); + const targetParts = [event.targetModuleKey, event.targetResourceType, event.targetResourceId].filter( + (part) => part !== undefined, + ); if (targetParts.length !== 0 && targetParts.length !== 3) { - return Effect.fail( - invalidCollectorInput('A Data Access Event target must be fully specified or absent'), - ); + return Effect.fail(invalidCollectorInput('A Data Access Event target must be fully specified or absent')); } return Effect.succeed(event); @@ -152,9 +131,7 @@ export interface ActionCollector { readonly addDomainEvent: ( event: DeclaredDomainEvent, ) => Effect.Effect; - readonly addDomainEventInput: ( - event: Input, - ) => Effect.Effect; + readonly addDomainEventInput: (event: Input) => Effect.Effect; readonly addOutboxMessage: ( domainEvent: DomainEventReference, message: OutboxMessage, @@ -166,15 +143,9 @@ export interface ActionCollector { readonly recordAuditEvidence: ( evidence: Readonly>>, ) => Effect.Effect; - readonly recordAuditEvidenceInput: ( - evidence: Input, - ) => Effect.Effect; - readonly recordDataAccess: ( - event: DataAccessEventInput, - ) => Effect.Effect; - readonly recordDataAccessInput: ( - event: Input, - ) => Effect.Effect; + readonly recordAuditEvidenceInput: (evidence: Input) => Effect.Effect; + readonly recordDataAccess: (event: DataAccessEventInput) => Effect.Effect; + readonly recordDataAccessInput: (event: Input) => Effect.Effect; readonly snapshot: () => ActionEvidenceSnapshot; } @@ -193,46 +164,34 @@ export const createActionCollector = (); - const recordAuditEvidenceInput = ( - evidence: Input, - ): Effect.Effect => + const recordAuditEvidenceInput = (evidence: Input): Effect.Effect => Schema.decodeUnknownEffect(UnknownRecordSchema)(evidence).pipe( Effect.catchTag('SchemaError', () => Effect.fail(invalidCollectorInput('Action audit evidence must be a JSON object')), ), Effect.flatMap((evidenceRecord) => { if (hasAuditEvidence) { - return Effect.fail( - invalidCollectorInput('Action audit evidence may be recorded only once'), - ); + return Effect.fail(invalidCollectorInput('Action audit evidence may be recorded only once')); } if ( Schema.is(RuntimeActionKeyEvidenceSchema)(evidenceRecord) || Schema.is(RuntimeResultHashEvidenceSchema)(evidenceRecord) ) { - return Effect.fail( - invalidCollectorInput('Action audit evidence cannot replace runtime-owned fields'), - ); + return Effect.fail(invalidCollectorInput('Action audit evidence cannot replace runtime-owned fields')); } if (auditEvidenceSchema === undefined) { - return Effect.fail( - invalidCollectorInput('This Action does not declare custom audit evidence'), - ); + return Effect.fail(invalidCollectorInput('This Action does not declare custom audit evidence')); } const inputKeys = Object.keys(evidenceRecord).toSorted(); return Schema.decodeUnknownEffect(auditEvidenceSchema)(evidence).pipe( Effect.catchTag('SchemaError', () => - Effect.fail( - invalidCollectorInput('The Action audit evidence does not match its declared schema'), - ), + Effect.fail(invalidCollectorInput('The Action audit evidence does not match its declared schema')), ), Effect.map((declared) => ({ declared, inputKeys })), ); }), Effect.flatMap(({ declared, inputKeys }) => - Schema.decodeUnknownEffect(Schema.Json)(declared).pipe( - Effect.map((decoded) => ({ decoded, inputKeys })), - ), + Schema.decodeUnknownEffect(Schema.Json)(declared).pipe(Effect.map((decoded) => ({ decoded, inputKeys }))), ), Effect.catchTag('SchemaError', () => Effect.fail(invalidCollectorInput('The Action audit evidence is not valid JSON')), @@ -242,13 +201,8 @@ export const createActionCollector = key !== decodedKeys[index]) - ) { - return Effect.fail( - invalidCollectorInput('Action audit evidence contains undeclared fields'), - ); + if (inputKeys.length !== decodedKeys.length || inputKeys.some((key, index) => key !== decodedKeys[index])) { + return Effect.fail(invalidCollectorInput('Action audit evidence contains undeclared fields')); } return Schema.encodeEffect(JsonObjectJsonStringSchema)(decoded).pipe( Effect.catchTag('SchemaError', () => @@ -256,9 +210,7 @@ export const createActionCollector = { if (Buffer.byteLength(encoded, 'utf-8') > 4096) { - return Effect.fail( - invalidCollectorInput('Action audit evidence exceeds its size limit'), - ); + return Effect.fail(invalidCollectorInput('Action audit evidence exceeds its size limit')); } return Effect.sync(() => { auditEvidence = cloneAndFreeze(decoded); @@ -268,12 +220,9 @@ export const createActionCollector = ['recordAuditEvidence'] = - recordAuditEvidenceInput; + const recordAuditEvidence: ActionCollector['recordAuditEvidence'] = recordAuditEvidenceInput; - const recordDataAccessInput = ( - event: Input, - ): Effect.Effect => { + const recordDataAccessInput = (event: Input): Effect.Effect => { const eventRecord = Schema.is(UnknownRecordSchema)(event) ? event : undefined; const resultFingerprintHash = eventRecord?.['resultFingerprintHash']; const policyFields = Match.value(accessEvidencePolicy).pipe( @@ -293,8 +242,7 @@ export const createActionCollector = @@ -311,35 +259,25 @@ export const createActionCollector = ['recordDataAccess'] = recordDataAccessInput; - const addDomainEventInput = ( - event: Input, - ): Effect.Effect => + const addDomainEventInput = (event: Input): Effect.Effect => Schema.decodeUnknownEffect(DomainEventSchema)(event).pipe( Effect.catchTag('SchemaError', () => Effect.fail(invalidCollectorInput('The Domain Event is structurally invalid')), ), Effect.flatMap((decoded) => { if (decoded.producerModuleKey !== owningModuleKey) { - return Effect.fail( - invalidCollectorInput('A Domain Event producer must match the owning Action module'), - ); + return Effect.fail(invalidCollectorInput('A Domain Event producer must match the owning Action module')); } if (!Object.hasOwn(domainEventContracts, decoded.eventType)) { - return Effect.fail( - invalidCollectorInput('The Domain Event is not declared by this Action'), - ); + return Effect.fail(invalidCollectorInput('The Domain Event is not declared by this Action')); } const payloadSchema = domainEventContracts[decoded.eventType]; if (payloadSchema === undefined) { - return Effect.fail( - invalidCollectorInput('The Domain Event declaration has no payload schema'), - ); + return Effect.fail(invalidCollectorInput('The Domain Event declaration has no payload schema')); } - return Schema.decodeUnknownEffect(payloadSchema)(decoded.payloadJson).pipe( + return Schema.decodeEffect(payloadSchema)(decoded.payloadJson).pipe( Effect.catchTag('SchemaError', () => - Effect.fail( - invalidCollectorInput('The Domain Event payload violates its declared contract'), - ), + Effect.fail(invalidCollectorInput('The Domain Event payload violates its declared contract')), ), Effect.flatMap((payload) => Schema.decodeUnknownEffect(Schema.Json)(payload).pipe( @@ -377,9 +315,7 @@ export const createActionCollector = >>, ) => Effect.Effect; - readonly recordDataAccess: ( - event: DataAccessEventInput, - ) => Effect.Effect; + readonly recordDataAccess: (event: DataAccessEventInput) => Effect.Effect; } export interface ActionHandlerContext< diff --git a/app/packages/core-runtime/src/actions/definition.ts b/app/packages/core-runtime/src/actions/definition.ts index d8e825537..94a71e38b 100644 --- a/app/packages/core-runtime/src/actions/definition.ts +++ b/app/packages/core-runtime/src/actions/definition.ts @@ -1,21 +1,20 @@ import { Effect, Schema, Predicate } from 'effect'; + +import type { ScopedTransactionExecutor } from '../db/scoped-transaction.ts'; +import type { ModuleEntrypointDescriptor } from '../modules/module-entrypoint.ts'; +import { LEGAL_ENTITY_SCOPES } from '../operations/context.ts'; +import type { OperationalScope, LegalEntityScope } from '../operations/context.ts'; +import type { OperationContextUnavailable } from '../operations/errors.ts'; +import type { ResourceAccessTarget, TenantPermissionKey } from '../permissions/context-access.ts'; import type { ActionHandlerContext } from './context.ts'; import { ActionPayloadValidationError, ActionResultValidationError } from './errors.ts'; import type { ActionCollectorError } from './errors.ts'; import type { ActionAccessEvidencePolicy, DomainEventContractMap } from './events.ts'; import { isActionPolicy } from './policy.ts'; import type { ActionPolicy } from './policy.ts'; -import type { ModuleEntrypointDescriptor } from '../modules/module-entrypoint.ts'; -import type { ScopedTransactionExecutor } from '../db/scoped-transaction.ts'; -import { LEGAL_ENTITY_SCOPES } from '../operations/context.ts'; -import type { OperationalScope, LegalEntityScope } from '../operations/context.ts'; -import type { OperationContextUnavailable } from '../operations/errors.ts'; -import type { ResourceAccessTarget, TenantPermissionKey } from '../permissions/context-access.ts'; const actionRegistration: unique symbol = Symbol('@app/core-runtime/actions/registration'); -const actionResourcePermissionDeclaration: unique symbol = Symbol( - '@app/core-runtime/actions/resource-permission', -); +const actionResourcePermissionDeclaration: unique symbol = Symbol('@app/core-runtime/actions/resource-permission'); class ActionPrivateStorage { declare readonly [actionRegistration]?: true; @@ -65,10 +64,9 @@ export type ActionResourcePermissionDeclaration = ActionPrivateStorage< readonly kind: 'resource'; }; -const ActionDefinitionInvariantError = Schema.TaggedError()( - 'ActionDefinitionInvariantError', - { message: Schema.String }, -); +const ActionDefinitionInvariantError = Schema.TaggedError()('ActionDefinitionInvariantError', { + message: Schema.String, +}); const failActionDefinition = (message: string): never => { throw new ActionDefinitionInvariantError({ message }); @@ -132,9 +130,7 @@ export interface ActionDescriptor< * Declares an additional tenant-role permission required for the decoded payload. * Returning undefined means the Action executor relation is sufficient for that payload. */ - readonly tenantPermission?: ( - payload: PayloadSchema['Type'], - ) => ActionTenantPermission | undefined; + readonly tenantPermission?: (payload: PayloadSchema['Type']) => ActionTenantPermission | undefined; } export type ActionHandler< @@ -147,11 +143,7 @@ export type ActionHandler< > = ( payload: PayloadSchema['Type'], context: ActionHandlerContext, -) => Effect.Effect< - ResultSchema['Type'], - ActionCollectorError | DomainErrorSchema['Type'], - Requirements ->; +) => Effect.Effect; export type ActionServiceFactory = ( transaction: ScopedTransactionExecutor, @@ -160,8 +152,7 @@ export type ActionServiceFactory = ( type EmptyActionServices = Readonly>; const emptyActionServices: EmptyActionServices = Object.freeze({}); -const emptyActionServiceFactory: ActionServiceFactory = () => - Effect.succeed(emptyActionServices); +const emptyActionServiceFactory: ActionServiceFactory = () => Effect.succeed(emptyActionServices); type ActionRegistrationPrivateValue< PayloadSchema extends Schema.ConstraintDecoder, @@ -171,14 +162,7 @@ type ActionRegistrationPrivateValue< Services = Readonly>, HandlerRequirements = never, > = readonly [ - handler: ActionHandler< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Services, - HandlerRequirements - >, + handler: ActionHandler, serviceFactory: ActionServiceFactory, ]; @@ -203,9 +187,7 @@ export type ActionRegistration< readonly _handlerRequirements?: HandlerRequirements; readonly _services?: Services; readonly [actionRegistration]: true; - readonly descriptor: Readonly< - ActionDescriptor - >; + readonly descriptor: Readonly>; }; /** @@ -257,15 +239,12 @@ export interface ActionDescriptorValidationInput { readonly tenantPermission?: unknown; } -const validateActionEntrypoint = ( - descriptor: ActionDescriptorValidationInput, -): void => { +const validateActionEntrypoint = (descriptor: ActionDescriptorValidationInput): void => { if ( descriptor.entrypoint.role !== 'action' || descriptor.entrypoint.access !== 'write' || descriptor.entrypoint.moduleKey !== descriptor.owningModuleKey || - descriptor.entrypoint.scope !== - (descriptor.owningModuleKey.startsWith('core.') ? 'system' : 'tenant') || + descriptor.entrypoint.scope !== (descriptor.owningModuleKey.startsWith('core.') ? 'system' : 'tenant') || !Object.isFrozen(descriptor.entrypoint) ) { return failActionDefinition( @@ -273,32 +252,24 @@ const validateActionEntrypoint = ( ); } }; -const validateActionLegalEntityScope = ( - descriptor: ActionDescriptorValidationInput, -): void => { +const validateActionLegalEntityScope = (descriptor: ActionDescriptorValidationInput): void => { if (!LEGAL_ENTITY_SCOPES.some((scope) => scope === descriptor.legalEntityScope)) { - return failActionDefinition( - 'Action legal-entity scope must be required, optional, or forbidden', - ); + return failActionDefinition('Action legal-entity scope must be required, optional, or forbidden'); } if ( descriptor.legalEntityPermission !== undefined && - (descriptor.legalEntityPermission !== 'manage_counterparty' || - descriptor.legalEntityScope !== 'required') + (descriptor.legalEntityPermission !== 'manage_counterparty' || descriptor.legalEntityScope !== 'required') ) { return failActionDefinition( 'Action Legal Entity permission must be supported and require trusted Legal Entity scope', ); } }; -const validateActionPermissions = ( - descriptor: ActionDescriptorValidationInput, -): void => { +const validateActionPermissions = (descriptor: ActionDescriptorValidationInput): void => { if ( (descriptor.resourcePermission !== undefined && !Schema.is(ActionResourcePermissionDeclarationSchema)(descriptor.resourcePermission)) || - (descriptor.tenantPermission !== undefined && - !Predicate.isFunction(descriptor.tenantPermission)) + (descriptor.tenantPermission !== undefined && !Predicate.isFunction(descriptor.tenantPermission)) ) { return failActionDefinition('Action permission declarations and resolvers must be valid'); } @@ -308,9 +279,7 @@ const validateActionPolicies = ( policies: readonly Policy[] | undefined, ): void => { if (!Array.isArray(policies)) { - return failActionDefinition( - 'Action policies must be an explicit readonly array of Policy references', - ); + return failActionDefinition('Action policies must be an explicit readonly array of Policy references'); } validateActionPermissions(descriptor); for (const policy of policies) { @@ -318,15 +287,11 @@ const validateActionPolicies = ( return failActionDefinition('Action policies must contain direct Policy object references'); } if (policy.scope === 'microvertical' && policy.owningModuleKey !== descriptor.owningModuleKey) { - return failActionDefinition( - 'A MicroVertical Policy must be owned by the Action owning module', - ); + return failActionDefinition('A MicroVertical Policy must be owned by the Action owning module'); } } }; -export const validateActionDescriptorInput = ( - descriptor: ActionDescriptorValidationInput, -): void => { +export const validateActionDescriptorInput = (descriptor: ActionDescriptorValidationInput): void => { validateActionEntrypoint(descriptor); validateActionLegalEntityScope(descriptor); validateActionPolicies(descriptor, descriptor.policies); @@ -369,14 +334,7 @@ export function defineAction< >( descriptor: ActionDescriptor, ...definition: readonly [ - handler: ActionHandler< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Services, - HandlerRequirements - >, + handler: ActionHandler, serviceFactory: ActionServiceFactory, ] ): ActionRegistration< @@ -446,9 +404,7 @@ export function defineAction< const AnyActionRegistrationSchema = Schema.instanceOf(ActionPrivateStorage).check( Schema.makeFilter((registration) => - registration[actionRegistration] === true && - registration.descriptor !== undefined && - Object.isFrozen(registration) + registration[actionRegistration] === true && registration.descriptor !== undefined && Object.isFrozen(registration) ? undefined : 'Expected an immutable Action registration', ), @@ -477,14 +433,8 @@ export const getActionHandler = < Services, HandlerRequirements >, -): ActionHandler< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Services, - HandlerRequirements -> => ActionPrivateStorage.getValue(registration)[0]; +): ActionHandler => + ActionPrivateStorage.getValue(registration)[0]; export const getActionServiceFactory = < PayloadSchema extends Schema.ConstraintDecoder, @@ -504,8 +454,7 @@ export const getActionServiceFactory = < Services, HandlerRequirements >, -): ActionServiceFactory => - ActionPrivateStorage.getValue(registration)[1]; +): ActionServiceFactory => ActionPrivateStorage.getValue(registration)[1]; export const getActionResourcePermissionTargetResolver = < PayloadSchema extends Schema.ConstraintDecoder, @@ -530,10 +479,7 @@ export const getActionResourcePermissionTargetResolver = < ? undefined : ActionPrivateStorage.getValue(registration.descriptor.resourcePermission); -const preserveFailureCause = ( - failure: Failure, - cause: unknown, -): Failure => { +const preserveFailureCause = (failure: Failure, cause: unknown): Failure => { Object.defineProperty(failure, 'cause', { configurable: false, enumerable: false, @@ -543,10 +489,7 @@ const preserveFailureCause = ( return failure; }; -export const decodeActionPayload = < - PayloadSchema extends Schema.ConstraintDecoder, - Payload, ->( +export const decodeActionPayload = , Payload>( schema: PayloadSchema, payload: Payload, ): Effect.Effect => { @@ -576,9 +519,7 @@ export const decodeActionResult = => - Schema.encodeUnknownEffect(Schema.make>(schema.ast))( - result, - ).pipe( + Schema.encodeUnknownEffect(Schema.make>(schema.ast))(result).pipe( Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.mapError((cause) => preserveFailureCause( diff --git a/app/packages/core-runtime/src/actions/error-schema.ts b/app/packages/core-runtime/src/actions/error-schema.ts index 819e78eb2..91bf06ce0 100644 --- a/app/packages/core-runtime/src/actions/error-schema.ts +++ b/app/packages/core-runtime/src/actions/error-schema.ts @@ -1,10 +1,7 @@ import type { Cause } from 'effect'; import { Schema } from 'effect'; -export const actionErrorSchema = < - const Tag extends string, - const Fields extends Schema.Struct.Fields, ->( +export const actionErrorSchema = ( tag: Tag, fields: Fields, ) => { diff --git a/app/packages/core-runtime/src/actions/errors.ts b/app/packages/core-runtime/src/actions/errors.ts index 5f0398bae..bd8e8d422 100644 --- a/app/packages/core-runtime/src/actions/errors.ts +++ b/app/packages/core-runtime/src/actions/errors.ts @@ -1,11 +1,9 @@ import { Cause, Schema } from 'effect'; + +import type { ModuleStateCheckUnavailableError, ModuleStateDeniedError } from '../modules/module-state-gate-errors.ts'; +import type { OperationContextError } from '../operations/errors.ts'; import { actionErrorSchema } from './error-schema.ts'; import type { ActionTransactionError } from './transaction-error.ts'; -import type { - ModuleStateCheckUnavailableError, - ModuleStateDeniedError, -} from '../modules/module-state-gate-errors.ts'; -import type { OperationContextError } from '../operations/errors.ts'; export { ActionTransactionError } from './transaction-error.ts'; @@ -13,10 +11,7 @@ const safeReason = { reason: Schema.String, } as const; -const ActionInvocationIdSchema = Schema.String.pipe( - Schema.brand('ActionInvocationId'), - Schema.decodeTo(Schema.String), -); +const ActionInvocationIdSchema = Schema.String.pipe(Schema.brand('ActionInvocationId'), Schema.decodeTo(Schema.String)); const ActionPayloadValidationErrorValue = actionErrorSchema('ActionPayloadValidationError', { code: Schema.Literal('action_payload_invalid'), @@ -32,16 +27,11 @@ const ActionResultValidationErrorValue = actionErrorSchema('ActionResultValidati export type ActionResultValidationError = InstanceType; export { ActionResultValidationErrorValue as ActionResultValidationError }; -const ActionTrustedContextValidationErrorValue = actionErrorSchema( - 'ActionTrustedContextValidationError', - { - code: Schema.Literal('action_trusted_context_invalid'), - ...safeReason, - }, -); -export type ActionTrustedContextValidationError = InstanceType< - typeof ActionTrustedContextValidationErrorValue ->; +const ActionTrustedContextValidationErrorValue = actionErrorSchema('ActionTrustedContextValidationError', { + code: Schema.Literal('action_trusted_context_invalid'), + ...safeReason, +}); +export type ActionTrustedContextValidationError = InstanceType; export { ActionTrustedContextValidationErrorValue as ActionTrustedContextValidationError }; const ActionIdempotencyKeyRequiredValue = actionErrorSchema('ActionIdempotencyKeyRequired', { @@ -80,16 +70,11 @@ const ActionRequestHashConflictValue = actionErrorSchema('ActionRequestHashConfl export type ActionRequestHashConflict = InstanceType; export { ActionRequestHashConflictValue as ActionRequestHashConflict }; -const ActionInvocationPersistenceErrorValue = actionErrorSchema( - 'ActionInvocationPersistenceError', - { - code: Schema.Literal('action_invocation_persistence_failed'), - ...safeReason, - }, -); -export type ActionInvocationPersistenceError = InstanceType< - typeof ActionInvocationPersistenceErrorValue ->; +const ActionInvocationPersistenceErrorValue = actionErrorSchema('ActionInvocationPersistenceError', { + code: Schema.Literal('action_invocation_persistence_failed'), + ...safeReason, +}); +export type ActionInvocationPersistenceError = InstanceType; const ActionInvocationPersistenceErrorInternals = (() => { let createWithCause: ( props: ConstructorParameters[0], @@ -119,8 +104,7 @@ export { ActionInvocationPersistenceErrorClass as ActionInvocationPersistenceErr // Core-only accessors: deliberately excluded from the package root exports. export const createActionInvocationPersistenceErrorWithCause = ActionInvocationPersistenceErrorInternals.createWithCause; -export const getActionInvocationPersistenceErrorCause = - ActionInvocationPersistenceErrorInternals.readCause; +export const getActionInvocationPersistenceErrorCause = ActionInvocationPersistenceErrorInternals.readCause; const ActionInvocationNotFoundValue = actionErrorSchema('ActionInvocationNotFound', { code: Schema.Literal('action_invocation_not_found'), diff --git a/app/packages/core-runtime/src/actions/events.ts b/app/packages/core-runtime/src/actions/events.ts index a3fa824aa..967b9339d 100644 --- a/app/packages/core-runtime/src/actions/events.ts +++ b/app/packages/core-runtime/src/actions/events.ts @@ -1,10 +1,6 @@ import { Schema } from 'effect'; -import { - decodedStringBrand, - nonEmptyString, - TargetModuleKeySchema, - TargetResourceIdSchema, -} from './string-schemas.ts'; + +import { decodedStringBrand, nonEmptyString, TargetModuleKeySchema, TargetResourceIdSchema } from './string-schemas.ts'; const nonNegativeInteger = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); const EvidencePolicyKeySchema = decodedStringBrand(nonEmptyString, 'EvidencePolicyKey'); @@ -33,12 +29,7 @@ export type ActionAccessEvidencePolicy = export const DataAccessEventSchema = Schema.Struct({ accessKind: Schema.Literals(['read', 'list', 'search', 'export', 'download']), - evidenceCaptureMode: Schema.Literals([ - 'metadata_only', - 'hash_only', - 'redacted_payload', - 'stored_artifact', - ]), + evidenceCaptureMode: Schema.Literals(['metadata_only', 'hash_only', 'redacted_payload', 'stored_artifact']), evidencePayloadJson: Schema.optionalKey(Schema.Json), evidencePolicyKey: EvidencePolicyKeySchema, occurredAt: Schema.optionalKey(Schema.Date), @@ -74,10 +65,7 @@ export const DomainEventSchema = Schema.Struct({ export type DomainEvent = Schema.Schema.Type; export type DeclaredDomainEvent = { - readonly [EventType in keyof Contracts & string]: Omit< - DomainEvent, - 'eventType' | 'payloadJson' - > & { + readonly [EventType in keyof Contracts & string]: Omit & { readonly eventType: EventType; readonly payloadJson: Contracts[EventType]['Type']; }; @@ -91,9 +79,7 @@ export const OutboxMessageSchema = Schema.Struct({ export type OutboxMessage = Schema.Schema.Type; -const domainEventReferenceBrand: unique symbol = Symbol( - '@app/core-runtime/actions/events/DomainEventReference', -); +const domainEventReferenceBrand: unique symbol = Symbol('@app/core-runtime/actions/events/DomainEventReference'); /** Opaque reference produced only by one execution's Domain Event collector. */ export interface DomainEventReference { diff --git a/app/packages/core-runtime/src/actions/policy.ts b/app/packages/core-runtime/src/actions/policy.ts index 86f7c3e0f..f0660a3e8 100644 --- a/app/packages/core-runtime/src/actions/policy.ts +++ b/app/packages/core-runtime/src/actions/policy.ts @@ -1,5 +1,6 @@ import { Schema } from 'effect'; import type { Effect } from 'effect'; + import type { ActionTransportMetadata, TrustedPrincipalContext } from './context.ts'; const policyReference = '__actionPolicyReference' as const; @@ -30,10 +31,7 @@ const policyDeniedFields = { }; const PolicyDeniedContract = Schema.TaggedStruct('PolicyDenied', policyDeniedFields); type PolicyDeniedSelf = typeof PolicyDeniedContract.Type; -const PolicyDeniedValue = Schema.TaggedError()( - 'PolicyDenied', - policyDeniedFields, -); +const PolicyDeniedValue = Schema.TaggedError()('PolicyDenied', policyDeniedFields); export type PolicyDenied = InstanceType; export { PolicyDeniedValue as PolicyDenied }; @@ -51,10 +49,7 @@ export interface GlobalActionPolicy extends ActionPolicyBase { readonly scope: 'global'; } -export interface MicroverticalActionPolicy< - Payload, - Owner extends string, -> extends ActionPolicyBase { +export interface MicroverticalActionPolicy extends ActionPolicyBase { readonly owningModuleKey: Owner; readonly scope: 'microvertical'; } @@ -84,7 +79,10 @@ const requireStableIdentifier = (value: string, field: string): void => { }; const registerPolicy = (policy: Policy): Readonly => { - Object.defineProperty(policy, policyReference, { enumerable: false, value: true }); + Object.defineProperty(policy, policyReference, { + enumerable: false, + value: true, + }); const frozen = Object.freeze(policy); return frozen; }; @@ -95,9 +93,7 @@ export const denyPolicy = (reasonCode: string, reason: string): PolicyDenied => return Object.freeze(new PolicyDeniedValue({ reason, reasonCode })); }; -export const defineGlobalPolicy = ( - input: DefineGlobalPolicyInput, -): GlobalActionPolicy => { +export const defineGlobalPolicy = (input: DefineGlobalPolicyInput): GlobalActionPolicy => { requireStableIdentifier(input.policyKey, 'Policy key'); return registerPolicy({ evaluate: input.evaluate, @@ -131,16 +127,12 @@ const ActionPolicyReferenceSchema = Schema.Union([ }), Schema.Struct({ evaluate: Schema.Any, - owningModuleKey: Schema.String.pipe( - Schema.brand('OwningModuleKey'), - Schema.decodeTo(Schema.String), - ), + owningModuleKey: Schema.String.pipe(Schema.brand('OwningModuleKey'), Schema.decodeTo(Schema.String)), policyKey: Schema.String.pipe(Schema.brand('PolicyKey'), Schema.decodeTo(Schema.String)), [policyReference]: Schema.Literal(true), scope: Schema.Literal('microvertical'), }), ]); -export const isActionPolicy: ( - value: ActionPolicy, -) => value is ActionPolicy = Schema.is(ActionPolicyReferenceSchema); +export const isActionPolicy: (value: ActionPolicy) => value is ActionPolicy = + Schema.is(ActionPolicyReferenceSchema); diff --git a/app/packages/core-runtime/src/actions/principal-context.ts b/app/packages/core-runtime/src/actions/principal-context.ts index ea007f317..da8dfa6d4 100644 --- a/app/packages/core-runtime/src/actions/principal-context.ts +++ b/app/packages/core-runtime/src/actions/principal-context.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { decodedStringBrand, nonEmptyString } from './string-schemas.ts'; const uuid = Schema.String.check(Schema.isUUID()); @@ -19,13 +20,9 @@ const TrustedPrincipalContextFieldsSchema = Schema.Struct({ }); type TrustedPrincipalContextFields = typeof TrustedPrincipalContextFieldsSchema.Type; -type PrincipalContextValidator = ( - context: TrustedPrincipalContextFields, -) => readonly Schema.FilterIssue[]; +type PrincipalContextValidator = (context: TrustedPrincipalContextFields) => readonly Schema.FilterIssue[]; -const issue = (message: string): readonly Schema.FilterIssue[] => [ - { issue: message, path: ['authMethod'] }, -]; +const issue = (message: string): readonly Schema.FilterIssue[] => [{ issue: message, path: ['authMethod'] }]; const validateApiKeyContext: PrincipalContextValidator = (context) => context.authBindingId === undefined || diff --git a/app/packages/core-runtime/src/actions/repository.ts b/app/packages/core-runtime/src/actions/repository.ts index 9ee326d8a..0c397ef93 100644 --- a/app/packages/core-runtime/src/actions/repository.ts +++ b/app/packages/core-runtime/src/actions/repository.ts @@ -1,8 +1,10 @@ +import { createHash, randomUUID } from 'node:crypto'; + import { and, eq, inArray, isNull } from 'drizzle-orm'; import type { Cause } from 'effect'; import { Context, DateTime, Effect, Layer, Predicate, Result, Schema } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; -import { createHash, randomUUID } from 'node:crypto'; + import type { ActionInvocationStatus } from '../db/schema.ts'; import { actionInvocations, @@ -23,17 +25,9 @@ import { getActionInvocationPersistenceErrorCause, } from './errors.ts'; import type { ActionEvidenceSnapshot } from './events.ts'; -import { - createActionTransactionErrorWithCause, - getActionTransactionErrorCause, -} from './transaction-error.ts'; - -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +import { createActionTransactionErrorWithCause, getActionTransactionErrorCause } from './transaction-error.ts'; + +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -47,10 +41,7 @@ export interface ActionRequestHashInput { readonly owningModuleKey: string; readonly principal: TrustedPrincipalContext; readonly schemaVersion: string; - readonly target: Pick< - ActionTransportMetadata, - 'targetModuleKey' | 'targetResourceId' | 'targetResourceType' - >; + readonly target: Pick; } type CanonicalValue = @@ -282,17 +273,11 @@ export interface ActionRepositoryService { readonly rejectPermissionDenied: ( executor: CoreDatabaseExecutor, input: RejectPermissionDeniedInput, - ) => Effect.Effect< - void, - ActionInvocationPersistenceError | ActionInvocationStateError | ActionTransactionError - >; + ) => Effect.Effect; readonly resolveInvocation: ( executor: CoreDatabaseExecutor, input: ResolveActionInvocationInput, - ) => Effect.Effect< - ActionInvocationRecord, - ActionInvocationNotFound | ActionInvocationPersistenceError - >; + ) => Effect.Effect; readonly transitionInvocationToRunning: ( executor: CoreDatabaseExecutor, invocationId: string, @@ -338,10 +323,7 @@ export const logActionInvocationPersistenceFailureCause = ( const cause = getActionInvocationPersistenceErrorCause(failure); return cause === undefined ? Effect.void - : Effect.annotateLogs( - Effect.logError('Unexpected Action invocation persistence failure', cause), - annotations, - ); + : Effect.annotateLogs(Effect.logError('Unexpected Action invocation persistence failure', cause), annotations); }; const transactionFailure = (reason: string, cause?: FailureCause) => { @@ -360,9 +342,8 @@ const transactionFailure = (reason: string, cause?: FailureCause) * * @internal */ -export const getActionTransactionFailureCause = ( - failure: ActionTransactionError, -): Cause.Cause | undefined => getActionTransactionErrorCause(failure); +export const getActionTransactionFailureCause = (failure: ActionTransactionError): Cause.Cause | undefined => + getActionTransactionErrorCause(failure); /** * Logs the privately retained transaction defect. @@ -375,18 +356,13 @@ export const logActionTransactionFailureCause = ( annotations: Readonly>, ): Effect.Effect => { const cause = getActionTransactionErrorCause(failure); - return cause === undefined - ? Effect.void - : Effect.annotateLogs(Effect.logError(message, cause), annotations); + return cause === undefined ? Effect.void : Effect.annotateLogs(Effect.logError(message, cause), annotations); }; export const makeActionRepository = (): ActionRepositoryService => { const createOrResolveInvocation: ActionRepositoryService['createOrResolveInvocation'] = Effect.fn( 'makeActionRepository.createOrResolveInvocation', - )(function* createOrResolveInvocationEffect( - executor: CoreDatabaseExecutor, - input: PrepareActionInvocationInput, - ) { + )(function* createOrResolveInvocationEffect(executor: CoreDatabaseExecutor, input: PrepareActionInvocationInput) { const failureReason = 'Unable to create or resolve the Action invocation'; const inserted = yield* executor .insert(actionInvocations) @@ -453,26 +429,28 @@ export const makeActionRepository = (): ActionRepositoryService => { return resolved; }); - const lockInvocation: ActionRepositoryService['lockInvocation'] = Effect.fn( - 'makeActionRepository.lockInvocation', - )(function* lockInvocationEffect(transaction: CoreTransaction, invocationId: string) { - const failureReason = 'Unable to lock the Action invocation'; - const rows = yield* transaction - .select(invocationSelection) - .from(actionInvocations) - .where(eq(actionInvocations.actionInvocationId, invocationId)) - .for('update') - .limit(1) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const [invocation] = rows; - if (invocation === undefined) { - return yield* persistenceFailure( - failureReason, - new RepositoryInvariantError({ reason: 'The Action invocation no longer exists' }), - ); - } - return invocation; - }); + const lockInvocation: ActionRepositoryService['lockInvocation'] = Effect.fn('makeActionRepository.lockInvocation')( + function* lockInvocationEffect(transaction: CoreTransaction, invocationId: string) { + const failureReason = 'Unable to lock the Action invocation'; + const rows = yield* transaction + .select(invocationSelection) + .from(actionInvocations) + .where(eq(actionInvocations.actionInvocationId, invocationId)) + .for('update') + .limit(1) + .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + const [invocation] = rows; + if (invocation === undefined) { + return yield* persistenceFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'The Action invocation no longer exists', + }), + ); + } + return invocation; + }, + ); const resolveInvocation: ActionRepositoryService['resolveInvocation'] = (executor, input) => executor @@ -488,9 +466,7 @@ export const makeActionRepository = (): ActionRepositoryService => { .for('update') .limit(1) .pipe( - Effect.mapError((cause) => - persistenceFailure('Unable to resolve the Action invocation commit state', cause), - ), + Effect.mapError((cause) => persistenceFailure('Unable to resolve the Action invocation commit state', cause)), Effect.flatMap(([invocation]) => invocation === undefined ? Effect.fail( @@ -503,52 +479,47 @@ export const makeActionRepository = (): ActionRepositoryService => { ), ); - const transitionInvocationToRunning: ActionRepositoryService['transitionInvocationToRunning'] = - Effect.fn('makeActionRepository.transitionInvocationToRunning')( - function* transitionInvocationToRunningEffect( - executor: CoreDatabaseExecutor, - invocationId: string, - ) { - const failureReason = 'Unable to transition the Action invocation to running'; - const transitioned = yield* executor - .update(actionInvocations) - .set({ status: 'running' }) - .where( - and( - eq(actionInvocations.actionInvocationId, invocationId), - inArray(actionInvocations.status, ['received', 'running']), - isNull(actionInvocations.completedAt), - ), - ) - .returning(invocationSelection) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const [invocation] = transitioned; - if (invocation !== undefined) { - return invocation; - } - const current = yield* executor - .select(invocationSelection) - .from(actionInvocations) - .where(eq(actionInvocations.actionInvocationId, invocationId)) - .limit(1) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const [resolved] = current; - if (resolved === undefined) { - return yield* persistenceFailure( - failureReason, - new RepositoryInvariantError({ reason: 'The Action invocation no longer exists' }), - ); - } - return resolved; - }, - ); + const transitionInvocationToRunning: ActionRepositoryService['transitionInvocationToRunning'] = Effect.fn( + 'makeActionRepository.transitionInvocationToRunning', + )(function* transitionInvocationToRunningEffect(executor: CoreDatabaseExecutor, invocationId: string) { + const failureReason = 'Unable to transition the Action invocation to running'; + const transitioned = yield* executor + .update(actionInvocations) + .set({ status: 'running' }) + .where( + and( + eq(actionInvocations.actionInvocationId, invocationId), + inArray(actionInvocations.status, ['received', 'running']), + isNull(actionInvocations.completedAt), + ), + ) + .returning(invocationSelection) + .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + const [invocation] = transitioned; + if (invocation !== undefined) { + return invocation; + } + const current = yield* executor + .select(invocationSelection) + .from(actionInvocations) + .where(eq(actionInvocations.actionInvocationId, invocationId)) + .limit(1) + .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + const [resolved] = current; + if (resolved === undefined) { + return yield* persistenceFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'The Action invocation no longer exists', + }), + ); + } + return resolved; + }); const rejectPermissionDenied: ActionRepositoryService['rejectPermissionDenied'] = Effect.fn( 'makeActionRepository.rejectPermissionDenied', - )(function* rejectPermissionDeniedEffect( - executor: CoreDatabaseExecutor, - input: RejectPermissionDeniedInput, - ) { + )(function* rejectPermissionDeniedEffect(executor: CoreDatabaseExecutor, input: RejectPermissionDeniedInput) { const failureReason = 'Unable to persist Action permission denial evidence'; const transactionBody = Effect.fn('rejectPermissionDenied.transactionBody')( function* rejectPermissionDeniedTransaction(transaction: CoreTransaction) { @@ -612,13 +583,9 @@ export const makeActionRepository = (): ActionRepositoryService => { ); yield* executor.transaction(transactionBody).pipe( - Effect.catchTag('SqlError', (failure) => - Effect.fail(transactionFailure(failureReason, failure)), - ), + Effect.catchTag('SqlError', (failure) => Effect.fail(transactionFailure(failureReason, failure))), Effect.catchDefect((defect) => - isSqlError(defect) - ? Effect.fail(transactionFailure(failureReason, defect)) - : Effect.die(defect), + isSqlError(defect) ? Effect.fail(transactionFailure(failureReason, defect)) : Effect.die(defect), ), ); return yield* Effect.void; @@ -626,63 +593,119 @@ export const makeActionRepository = (): ActionRepositoryService => { const finalizePolicyDenial: ActionRepositoryService['finalizePolicyDenial'] = Effect.fn( 'makeActionRepository.finalizePolicyDenial', - )(function* finalizePolicyDenialEffect( - executor: CoreDatabaseExecutor, - input: FinalizeActionPolicyDenialInput, - ) { + )(function* finalizePolicyDenialEffect(executor: CoreDatabaseExecutor, input: FinalizeActionPolicyDenialInput) { const failureReason = 'Unable to persist the rejected Action invocation'; - const transactionBody = Effect.fn('finalizePolicyDenial.transactionBody')( - function* finalizePolicyDenialTransaction(transaction: CoreTransaction) { - const rows = yield* transaction - .select(invocationSelection) - .from(actionInvocations) - .where(eq(actionInvocations.actionInvocationId, input.actionInvocationId)) - .for('update') - .limit(1) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const [invocation] = rows; - if (invocation === undefined) { - return yield* persistenceFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'The Action invocation no longer exists', - }), - ); - } - if (invocation.status === 'rejected' && invocation.completedAt !== null) { - return yield* Effect.void; - } - if (invocation.status !== 'received' || invocation.completedAt !== null) { - return yield* persistenceFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'The Action invocation is no longer open for Policy rejection', - }), - ); - } - - const policyEvidence = withOptionalProperty( - { actionKey: input.actionKey }, - input.policy.owningModuleKey !== undefined, - 'owningModuleKey', - input.policy.owningModuleKey, - { policyKey: input.policy.policyKey, policyScope: input.policy.scope }, + const transactionBody = Effect.fn('finalizePolicyDenial.transactionBody')(function* finalizePolicyDenialTransaction( + transaction: CoreTransaction, + ) { + const rows = yield* transaction + .select(invocationSelection) + .from(actionInvocations) + .where(eq(actionInvocations.actionInvocationId, input.actionInvocationId)) + .for('update') + .limit(1) + .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + const [invocation] = rows; + if (invocation === undefined) { + return yield* persistenceFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'The Action invocation no longer exists', + }), + ); + } + if (invocation.status === 'rejected' && invocation.completedAt !== null) { + return yield* Effect.void; + } + if (invocation.status !== 'received' || invocation.completedAt !== null) { + return yield* persistenceFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'The Action invocation is no longer open for Policy rejection', + }), ); + } + + const policyEvidence = withOptionalProperty( + { actionKey: input.actionKey }, + input.policy.owningModuleKey !== undefined, + 'owningModuleKey', + input.policy.owningModuleKey, + { + policyKey: input.policy.policyKey, + policyScope: input.policy.scope, + }, + ); + yield* transaction + .insert(auditEvents) + .values( + ['action.policy_checked', 'action.rejected'].map((eventType) => ({ + actionInvocationId: input.actionInvocationId, + auditProfile: input.auditProfile, + authBindingId: input.principal.authBindingId, + authContextRef: input.principal.authContextRef, + authMethod: input.principal.authMethod, + eventType, + evidenceJson: policyEvidence, + impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, + legalEntityId: input.principal.legalEntityId, + outcome: 'denied', + outcomeCode: input.reasonCode, + outcomeStage: 'policy', + principalId: input.principal.principalId, + targetModuleKey: input.transport.targetModuleKey, + targetResourceId: input.transport.targetResourceId, + targetResourceType: input.transport.targetResourceType, + tenantId: input.principal.tenantId, + })), + ) + .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + + yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( + Effect.mapError((cause) => persistenceFailure(failureReason, cause)), + ); + return yield* Effect.void; + }); + + yield* executor.transaction(transactionBody).pipe( + Effect.catchTag('SqlError', (failure) => Effect.fail(persistenceFailure(failureReason, failure))), + Effect.catchDefect((defect) => + isSqlError(defect) ? Effect.fail(persistenceFailure(failureReason, defect)) : Effect.die(defect), + ), + ); + return yield* Effect.void; + }); + + const flushSuccess: ActionRepositoryService['flushSuccess'] = Effect.fn('makeActionRepository.flushSuccess')( + function* flushSuccessEffect(transaction: CoreTransaction, input: FlushActionSuccessInput) { + const failureReason = 'Unable to persist successful Action evidence'; + if (input.allowedPolicies.length > 0) { yield* transaction .insert(auditEvents) .values( - ['action.policy_checked', 'action.rejected'].map((eventType) => ({ + input.allowedPolicies.map((policy) => ({ actionInvocationId: input.actionInvocationId, auditProfile: input.auditProfile, authBindingId: input.principal.authBindingId, authContextRef: input.principal.authContextRef, authMethod: input.principal.authMethod, - eventType, - evidenceJson: policyEvidence, + eventType: 'action.policy_checked', + evidenceJson: withOptionalProperty( + { + actionKey: input.actionKey, + }, + policy.owningModuleKey !== undefined, + 'owningModuleKey', + policy.owningModuleKey, + { + policyKey: policy.policyKey, + policyScope: policy.scope, + }, + ), impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, legalEntityId: input.principal.legalEntityId, - outcome: 'denied', - outcomeCode: input.reasonCode, + outcome: 'allowed', + outcomeCode: 'policy_allowed', outcomeStage: 'policy', principalId: input.principal.principalId, targetModuleKey: input.transport.targetModuleKey, @@ -691,228 +714,171 @@ export const makeActionRepository = (): ActionRepositoryService => { tenantId: input.principal.tenantId, })), ) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - - yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( - Effect.mapError((cause) => persistenceFailure(failureReason, cause)), - ); - return yield* Effect.void; - }, - ); - - yield* executor.transaction(transactionBody).pipe( - Effect.catchTag('SqlError', (failure) => - Effect.fail(persistenceFailure(failureReason, failure)), - ), - Effect.catchDefect((defect) => - isSqlError(defect) - ? Effect.fail(persistenceFailure(failureReason, defect)) - : Effect.die(defect), - ), - ); - return yield* Effect.void; - }); + .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + } - const flushSuccess: ActionRepositoryService['flushSuccess'] = Effect.fn( - 'makeActionRepository.flushSuccess', - )(function* flushSuccessEffect(transaction: CoreTransaction, input: FlushActionSuccessInput) { - const failureReason = 'Unable to persist successful Action evidence'; - if (input.allowedPolicies.length > 0) { yield* transaction .insert(auditEvents) - .values( - input.allowedPolicies.map((policy) => ({ - actionInvocationId: input.actionInvocationId, - auditProfile: input.auditProfile, - authBindingId: input.principal.authBindingId, - authContextRef: input.principal.authContextRef, - authMethod: input.principal.authMethod, - eventType: 'action.policy_checked', - evidenceJson: withOptionalProperty( - { - actionKey: input.actionKey, - }, - policy.owningModuleKey !== undefined, - 'owningModuleKey', - policy.owningModuleKey, - { - policyKey: policy.policyKey, - policyScope: policy.scope, - }, - ), - impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, - legalEntityId: input.principal.legalEntityId, - outcome: 'allowed', - outcomeCode: 'policy_allowed', - outcomeStage: 'policy', - principalId: input.principal.principalId, - targetModuleKey: input.transport.targetModuleKey, - targetResourceId: input.transport.targetResourceId, - targetResourceType: input.transport.targetResourceType, - tenantId: input.principal.tenantId, - })), - ) + .values({ + actionInvocationId: input.actionInvocationId, + auditProfile: input.auditProfile, + authBindingId: input.principal.authBindingId, + authContextRef: input.principal.authContextRef, + authMethod: input.principal.authMethod, + eventType: 'action.executed', + evidenceJson: { + ...input.evidence.auditEvidence, + actionKey: input.actionKey, + resultHash: input.resultHash, + }, + impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, + legalEntityId: input.principal.legalEntityId, + outcome: 'succeeded', + outcomeCode: 'action_executed', + outcomeStage: 'execution', + principalId: input.principal.principalId, + targetModuleKey: input.transport.targetModuleKey, + targetResourceId: input.transport.targetResourceId, + targetResourceType: input.transport.targetResourceType, + tenantId: input.principal.tenantId, + }) .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - } - yield* transaction - .insert(auditEvents) - .values({ + if (input.evidence.dataAccessEvents.length > 0) { + yield* transaction + .insert(dataAccessEvents) + .values( + input.evidence.dataAccessEvents.map((event) => ({ + accessKind: event.accessKind, + actionInvocationId: input.actionInvocationId, + authBindingId: input.principal.authBindingId, + authContextRef: input.principal.authContextRef, + authMethod: input.principal.authMethod, + evidenceCaptureMode: event.evidenceCaptureMode, + evidencePayloadJson: event.evidencePayloadJson, + evidencePolicyKey: event.evidencePolicyKey, + impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, + legalEntityId: input.principal.legalEntityId, + occurredAt: event.occurredAt, + outcome: 'allowed', + outcomeCode: 'action_read_allowed', + outcomeStage: 'execution', + principalId: input.principal.principalId, + queryHash: event.queryHash, + redactionProfile: event.redactionProfile, + resultCount: event.resultCount, + resultFingerprintHash: event.resultFingerprintHash, + resultFingerprintSchema: event.resultFingerprintSchema, + servingModuleKey: event.servingModuleKey, + targetModuleKey: event.targetModuleKey, + targetResourceId: event.targetResourceId, + targetResourceType: event.targetResourceType, + tenantId: input.principal.tenantId, + })), + ) + .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + } + + if (input.evidence.domainEvents.length > 0) { + // The tenant row is the existing, typed per-tenant serialization + // anchor. Holding this lock until commit ensures sequence allocation + // order cannot overtake commit order for one tenant's event stream. + const lockedTenant = yield* transaction + .select({ tenantId: tenants.tenantId }) + .from(tenants) + .where(eq(tenants.tenantId, input.principal.tenantId)) + .for('update') + .limit(1) + .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + if (lockedTenant.length !== 1) { + return yield* transactionFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'The Domain Event tenant does not exist', + }), + ); + } + } + + const persistedDomainEvents = input.evidence.domainEvents.map((event) => ({ actionInvocationId: input.actionInvocationId, - auditProfile: input.auditProfile, - authBindingId: input.principal.authBindingId, - authContextRef: input.principal.authContextRef, - authMethod: input.principal.authMethod, - eventType: 'action.executed', - evidenceJson: { - ...input.evidence.auditEvidence, - actionKey: input.actionKey, - resultHash: input.resultHash, - }, - impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, + domainEventId: randomUUID(), + eventType: event.eventType, legalEntityId: input.principal.legalEntityId, - outcome: 'succeeded', - outcomeCode: 'action_executed', - outcomeStage: 'execution', - principalId: input.principal.principalId, - targetModuleKey: input.transport.targetModuleKey, - targetResourceId: input.transport.targetResourceId, - targetResourceType: input.transport.targetResourceType, + occurredAt: event.occurredAt, + payloadJson: event.payloadJson, + producerModuleKey: event.producerModuleKey, + subjectModuleKey: event.subjectModuleKey, + subjectResourceId: event.subjectResourceId, + subjectResourceType: event.subjectResourceType, tenantId: input.principal.tenantId, - }) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + })); - if (input.evidence.dataAccessEvents.length > 0) { - yield* transaction - .insert(dataAccessEvents) - .values( - input.evidence.dataAccessEvents.map((event) => ({ - accessKind: event.accessKind, - actionInvocationId: input.actionInvocationId, - authBindingId: input.principal.authBindingId, - authContextRef: input.principal.authContextRef, - authMethod: input.principal.authMethod, - evidenceCaptureMode: event.evidenceCaptureMode, - evidencePayloadJson: event.evidencePayloadJson, - evidencePolicyKey: event.evidencePolicyKey, - impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, - legalEntityId: input.principal.legalEntityId, - occurredAt: event.occurredAt, - outcome: 'allowed', - outcomeCode: 'action_read_allowed', - outcomeStage: 'execution', - principalId: input.principal.principalId, - queryHash: event.queryHash, - redactionProfile: event.redactionProfile, - resultCount: event.resultCount, - resultFingerprintHash: event.resultFingerprintHash, - resultFingerprintSchema: event.resultFingerprintSchema, - servingModuleKey: event.servingModuleKey, - targetModuleKey: event.targetModuleKey, - targetResourceId: event.targetResourceId, - targetResourceType: event.targetResourceType, - tenantId: input.principal.tenantId, - })), + if (persistedDomainEvents.length > 0) { + yield* transaction + .insert(domainEvents) + .values(persistedDomainEvents) + .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + } + + if (input.evidence.outboxMessages.length > 0) { + const persistedOutboxMessages = yield* Effect.forEach( + input.evidence.outboxMessages, + (collected) => { + const persistedDomainEvent = persistedDomainEvents[collected.domainEventIndex]; + if (persistedDomainEvent === undefined) { + return Effect.fail( + transactionFailure( + failureReason, + new RepositoryInvariantError({ + reason: 'An Outbox Message has no persisted Domain Event', + }), + ), + ); + } + return Effect.succeed({ + domainEventId: persistedDomainEvent.domainEventId, + payloadJson: collected.message.payloadJson, + producerModuleKey: collected.message.producerModuleKey, + tenantId: input.principal.tenantId, + topic: collected.message.topic, + }); + }, + { concurrency: 1 }, + ); + yield* transaction + .insert(outboxMessages) + .values(persistedOutboxMessages) + .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + } + + const completedAt = yield* DateTime.nowAsDate; + const succeeded = yield* transaction + .update(actionInvocations) + .set({ + completedAt, + status: 'succeeded', + }) + .where( + and( + eq(actionInvocations.actionInvocationId, input.actionInvocationId), + eq(actionInvocations.status, 'running'), + ), ) + .returning({ actionInvocationId: actionInvocations.actionInvocationId }) .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - } - if (input.evidence.domainEvents.length > 0) { - // The tenant row is the existing, typed per-tenant serialization - // anchor. Holding this lock until commit ensures sequence allocation - // order cannot overtake commit order for one tenant's event stream. - const lockedTenant = yield* transaction - .select({ tenantId: tenants.tenantId }) - .from(tenants) - .where(eq(tenants.tenantId, input.principal.tenantId)) - .for('update') - .limit(1) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - if (lockedTenant.length !== 1) { + if (succeeded.length !== 1) { return yield* transactionFailure( failureReason, - new RepositoryInvariantError({ reason: 'The Domain Event tenant does not exist' }), + new RepositoryInvariantError({ + reason: 'The Action invocation could not be marked succeeded', + }), ); } - } - - const persistedDomainEvents = input.evidence.domainEvents.map((event) => ({ - actionInvocationId: input.actionInvocationId, - domainEventId: randomUUID(), - eventType: event.eventType, - legalEntityId: input.principal.legalEntityId, - occurredAt: event.occurredAt, - payloadJson: event.payloadJson, - producerModuleKey: event.producerModuleKey, - subjectModuleKey: event.subjectModuleKey, - subjectResourceId: event.subjectResourceId, - subjectResourceType: event.subjectResourceType, - tenantId: input.principal.tenantId, - })); - - if (persistedDomainEvents.length > 0) { - yield* transaction - .insert(domainEvents) - .values(persistedDomainEvents) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - } - - if (input.evidence.outboxMessages.length > 0) { - const persistedOutboxMessages = yield* Effect.all( - input.evidence.outboxMessages.map((collected) => { - const persistedDomainEvent = persistedDomainEvents[collected.domainEventIndex]; - if (persistedDomainEvent === undefined) { - return Effect.fail( - transactionFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'An Outbox Message has no persisted Domain Event', - }), - ), - ); - } - return Effect.succeed({ - domainEventId: persistedDomainEvent.domainEventId, - payloadJson: collected.message.payloadJson, - producerModuleKey: collected.message.producerModuleKey, - tenantId: input.principal.tenantId, - topic: collected.message.topic, - }); - }), - { concurrency: 1 }, - ); - yield* transaction - .insert(outboxMessages) - .values(persistedOutboxMessages) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - } - - const completedAt = yield* DateTime.nowAsDate; - const succeeded = yield* transaction - .update(actionInvocations) - .set({ - completedAt, - status: 'succeeded', - }) - .where( - and( - eq(actionInvocations.actionInvocationId, input.actionInvocationId), - eq(actionInvocations.status, 'running'), - ), - ) - .returning({ actionInvocationId: actionInvocations.actionInvocationId }) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - - if (succeeded.length !== 1) { - return yield* transactionFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'The Action invocation could not be marked succeeded', - }), - ); - } - return yield* Effect.void; - }); + return yield* Effect.void; + }, + ); return Object.freeze({ createOrResolveInvocation, diff --git a/app/packages/core-runtime/src/actions/runtime.ts b/app/packages/core-runtime/src/actions/runtime.ts index 2da9ab2dd..911524019 100644 --- a/app/packages/core-runtime/src/actions/runtime.ts +++ b/app/packages/core-runtime/src/actions/runtime.ts @@ -1,13 +1,26 @@ import { Cause, Context, Effect, Exit, Layer, Option, Ref, Result, Schema } from 'effect'; import type { SqlError } from 'effect/unstable/sql/SqlError'; import { ConnectionError, UnknownError, isSqlError } from 'effect/unstable/sql/SqlError'; + import { decodeTrustedPrincipalContext, isTrustedSupportRecoveryPrincipalContext, } from '../auth/system-principal-context-provenance.ts'; +import { isDatabaseCommitAcknowledgementAmbiguous } from '../database/driver-failure.ts'; import { findPostgresFailure } from '../database/postgres-failure.ts'; import { CoreDatabase as CoreDatabaseService } from '../db/client.ts'; +import { installOperationalScope } from '../db/scoped-transaction.ts'; import type { CoreTransaction } from '../db/types.ts'; +import type { ModuleEntrypointGatewayService } from '../modules/module-entrypoint-gateway.ts'; +import { ModuleEntrypointGateway } from '../modules/module-entrypoint-gateway.ts'; +import type { TenantModuleEntrypoint } from '../modules/module-entrypoint.ts'; +import type { ModuleStateGateService } from '../modules/module-state-gate.ts'; +import { ModuleStateGate } from '../modules/module-state-gate.ts'; +import type { OperationalScope, OperationalScopeResolverService } from '../operations/context.ts'; +import { OperationalScopeResolver } from '../operations/context.ts'; +import { ContextAccess } from '../permissions/context-access.ts'; +import type { ActionPermissionService } from '../permissions/service.ts'; +import { ActionPermission } from '../permissions/service.ts'; import { createActionCollector } from './collector.ts'; import type { ActionTransportMetadata, TrustedPrincipalContext } from './context.ts'; import { ActionTransportMetadataSchema } from './context.ts'; @@ -42,28 +55,11 @@ import { ActionTransactionError, ActionTrustedContextValidationError, } from './errors.ts'; - -import { isDatabaseCommitAcknowledgementAmbiguous } from '../database/driver-failure.ts'; -import { installOperationalScope } from '../db/scoped-transaction.ts'; -import type { ModuleEntrypointGatewayService } from '../modules/module-entrypoint-gateway.ts'; -import { ModuleEntrypointGateway } from '../modules/module-entrypoint-gateway.ts'; -import type { TenantModuleEntrypoint } from '../modules/module-entrypoint.ts'; -import type { ModuleStateGateService } from '../modules/module-state-gate.ts'; -import { ModuleStateGate } from '../modules/module-state-gate.ts'; -import type { OperationalScope, OperationalScopeResolverService } from '../operations/context.ts'; -import { OperationalScopeResolver } from '../operations/context.ts'; -import { ContextAccess } from '../permissions/context-access.ts'; -import type { ActionPermissionService } from '../permissions/service.ts'; -import { ActionPermission } from '../permissions/service.ts'; import type { ActionCoreError, ActionInvocationNotFound } from './errors.ts'; import type { DomainEventContractMap } from './events.ts'; import type { ActionPolicy, ActionPolicyEvaluatorInput } from './policy.ts'; import { PolicyDenied } from './policy.ts'; -import type { - ActionInvocationRecord, - ActionPolicyEvidence, - ActionRepositoryService, -} from './repository.ts'; +import type { ActionInvocationRecord, ActionPolicyEvidence, ActionRepositoryService } from './repository.ts'; import { ActionRepository, computeActionRequestHash, @@ -82,12 +78,7 @@ const requireIdempotencyKey = (idempotency: string, transport: ActionTransportMe ) : Effect.void; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -95,11 +86,12 @@ const withOptionalProperty = < trailing: Trailing, ) => (condition ? { ...base, [key]: value, ...trailing } : { ...base, ...trailing }); -const attachFailureCause = ( - failure: Failure, - cause: FailureCause, -): Failure => { - Object.defineProperty(failure, 'cause', { configurable: false, enumerable: false, value: cause }); +const attachFailureCause = (failure: Failure, cause: FailureCause): Failure => { + Object.defineProperty(failure, 'cause', { + configurable: false, + enumerable: false, + value: cause, + }); return failure; }; @@ -149,9 +141,7 @@ export interface ResolveActionCommitInput { readonly principal: unknown; } -const ActionInvocationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('ActionInvocationId'), -); +const ActionInvocationIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('ActionInvocationId')); const ActionCommitOpenSchema = Schema.TaggedStruct('ActionCommitOpen', { invocationId: ActionInvocationIdSchema, @@ -174,7 +164,9 @@ export interface ActionRuntimeService { readonly runAction: < PayloadSchema extends Schema.ConstraintDecoder, ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder<{ readonly _tag: string }>, + DomainErrorSchema extends Schema.ConstraintDecoder<{ + readonly _tag: string; + }>, DomainEvents extends DomainEventContractMap, Owner extends string, Services, @@ -189,11 +181,7 @@ export interface ActionRuntimeService { Services, HandlerRequirements >, - ) => Effect.Effect< - ResultSchema['Type'], - ActionCoreError | DomainErrorSchema['Type'], - HandlerRequirements - >; + ) => Effect.Effect; } export interface ActionRuntimeOptions { @@ -275,9 +263,7 @@ const ActionResourcePermissionTargetSchema = Schema.Struct({ const resolveActionResourcePermissionTarget = ( payload: Payload, scope: OperationalScope, - resolver: - | ((payload: Payload, scope: OperationalScope) => ActionResourcePermissionTarget) - | undefined, + resolver: ((payload: Payload, scope: OperationalScope) => ActionResourcePermissionTarget) | undefined, ): Effect.Effect, ActionPermissionCheckError> => Effect.suspend(() => { if (resolver === undefined) { @@ -320,10 +306,7 @@ const verifyInvocation = ( requestHash: string, ): Effect.Effect< void, - | ActionAlreadyCommitted - | ActionCommitIndeterminate - | ActionInvocationStateError - | ActionRequestHashConflict + ActionAlreadyCommitted | ActionCommitIndeterminate | ActionInvocationStateError | ActionRequestHashConflict > => { if (invocation.requestHash !== requestHash) { return Effect.fail(requestHashConflict()); @@ -340,10 +323,7 @@ const verifyInvocation = ( }), ); } - if ( - (invocation.status === 'received' || invocation.status === 'running') && - invocation.completedAt === null - ) { + if ((invocation.status === 'received' || invocation.status === 'running') && invocation.completedAt === null) { return Effect.void; } return Effect.fail( @@ -387,9 +367,7 @@ const validatePrincipal = ( - input: Input, -): Effect.Effect => +const validateTransport = (input: Input): Effect.Effect => Schema.decodeUnknownEffect(ActionTransportMetadataSchema)(input).pipe( Effect.mapError((cause) => attachFailureCause( @@ -408,9 +386,7 @@ const makeHandlerExecutionError = () => reason: 'The Action handler failed unexpectedly', }); -const policyEvidence = ( - policy: ActionPolicy, -): ActionPolicyEvidence => +const policyEvidence = (policy: ActionPolicy): ActionPolicyEvidence => policy.scope === 'global' ? { policyKey: policy.policyKey, scope: policy.scope } : { @@ -442,9 +418,7 @@ type ActionRuntimeConstruction = readonly [ options: ActionRuntimeOptions, ]; -export const makeActionRuntime = ( - ...construction: ActionRuntimeConstruction -): ActionRuntimeService => { +export const makeActionRuntime = (...construction: ActionRuntimeConstruction): ActionRuntimeService => { const [database, repository, permission, operationalScopeResolver, options] = construction; const { contextAccess, moduleEntrypointGateway, moduleStateGate } = options; const resolveHandler = options.resolveHandler ?? getActionHandler; @@ -472,8 +446,7 @@ export const makeActionRuntime = ( }) .pipe( Effect.flatMap(([decision]) => - decision?.key === principal.tenantId && - (decision.decision === 'allowed' || decision.decision === 'denied') + decision?.key === principal.tenantId && (decision.decision === 'allowed' || decision.decision === 'denied') ? Effect.succeed(decision.decision) : Effect.fail(permissionUnavailable()), ), @@ -538,572 +511,508 @@ export const makeActionRuntime = ( ); }; - const runAction: ActionRuntimeService['runAction'] = Effect.fn('ActionRuntime.runAction')( - function* runActionEffect< - PayloadSchema extends Schema.ConstraintDecoder, - ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder<{ readonly _tag: string }>, - DomainEvents extends DomainEventContractMap, - Owner extends string, + const runAction: ActionRuntimeService['runAction'] = Effect.fn('ActionRuntime.runAction')(function* runActionEffect< + PayloadSchema extends Schema.ConstraintDecoder, + ResultSchema extends Schema.ConstraintDecoder, + DomainErrorSchema extends Schema.ConstraintDecoder<{ + readonly _tag: string; + }>, + DomainEvents extends DomainEventContractMap, + Owner extends string, + Services, + HandlerRequirements, + >( + input: RunActionInput< + PayloadSchema, + ResultSchema, + DomainErrorSchema, + DomainEvents, + Owner, Services, - HandlerRequirements, - >( - input: RunActionInput< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Owner, - Services, - HandlerRequirements - >, - ) { - const payload = yield* decodeActionPayload( - input.registration.descriptor.payloadSchema, - input.payload, - ); - notifyStage('payload_decoded'); - - const principal = yield* validatePrincipal(input.principal, input.registration, payload); - const transport = yield* validateTransport(input.transport); - const scope = yield* operationalScopeResolver.resolve( + HandlerRequirements + >, + ) { + const payload = yield* decodeActionPayload(input.registration.descriptor.payloadSchema, input.payload); + notifyStage('payload_decoded'); + + const principal = yield* validatePrincipal(input.principal, input.registration, payload); + const transport = yield* validateTransport(input.transport); + const scope = yield* operationalScopeResolver.resolve( + withOptionalProperty( + { + correlationId: transport.correlationId, + legalEntityScope: input.registration.descriptor.legalEntityScope, + principal, + }, + transport.traceId !== undefined, + 'traceId', + transport.traceId, + {}, + ), + ); + notifyStage('trusted_context_validated'); + + const moduleStateSnapshot = yield* moduleEntrypointGateway.prepareSnapshot(scope, [ + input.registration.descriptor.entrypoint, + ]); + yield* moduleEntrypointGateway.check(moduleStateSnapshot, input.registration.descriptor.entrypoint); + notifyStage('module_state_gate'); + + yield* requireIdempotencyKey(input.registration.descriptor.idempotency, transport); + + const tenantPermission = isTrustedSupportRecoveryPrincipalContext(principal, input.registration) + ? Option.none() + : yield* resolveActionTenantPermission(payload, input.registration.descriptor.tenantPermission); + const { legalEntityPermission } = input.registration.descriptor; + const hasCanonicalScopeTarget = Option.isSome(tenantPermission) || legalEntityPermission !== undefined; + + const resourcePermissionTarget = yield* resolveActionResourcePermissionTarget( + payload, + scope, + getActionResourcePermissionTargetResolver(input.registration), + ); + let actionTarget: Pick; + let governedTransport: ActionTransportMetadata; + if (Option.isSome(resourcePermissionTarget)) { + const target = resourcePermissionTarget.value; + actionTarget = { + targetModuleKey: target.resource.moduleId, + targetResourceId: target.resource.resourceId, + targetResourceType: target.resource.resourceType, + }; + governedTransport = withOptionalProperty( withOptionalProperty( { correlationId: transport.correlationId, - legalEntityScope: input.registration.descriptor.legalEntityScope, - principal, + targetModuleKey: target.resource.moduleId, + targetResourceId: target.resource.resourceId, + targetResourceType: target.resource.resourceType, }, - transport.traceId !== undefined, - 'traceId', - transport.traceId, + transport.idempotencyKey !== undefined, + 'idempotencyKey', + transport.idempotencyKey, {}, ), + transport.traceId !== undefined, + 'traceId', + transport.traceId, + {}, ); - notifyStage('trusted_context_validated'); - - const moduleStateSnapshot = yield* moduleEntrypointGateway.prepareSnapshot(scope, [ - input.registration.descriptor.entrypoint, - ]); - yield* moduleEntrypointGateway.check( - moduleStateSnapshot, - input.registration.descriptor.entrypoint, - ); - notifyStage('module_state_gate'); - - yield* requireIdempotencyKey(input.registration.descriptor.idempotency, transport); - - const tenantPermission = isTrustedSupportRecoveryPrincipalContext( - principal, - input.registration, - ) - ? Option.none() - : yield* resolveActionTenantPermission( - payload, - input.registration.descriptor.tenantPermission, - ); - const { legalEntityPermission } = input.registration.descriptor; - const hasCanonicalScopeTarget = - Option.isSome(tenantPermission) || legalEntityPermission !== undefined; - - const resourcePermissionTarget = yield* resolveActionResourcePermissionTarget( - payload, - scope, - getActionResourcePermissionTargetResolver(input.registration), - ); - let actionTarget: Pick< - ActionTransportMetadata, - 'targetModuleKey' | 'targetResourceId' | 'targetResourceType' - >; - let governedTransport: ActionTransportMetadata; - if (Option.isSome(resourcePermissionTarget)) { - const target = resourcePermissionTarget.value; - actionTarget = { - targetModuleKey: target.resource.moduleId, - targetResourceId: target.resource.resourceId, - targetResourceType: target.resource.resourceType, - }; - governedTransport = withOptionalProperty( - withOptionalProperty( - { - correlationId: transport.correlationId, - targetModuleKey: target.resource.moduleId, - targetResourceId: target.resource.resourceId, - targetResourceType: target.resource.resourceType, - }, - transport.idempotencyKey !== undefined, - 'idempotencyKey', - transport.idempotencyKey, - {}, - ), - transport.traceId !== undefined, - 'traceId', - transport.traceId, + } else if (hasCanonicalScopeTarget) { + actionTarget = {}; + governedTransport = withOptionalProperty( + withOptionalProperty( + { correlationId: transport.correlationId }, + transport.idempotencyKey !== undefined, + 'idempotencyKey', + transport.idempotencyKey, {}, - ); - } else if (hasCanonicalScopeTarget) { - actionTarget = {}; - governedTransport = withOptionalProperty( + ), + transport.traceId !== undefined, + 'traceId', + transport.traceId, + {}, + ); + } else { + actionTarget = withOptionalProperty( + withOptionalProperty( withOptionalProperty( - { correlationId: transport.correlationId }, - transport.idempotencyKey !== undefined, - 'idempotencyKey', - transport.idempotencyKey, {}, - ), - transport.traceId !== undefined, - 'traceId', - transport.traceId, - {}, - ); - } else { - actionTarget = withOptionalProperty( - withOptionalProperty( - withOptionalProperty( - {}, - transport.targetModuleKey !== undefined, - 'targetModuleKey', - transport.targetModuleKey, - {}, - ), - transport.targetResourceId !== undefined, - 'targetResourceId', - transport.targetResourceId, + transport.targetModuleKey !== undefined, + 'targetModuleKey', + transport.targetModuleKey, {}, ), - transport.targetResourceType !== undefined, - 'targetResourceType', - transport.targetResourceType, + transport.targetResourceId !== undefined, + 'targetResourceId', + transport.targetResourceId, {}, - ); - governedTransport = transport; - } + ), + transport.targetResourceType !== undefined, + 'targetResourceType', + transport.targetResourceType, + {}, + ); + governedTransport = transport; + } - const normalizedPayload = yield* Effect.try({ - catch: (cause) => - attachFailureCause( - new ActionPayloadValidationError({ - code: 'action_payload_invalid', - reason: 'The decoded Action payload cannot be encoded safely', - }), - cause, - ), - try: () => - Result.getOrThrow( - Schema.encodeUnknownResult( - Schema.make>( - input.registration.descriptor.payloadSchema.ast, - ), - )(payload), - ), - }); + const normalizedPayload = yield* Effect.try({ + catch: (cause) => + attachFailureCause( + new ActionPayloadValidationError({ + code: 'action_payload_invalid', + reason: 'The decoded Action payload cannot be encoded safely', + }), + cause, + ), + try: () => + Result.getOrThrow( + Schema.encodeUnknownResult( + Schema.make>(input.registration.descriptor.payloadSchema.ast), + )(payload), + ), + }); - const requestHash = yield* Effect.try({ - catch: (cause) => - attachFailureCause( - new ActionPayloadValidationError({ - code: 'action_payload_invalid', - reason: 'The decoded Action payload cannot be normalized safely', - }), - cause, - ), - try: () => - computeActionRequestHash({ - actionKey: input.registration.descriptor.actionKey, - normalizedPayload, - owningModuleKey: input.registration.descriptor.owningModuleKey, - principal, - schemaVersion: input.registration.descriptor.schemaVersion, - target: actionTarget, + const requestHash = yield* Effect.try({ + catch: (cause) => + attachFailureCause( + new ActionPayloadValidationError({ + code: 'action_payload_invalid', + reason: 'The decoded Action payload cannot be normalized safely', }), - }); + cause, + ), + try: () => + computeActionRequestHash({ + actionKey: input.registration.descriptor.actionKey, + normalizedPayload, + owningModuleKey: input.registration.descriptor.owningModuleKey, + principal, + schemaVersion: input.registration.descriptor.schemaVersion, + target: actionTarget, + }), + }); - const invocation = yield* repository - .createOrResolveInvocation(database.executor, { + const invocation = yield* repository + .createOrResolveInvocation(database.executor, { + actionKey: input.registration.descriptor.actionKey, + idempotencyKey: transport.idempotencyKey, + principal, + requestHash, + transport: governedTransport, + }) + .pipe( + Effect.tapError((error) => + logInvocationPersistenceFailure(error, { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + }), + ), + ); + notifyStage('invocation_prepared'); + yield* verifyInvocation(invocation, requestHash); + + // The trusted context already represents authentication. Authorization + // uses only the immutable Action key and trusted principal identity. + notifyStage('authentication_boundary'); + const logPermissionInvocationFailure = Effect.fn('ActionRuntime.logPermissionInvocationFailure')( + function* logPermissionInvocationFailureEffect(failure: ActionInvocationPersistenceError) { + yield* logInvocationPersistenceFailure(failure, { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + invocationId: invocation.actionInvocationId, + }); + }, + ); + const logPermissionTransactionFailure = Effect.fn('ActionRuntime.logPermissionTransactionFailure')( + function* logPermissionTransactionFailureEffect(failure: ActionTransactionError) { + yield* logActionTransactionFailureCause(failure, 'Unexpected permission denial persistence failure', { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + invocationId: invocation.actionInvocationId, + }); + }, + ); + const rejectPermission = () => + repository + .rejectPermissionDenied(database.executor, { + actionInvocationId: invocation.actionInvocationId, actionKey: input.registration.descriptor.actionKey, - idempotencyKey: transport.idempotencyKey, + auditProfile: input.registration.descriptor.auditProfile, principal, - requestHash, transport: governedTransport, }) .pipe( - Effect.tapError((error) => - logInvocationPersistenceFailure(error, { - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - }), + Effect.tapErrorTag('ActionInvocationPersistenceError', logPermissionInvocationFailure), + Effect.tapErrorTag('ActionTransactionError', logPermissionTransactionFailure), + Effect.flatMap(() => + Effect.fail( + new ActionPermissionDenied({ + code: 'action_permission_denied', + reason: 'The principal is not permitted to execute this Action', + }), + ), ), ); - notifyStage('invocation_prepared'); - yield* verifyInvocation(invocation, requestHash); - - // The trusted context already represents authentication. Authorization - // uses only the immutable Action key and trusted principal identity. - notifyStage('authentication_boundary'); - const logPermissionInvocationFailure = Effect.fn( - 'ActionRuntime.logPermissionInvocationFailure', - )(function* logPermissionInvocationFailureEffect(failure: ActionInvocationPersistenceError) { - yield* logInvocationPersistenceFailure(failure, { - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - invocationId: invocation.actionInvocationId, - }); - }); - const logPermissionTransactionFailure = Effect.fn( - 'ActionRuntime.logPermissionTransactionFailure', - )(function* logPermissionTransactionFailureEffect(failure: ActionTransactionError) { - yield* logActionTransactionFailureCause( - failure, - 'Unexpected permission denial persistence failure', + const permissionDecision = yield* permission + .checkActionPermission({ + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + principalId: principal.principalId, + }) + .pipe(Effect.tapError((error) => Effect.logError(error.reason))); + const tenantPermissionDecision = yield* checkTenantActionPermission(principal, tenantPermission); + + const legalEntityPermissionDecision = yield* checkActionLegalEntityPermission(scope, legalEntityPermission); + + const resourcePermissionDecision = yield* checkActionResourcePermission(scope, resourcePermissionTarget); + notifyStage('permission_checked'); + if ( + permissionDecision === 'denied' || + tenantPermissionDecision === 'denied' || + legalEntityPermissionDecision === 'denied' || + resourcePermissionDecision === 'denied' + ) { + return yield* rejectPermission(); + } + + notifyStage('policy_boundary'); + + const policyInput: ActionPolicyEvaluatorInput = Object.freeze({ + action: Object.freeze({ + actionKey: input.registration.descriptor.actionKey, + owningModuleKey: input.registration.descriptor.owningModuleKey, + schemaVersion: input.registration.descriptor.schemaVersion, + }), + payload, + principal: Object.freeze({ ...principal }), + target: Object.freeze({ ...actionTarget }), + transport: Object.freeze( + withOptionalProperty( { - actionKey: input.registration.descriptor.actionKey, correlationId: transport.correlationId, - invocationId: invocation.actionInvocationId, }, - ); - }); - const rejectPermission = () => - repository - .rejectPermissionDenied(database.executor, { + transport.traceId !== undefined, + 'traceId', + transport.traceId, + {}, + ), + ), + }); + const evaluateActionPolicy = Effect.fn('ActionRuntime.evaluatePolicy')(function* evaluatePolicy( + policy: ActionPolicy, + ) { + const policyExit = yield* Effect.exit(Effect.suspend(() => policy.evaluate(policyInput))); + if (Exit.isSuccess(policyExit)) { + return policyEvidence(policy); + } + + const failureReasons = policyExit.cause.reasons.filter(Cause.isFailReason); + const [failureReason] = failureReasons; + if ( + failureReasons.length === policyExit.cause.reasons.length && + failureReason !== undefined && + Schema.is(PolicyDenied)(failureReason.error) + ) { + const denial = failureReason.error; + yield* repository + .finalizePolicyDenial(database.executor, { actionInvocationId: invocation.actionInvocationId, actionKey: input.registration.descriptor.actionKey, auditProfile: input.registration.descriptor.auditProfile, + policy: policyEvidence(policy), principal, + reasonCode: denial.reasonCode, transport: governedTransport, }) .pipe( - Effect.tapErrorTag('ActionInvocationPersistenceError', logPermissionInvocationFailure), - Effect.tapErrorTag('ActionTransactionError', logPermissionTransactionFailure), - Effect.flatMap(() => - Effect.fail( - new ActionPermissionDenied({ - code: 'action_permission_denied', - reason: 'The principal is not permitted to execute this Action', - }), - ), + Effect.tapError((error) => + logInvocationPersistenceFailure(error, { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + invocationId: invocation.actionInvocationId, + policyKey: policy.policyKey, + }), ), ); - const permissionDecision = yield* permission - .checkActionPermission({ - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - principalId: principal.principalId, - }) - .pipe(Effect.tapError((error) => Effect.logError(error.reason))); - const tenantPermissionDecision = yield* checkTenantActionPermission( - principal, - tenantPermission, - ); + return yield* new ActionPolicyDenied({ + code: 'action_policy_denied', + policyReasonCode: denial.reasonCode, + reason: denial.reason, + }); + } - const legalEntityPermissionDecision = yield* checkActionLegalEntityPermission( - scope, - legalEntityPermission, + yield* Effect.logError('Unexpected Action Policy evaluation failure', policyExit.cause); + return yield* new ActionPolicyEvaluationError({ + code: 'action_policy_evaluation_failed', + reason: 'A required Action Policy could not be evaluated', + }); + }); + const allowedPolicies = yield* Effect.forEach(input.registration.descriptor.policies, evaluateActionPolicy, { + concurrency: 1, + }); + + const runningInvocation = yield* repository + .transitionInvocationToRunning(database.executor, invocation.actionInvocationId) + .pipe( + Effect.tapError((error) => + logInvocationPersistenceFailure(error, { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + invocationId: invocation.actionInvocationId, + }), + ), ); + yield* verifyInvocation(runningInvocation, requestHash); + notifyStage('invocation_running'); - const resourcePermissionDecision = yield* checkActionResourcePermission( - scope, - resourcePermissionTarget, + const transactionBodyCompleted = yield* Ref.make(false); + const transactionBodyExit = yield* Ref.make | null>(null); + const transactionProgram = Effect.fn('ActionRuntime.transaction')(function* executeTransaction( + drizzleTransaction: CoreTransaction, + ) { + const lockedInvocation = yield* repository.lockInvocation(drizzleTransaction, invocation.actionInvocationId).pipe( + Effect.tapError((error) => + logInvocationPersistenceFailure(error, { + actionKey: input.registration.descriptor.actionKey, + correlationId: transport.correlationId, + invocationId: invocation.actionInvocationId, + }), + ), ); - notifyStage('permission_checked'); - if ( - permissionDecision === 'denied' || - tenantPermissionDecision === 'denied' || - legalEntityPermissionDecision === 'denied' || - resourcePermissionDecision === 'denied' - ) { - return yield* rejectPermission(); + notifyStage('invocation_locked'); + yield* verifyInvocation(lockedInvocation, requestHash); + + const scopedTransaction = yield* installScope(drizzleTransaction, scope); + notifyStage('database_scope_installed'); + + const actionEntrypoint = input.registration.descriptor.entrypoint; + if (actionEntrypoint.scope === 'tenant') { + const tenantEntrypoint = Object.freeze({ + ...actionEntrypoint, + scope: 'tenant' as const, + }) satisfies TenantModuleEntrypoint<'action', 'write', Owner>; + yield* moduleStateGate.recheckWrite(drizzleTransaction, scope.tenantId, tenantEntrypoint); } + notifyStage('module_state_rechecked'); + const serviceFactory = resolveServiceFactory(input.registration); + const services = yield* serviceFactory(scopedTransaction, scope); + const handler = resolveHandler(input.registration); + + const collector = createActionCollector( + input.registration.descriptor.domainEvents, + input.registration.descriptor.owningModuleKey, + input.registration.descriptor.accessEvidencePolicy, + input.registration.descriptor.auditEvidenceSchema, + ); + const handlerContext = Object.freeze({ + actionInvocationId: lockedInvocation.actionInvocationId, + addDomainEvent: collector.addDomainEvent, + addOutboxMessage: collector.addOutboxMessage, + recordAuditEvidence: collector.recordAuditEvidence, + recordDataAccess: collector.recordDataAccess, + scope, + services, + }); - notifyStage('policy_boundary'); + const handlerExit = yield* Effect.exit(Effect.suspend(() => handler(payload, handlerContext))); - const policyInput: ActionPolicyEvaluatorInput = Object.freeze({ - action: Object.freeze({ - actionKey: input.registration.descriptor.actionKey, - owningModuleKey: input.registration.descriptor.owningModuleKey, - schemaVersion: input.registration.descriptor.schemaVersion, - }), - payload, - principal: Object.freeze({ ...principal }), - target: Object.freeze({ ...actionTarget }), - transport: Object.freeze( - withOptionalProperty( - { - correlationId: transport.correlationId, - }, - transport.traceId !== undefined, - 'traceId', - transport.traceId, - {}, - ), - ), - }); - const evaluateActionPolicy = Effect.fn('ActionRuntime.evaluatePolicy')( - function* evaluatePolicy(policy: ActionPolicy) { - const policyExit = yield* Effect.exit(Effect.suspend(() => policy.evaluate(policyInput))); - if (Exit.isSuccess(policyExit)) { - return policyEvidence(policy); + if (Exit.isFailure(handlerExit)) { + const failureReasons = handlerExit.cause.reasons.filter(Cause.isFailReason); + const [failureReason] = failureReasons; + if (failureReasons.length === handlerExit.cause.reasons.length && failureReason !== undefined) { + if (Schema.is(ActionCollectorError)(failureReason.error)) { + return yield* failureReason.error; } - - const failureReasons = policyExit.cause.reasons.filter(Cause.isFailReason); - const [failureReason] = failureReasons; - if ( - failureReasons.length === policyExit.cause.reasons.length && - failureReason !== undefined && - Schema.is(PolicyDenied)(failureReason.error) - ) { - const denial = failureReason.error; - yield* repository - .finalizePolicyDenial(database.executor, { - actionInvocationId: invocation.actionInvocationId, - actionKey: input.registration.descriptor.actionKey, - auditProfile: input.registration.descriptor.auditProfile, - policy: policyEvidence(policy), - principal, - reasonCode: denial.reasonCode, - transport: governedTransport, - }) - .pipe( - Effect.tapError((error) => - logInvocationPersistenceFailure(error, { - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - invocationId: invocation.actionInvocationId, - policyKey: policy.policyKey, - }), - ), - ); - return yield* new ActionPolicyDenied({ - code: 'action_policy_denied', - policyReasonCode: denial.reasonCode, - reason: denial.reason, - }); + const decodedDomainError = yield* Effect.option( + Schema.decodeUnknownEffect(input.registration.descriptor.domainErrorSchema)(failureReason.error), + ); + if (Option.isSome(decodedDomainError)) { + return yield* Effect.fail(decodedDomainError.value); } + } + yield* Effect.logError('Unexpected Action execution defect', handlerExit.cause); + return yield* makeHandlerExecutionError(); + } + notifyStage('handler_executed'); - yield* Effect.logError('Unexpected Action Policy evaluation failure', policyExit.cause); - return yield* new ActionPolicyEvaluationError({ - code: 'action_policy_evaluation_failed', - reason: 'A required Action Policy could not be evaluated', - }); - }, - ); - const allowedPolicies = yield* Effect.forEach( - input.registration.descriptor.policies, - evaluateActionPolicy, - { concurrency: 1 }, - ); + const result = yield* decodeActionResult(input.registration.descriptor.resultSchema, handlerExit.value); - const runningInvocation = yield* repository - .transitionInvocationToRunning(database.executor, invocation.actionInvocationId) + const resultHash = yield* Effect.try({ + catch: (cause) => + attachFailureCause( + new ActionResultValidationError({ + code: 'action_result_invalid', + reason: 'The decoded Action result cannot be normalized safely', + }), + cause, + ), + try: () => + computeCanonicalValueHash( + Result.getOrThrow( + Schema.encodeUnknownResult( + Schema.make>(input.registration.descriptor.resultSchema.ast), + )(result), + ), + ), + }); + yield* repository + .flushSuccess(drizzleTransaction, { + actionInvocationId: invocation.actionInvocationId, + actionKey: input.registration.descriptor.actionKey, + allowedPolicies, + auditProfile: input.registration.descriptor.auditProfile, + evidence: collector.snapshot(), + principal, + resultHash, + transport: governedTransport, + }) .pipe( Effect.tapError((error) => - logInvocationPersistenceFailure(error, { + logActionTransactionFailureCause(error, 'Unexpected Action success evidence persistence failure', { actionKey: input.registration.descriptor.actionKey, correlationId: transport.correlationId, invocationId: invocation.actionInvocationId, }), ), ); - yield* verifyInvocation(runningInvocation, requestHash); - notifyStage('invocation_running'); - - const transactionBodyCompleted = yield* Ref.make(false); - const transactionBodyExit = yield* Ref.make | null>(null); - const transactionProgram = Effect.fn('ActionRuntime.transaction')( - function* executeTransaction(drizzleTransaction: CoreTransaction) { - const lockedInvocation = yield* repository - .lockInvocation(drizzleTransaction, invocation.actionInvocationId) - .pipe( - Effect.tapError((error) => - logInvocationPersistenceFailure(error, { - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - invocationId: invocation.actionInvocationId, - }), - ), - ); - notifyStage('invocation_locked'); - yield* verifyInvocation(lockedInvocation, requestHash); - - const scopedTransaction = yield* installScope(drizzleTransaction, scope); - notifyStage('database_scope_installed'); - - const actionEntrypoint = input.registration.descriptor.entrypoint; - if (actionEntrypoint.scope === 'tenant') { - const tenantEntrypoint = Object.freeze({ - ...actionEntrypoint, - scope: 'tenant' as const, - }) satisfies TenantModuleEntrypoint<'action', 'write', Owner>; - yield* moduleStateGate.recheckWrite( - drizzleTransaction, - scope.tenantId, - tenantEntrypoint, - ); - } - notifyStage('module_state_rechecked'); - const serviceFactory = resolveServiceFactory(input.registration); - const services = yield* serviceFactory(scopedTransaction, scope); - const handler = resolveHandler(input.registration); - - const collector = createActionCollector( - input.registration.descriptor.domainEvents, - input.registration.descriptor.owningModuleKey, - input.registration.descriptor.accessEvidencePolicy, - input.registration.descriptor.auditEvidenceSchema, - ); - const handlerContext = Object.freeze({ - actionInvocationId: lockedInvocation.actionInvocationId, - addDomainEvent: collector.addDomainEvent, - addOutboxMessage: collector.addOutboxMessage, - recordAuditEvidence: collector.recordAuditEvidence, - recordDataAccess: collector.recordDataAccess, - scope, - services, - }); - - const handlerExit = yield* Effect.exit( - Effect.suspend(() => handler(payload, handlerContext)), - ); - - if (Exit.isFailure(handlerExit)) { - const failureReasons = handlerExit.cause.reasons.filter(Cause.isFailReason); - const [failureReason] = failureReasons; - if ( - failureReasons.length === handlerExit.cause.reasons.length && - failureReason !== undefined - ) { - if (Schema.is(ActionCollectorError)(failureReason.error)) { - return yield* failureReason.error; + notifyStage('success_evidence_flushed'); + yield* Ref.set(transactionBodyCompleted, true); + return result; + }); + // The driver/body stay interruptible; classify the settled Cause before interruption resumes. + return yield* Effect.uninterruptibleMask( + Effect.fn('ActionRuntime.classifyTransactionOutcome')(function* classifyTransactionOutcome( + restore: ( + effect: Effect.Effect, + ) => Effect.Effect, + ) { + const transactionExit = yield* Effect.exit( + restore( + database.executor.transaction((transaction) => + transactionProgram(transaction).pipe(Effect.onExit((exit) => Ref.set(transactionBodyExit, exit))), + ), + ), + ); + if (Exit.isSuccess(transactionExit)) { + return transactionExit.value; + } + + const bodyExit = yield* Ref.get(transactionBodyExit); + const bodyCompleted = + (yield* Ref.get(transactionBodyCompleted)) && bodyExit !== null && Exit.isSuccess(bodyExit); + if ( + bodyExit !== null && + Exit.isFailure(bodyExit) && + transactionExit.cause.reasons.some((reason) => Cause.isDieReason(reason) && isSqlError(reason.defect)) + ) { + yield* Effect.logError('Action body failed before transaction rollback failed', bodyExit.cause); + } + return yield* Effect.failCause( + Cause.fromReasons( + transactionExit.cause.reasons.map((reason) => { + if (Cause.isInterruptReason(reason)) { + return reason; } - const decodedDomainError = yield* Effect.option( - Schema.decodeUnknownEffect(input.registration.descriptor.domainErrorSchema)( - failureReason.error, - ), - ); - if (Option.isSome(decodedDomainError)) { - return yield* Effect.fail(decodedDomainError.value); + const failure = Cause.isFailReason(reason) ? reason.error : reason.defect; + if (!isSqlError(failure)) { + return reason; } - } - yield* Effect.logError('Unexpected Action execution defect', handlerExit.cause); - return yield* makeHandlerExecutionError(); - } - notifyStage('handler_executed'); - - const result = yield* decodeActionResult( - input.registration.descriptor.resultSchema, - handlerExit.value, - ); - - const resultHash = yield* Effect.try({ - catch: (cause) => - attachFailureCause( - new ActionResultValidationError({ - code: 'action_result_invalid', - reason: 'The decoded Action result cannot be normalized safely', - }), - cause, - ), - try: () => - computeCanonicalValueHash( - Result.getOrThrow( - Schema.encodeUnknownResult( - Schema.make>( - input.registration.descriptor.resultSchema.ast, - ), - )(result), - ), - ), - }); - yield* repository - .flushSuccess(drizzleTransaction, { - actionInvocationId: invocation.actionInvocationId, - actionKey: input.registration.descriptor.actionKey, - allowedPolicies, - auditProfile: input.registration.descriptor.auditProfile, - evidence: collector.snapshot(), - principal, - resultHash, - transport: governedTransport, - }) - .pipe( - Effect.tapError((error) => - logActionTransactionFailureCause( - error, - 'Unexpected Action success evidence persistence failure', - { - actionKey: input.registration.descriptor.actionKey, - correlationId: transport.correlationId, - invocationId: invocation.actionInvocationId, - }, - ), - ), - ); - notifyStage('success_evidence_flushed'); - yield* Ref.set(transactionBodyCompleted, true); - return result; - }, - ); - // The driver/body stay interruptible; classify the settled Cause before interruption resumes. - return yield* Effect.uninterruptibleMask( - Effect.fn('ActionRuntime.classifyTransactionOutcome')(function* classifyTransactionOutcome( - restore: ( - effect: Effect.Effect, - ) => Effect.Effect, - ) { - const transactionExit = yield* Effect.exit( - restore( - database.executor.transaction((transaction) => - transactionProgram(transaction).pipe( - Effect.onExit((exit) => Ref.set(transactionBodyExit, exit)), - ), - ), - ), - ); - if (Exit.isSuccess(transactionExit)) { - return transactionExit.value; - } - - const bodyExit = yield* Ref.get(transactionBodyExit); - const bodyCompleted = - (yield* Ref.get(transactionBodyCompleted)) && - bodyExit !== null && - Exit.isSuccess(bodyExit); - if ( - bodyExit !== null && - Exit.isFailure(bodyExit) && - transactionExit.cause.reasons.some( - (reason) => Cause.isDieReason(reason) && isSqlError(reason.defect), - ) - ) { - yield* Effect.logError( - 'Action body failed before transaction rollback failed', - bodyExit.cause, - ); - } - return yield* Effect.failCause( - Cause.fromReasons( - transactionExit.cause.reasons.map((reason) => { - if (Cause.isInterruptReason(reason)) { - return reason; - } - const failure = Cause.isFailReason(reason) ? reason.error : reason.defect; - if (!isSqlError(failure)) { - return reason; - } - return Cause.makeFailReason( - bodyCompleted && isCommitAcknowledgementFailure(failure) - ? new ActionCommitIndeterminate({ - code: 'action_commit_indeterminate', - invocationId: invocation.actionInvocationId, - reason: 'The database did not confirm whether the Action commit completed', - }) - : transactionFailure(), - ); - }), - ), - ); - }), - ); - }, - ); + return Cause.makeFailReason( + bodyCompleted && isCommitAcknowledgementFailure(failure) + ? new ActionCommitIndeterminate({ + code: 'action_commit_indeterminate', + invocationId: invocation.actionInvocationId, + reason: 'The database did not confirm whether the Action commit completed', + }) + : transactionFailure(), + ); + }), + ), + ); + }), + ); + }); const resolveActionCommit: ActionRuntimeService['resolveActionCommit'] = Effect.fn( 'ActionRuntime.resolveActionCommit', @@ -1140,9 +1049,7 @@ export const makeActionRuntime = ( return yield* alreadyCommitted(invocation.actionInvocationId); } if ( - (invocation.status === 'received' || - invocation.status === 'running' || - invocation.status === 'indeterminate') && + (invocation.status === 'received' || invocation.status === 'running' || invocation.status === 'indeterminate') && invocation.completedAt === null ) { return Object.freeze({ @@ -1166,26 +1073,19 @@ export class ActionRuntime extends Context.Service { const ActionTransactionErrorClass = ActionTransactionErrorInternals.ErrorClass; export { ActionTransactionErrorClass as ActionTransactionError }; // Core-only accessors: deliberately excluded from the package root exports. -export const createActionTransactionErrorWithCause = - ActionTransactionErrorInternals.createWithCause; +export const createActionTransactionErrorWithCause = ActionTransactionErrorInternals.createWithCause; export const getActionTransactionErrorCause = ActionTransactionErrorInternals.readCause; diff --git a/app/packages/core-runtime/src/auth/gateway-assertion-redemption.ts b/app/packages/core-runtime/src/auth/gateway-assertion-redemption.ts index db4f50351..8ea3f5a39 100644 --- a/app/packages/core-runtime/src/auth/gateway-assertion-redemption.ts +++ b/app/packages/core-runtime/src/auth/gateway-assertion-redemption.ts @@ -1,5 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; + import type { GatewayAssertionRedemptionUnavailableError } from './gateway-assertion-redemption-unavailable-error.ts'; import type { GatewayAssertionReplayError } from './gateway-assertion-replay-error.ts'; @@ -13,14 +14,10 @@ export interface GatewayAssertionRedemptionInput { readonly jti: string; } -export type GatewayAssertionRedemptionError = - | GatewayAssertionReplayError - | GatewayAssertionRedemptionUnavailableError; +export type GatewayAssertionRedemptionError = GatewayAssertionReplayError | GatewayAssertionRedemptionUnavailableError; export interface GatewayAssertionRedemption { - readonly consume: ( - input: GatewayAssertionRedemptionInput, - ) => Effect.Effect; + readonly consume: (input: GatewayAssertionRedemptionInput) => Effect.Effect; } export class GatewayAssertionRedemptionService extends Context.Service< diff --git a/app/packages/core-runtime/src/auth/identity-persistence-unavailable-error.ts b/app/packages/core-runtime/src/auth/identity-persistence-unavailable-error.ts index 86ed65100..75d7950d1 100644 --- a/app/packages/core-runtime/src/auth/identity-persistence-unavailable-error.ts +++ b/app/packages/core-runtime/src/auth/identity-persistence-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class IdentityPersistenceUnavailableError extends Schema.TaggedError()( 'IdentityPersistenceUnavailableError', - { code: Schema.Literal('identity_persistence_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('identity_persistence_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/legal-entity-context.ts b/app/packages/core-runtime/src/auth/legal-entity-context.ts index 186b31f50..83e208bf7 100644 --- a/app/packages/core-runtime/src/auth/legal-entity-context.ts +++ b/app/packages/core-runtime/src/auth/legal-entity-context.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { Context, Duration, Effect, Layer } from 'effect'; + import { CoreDatabase } from '../db/client.ts'; import { legalEntities } from '../db/schema.ts'; import type { CoreDatabaseExecutor } from '../db/types.ts'; @@ -74,60 +75,51 @@ const validateRecords = ( export const classifyActiveLegalEntities = ( records: readonly LegalEntityContextRecord[], tenantId: string, -): Effect.Effect< - readonly SafeLegalEntity[], - LegalEntityContextInvalidError | LegalEntityContextAmbiguousError -> => +): Effect.Effect => validateRecords(records, tenantId).pipe( Effect.map((validated) => validated - .flatMap(({ legalEntityId, legalName, status }) => - status === 'active' ? [{ legalEntityId, legalName }] : [], - ) + .flatMap(({ legalEntityId, legalName, status }) => (status === 'active' ? [{ legalEntityId, legalName }] : [])) .toSorted( (left, right) => - compareText(left.legalName, right.legalName) || - compareText(left.legalEntityId, right.legalEntityId), + compareText(left.legalName, right.legalName) || compareText(left.legalEntityId, right.legalEntityId), ), ), ); -export const classifySelectedLegalEntity = Effect.fn( - 'LegalEntityContext.classifySelectedLegalEntity', -)(function* classifySelectedLegalEntityEffect( - records: readonly LegalEntityContextRecord[], - tenantId: string, - legalEntityId: string, -): Effect.fn.Return< - SafeLegalEntity, - Exclude -> { - const validated = yield* validateRecords(records, tenantId); - if (!uuidPattern.test(legalEntityId)) { - return yield* new LegalEntityContextInvalidError(); - } - - const matching = validated.filter((record) => record.legalEntityId === legalEntityId); - if (matching.length === 0) { - return yield* new LegalEntityContextMissingError(); - } - if (matching.length !== 1) { - return yield* new LegalEntityContextAmbiguousError(); - } - - const [selected] = matching; - if (selected === undefined) { - return yield* new LegalEntityContextMissingError(); - } - if (selected.status !== 'active') { - return yield* new LegalEntityContextInactiveError(); - } - - return { - legalEntityId: selected.legalEntityId, - legalName: selected.legalName, - }; -}); +export const classifySelectedLegalEntity = Effect.fn('LegalEntityContext.classifySelectedLegalEntity')( + function* classifySelectedLegalEntityEffect( + records: readonly LegalEntityContextRecord[], + tenantId: string, + legalEntityId: string, + ): Effect.fn.Return> { + const validated = yield* validateRecords(records, tenantId); + if (!uuidPattern.test(legalEntityId)) { + return yield* new LegalEntityContextInvalidError(); + } + + const matching = validated.filter((record) => record.legalEntityId === legalEntityId); + if (matching.length === 0) { + return yield* new LegalEntityContextMissingError(); + } + if (matching.length !== 1) { + return yield* new LegalEntityContextAmbiguousError(); + } + + const [selected] = matching; + if (selected === undefined) { + return yield* new LegalEntityContextMissingError(); + } + if (selected.status !== 'active') { + return yield* new LegalEntityContextInactiveError(); + } + + return { + legalEntityId: selected.legalEntityId, + legalName: selected.legalName, + }; + }, +); export interface LegalEntityContextService { readonly listActiveForTenant: ( @@ -139,10 +131,9 @@ export interface LegalEntityContextService { ) => Effect.Effect; } -export class LegalEntityContext extends Context.Service< - LegalEntityContext, - LegalEntityContextService ->()('@app/core-runtime/auth/legal-entity-context/LegalEntityContext') {} +export class LegalEntityContext extends Context.Service()( + '@app/core-runtime/auth/legal-entity-context/LegalEntityContext', +) {} type LegalEntityContextRecordLoadResult = Effect.Effect< readonly LegalEntityContextRecord[], @@ -153,10 +144,7 @@ interface LegalEntityContextRecordReader Result; } -const attachCause = ( - failure: Failure, - cause?: FailureCause, -): Failure => +const attachCause = (failure: Failure, cause?: FailureCause): Failure => cause === undefined ? failure : Object.defineProperty(failure, 'cause', { value: cause }); const unavailable = (cause?: FailureCause): LegalEntityContextUnavailableError => @@ -209,9 +197,7 @@ const legalEntityContextFromRepository = - loadRecords(tenantId).pipe( - Effect.flatMap((records) => classifyActiveLegalEntities(records, tenantId)), - ), + loadRecords(tenantId).pipe(Effect.flatMap((records) => classifyActiveLegalEntities(records, tenantId))), validateSelection: (tenantId, legalEntityId) => loadRecords(tenantId, legalEntityId).pipe( Effect.flatMap((records) => classifySelectedLegalEntity(records, tenantId, legalEntityId)), @@ -221,8 +207,7 @@ const legalEntityContextFromRepository = ; -}): LegalEntityContextService => - legalEntityContextFromRepository(legalEntityContextRepositoryFromDatabase(database)); +}): LegalEntityContextService => legalEntityContextFromRepository(legalEntityContextRepositoryFromDatabase(database)); export const LegalEntityContextLive = Layer.effect( LegalEntityContext, diff --git a/app/packages/core-runtime/src/auth/principal-administration-reads.ts b/app/packages/core-runtime/src/auth/principal-administration-reads.ts index 037b2e739..9b83830c0 100644 --- a/app/packages/core-runtime/src/auth/principal-administration-reads.ts +++ b/app/packages/core-runtime/src/auth/principal-administration-reads.ts @@ -1,5 +1,6 @@ import { and, asc, eq, or } from 'drizzle-orm'; import { Cause, DateTime, Effect, Schema } from 'effect'; + import { principalAuthBindings, principals } from '../db/schema.ts'; import type { ScopedTransactionExecutor } from '../db/scoped-transaction.ts'; import { defineSystemModuleEntrypoint } from '../modules/module-entrypoint.ts'; @@ -44,7 +45,10 @@ const SelfResultJson = Schema.toCodecJson(SelfResult); const ManagedResultJson = Schema.toCodecJson(ManagedResult); const readUnavailable = (reason: string, cause: unknown): ReadHandlerUnavailable => { - const error = new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }); + const error = new ReadHandlerUnavailable({ + code: 'read_handler_unavailable', + reason, + }); Object.defineProperty(error, 'cause', { configurable: true, value: cause }); return error; }; @@ -87,22 +91,13 @@ const services = ( ), ) .where( - and( - eq(principals.tenantId, tenantId), - or(eq(principals.kind, 'service'), eq(principals.kind, 'integration')), - ), - ) - .orderBy( - asc(principals.displayName), - asc(principals.principalId), - asc(principalAuthBindings.createdAt), + and(eq(principals.tenantId, tenantId), or(eq(principals.kind, 'service'), eq(principals.kind, 'integration'))), ) + .orderBy(asc(principals.displayName), asc(principals.principalId), asc(principalAuthBindings.createdAt)) .limit(limit + 1) .offset(offset) .pipe( - Effect.mapError((cause) => - readUnavailable('Managed identities are temporarily unavailable', cause), - ), + Effect.mapError((cause) => readUnavailable('Managed identities are temporarily unavailable', cause)), Effect.timeoutOrElse({ duration: databaseReadTimeout, orElse: () => @@ -115,8 +110,11 @@ const services = ( }), Effect.map((rows) => { const eligible = rows.filter( - (row): row is typeof row & { readonly kind: 'integration' | 'service' } => - row.kind === 'service' || row.kind === 'integration', + ( + row, + ): row is typeof row & { + readonly kind: 'integration' | 'service'; + } => row.kind === 'service' || row.kind === 'integration', ); return { items: eligible.slice(0, limit).map((row) => ({ @@ -135,10 +133,8 @@ const services = ( }; }), Effect.flatMap((result) => - Schema.decodeUnknownEffect(ManagedResultJson)(result).pipe( - Effect.mapError((cause) => - readUnavailable('Managed identities are temporarily unavailable', cause), - ), + Schema.decodeEffect(ManagedResultJson)(result).pipe( + Effect.mapError((cause) => readUnavailable('Managed identities are temporarily unavailable', cause)), ), ), ), @@ -158,16 +154,11 @@ const services = ( eq(principalAuthBindings.subjectType, 'api_key'), ), ) - .orderBy( - asc(principalAuthBindings.createdAt), - asc(principalAuthBindings.principalAuthBindingId), - ) + .orderBy(asc(principalAuthBindings.createdAt), asc(principalAuthBindings.principalAuthBindingId)) .limit(limit + 1) .offset(offset) .pipe( - Effect.mapError((cause) => - readUnavailable('Identity bindings are temporarily unavailable', cause), - ), + Effect.mapError((cause) => readUnavailable('Identity bindings are temporarily unavailable', cause)), Effect.timeoutOrElse({ duration: databaseReadTimeout, orElse: () => @@ -182,18 +173,13 @@ const services = ( items: rows.slice(0, limit).map((row) => ({ ...row, createdAt: DateTime.formatIso(DateTime.fromDateUnsafe(row.createdAt)), - revokedAt: - row.revokedAt === null - ? null - : DateTime.formatIso(DateTime.fromDateUnsafe(row.revokedAt)), + revokedAt: row.revokedAt === null ? null : DateTime.formatIso(DateTime.fromDateUnsafe(row.revokedAt)), })), nextOffset: rows.length > limit ? offset + limit : null, })), Effect.flatMap((result) => - Schema.decodeUnknownEffect(SelfResultJson)(result).pipe( - Effect.mapError((cause) => - readUnavailable('Identity bindings are temporarily unavailable', cause), - ), + Schema.decodeEffect(SelfResultJson)(result).pipe( + Effect.mapError((cause) => readUnavailable('Identity bindings are temporarily unavailable', cause)), ), ), ), @@ -211,7 +197,10 @@ export const selfApiKeyBindingsRead = defineRead< accessKind: 'list', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.identity.self-api-key-bindings', moduleKey: 'core.identity', role: 'api', @@ -230,9 +219,12 @@ export const selfApiKeyBindingsRead = defineRead< schemaVersion: '1', }, (input, context) => - context.services - .listSelf(input) - .pipe(Effect.map((result) => ({ evidence: { resultCount: result.items.length }, result }))), + context.services.listSelf(input).pipe( + Effect.map((result) => ({ + evidence: { resultCount: result.items.length }, + result, + })), + ), (transaction, scope) => Effect.succeed(services(transaction, scope.tenantId, scope.principalId)), () => ({ kind: 'tenant', permission: 'access' }), ); @@ -249,7 +241,10 @@ export const managedPrincipalsRead = defineRead< accessKind: 'list', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.identity.managed-principals', moduleKey: 'core.identity', role: 'api', @@ -268,9 +263,12 @@ export const managedPrincipalsRead = defineRead< schemaVersion: '1', }, (input, context) => - context.services - .listManaged(input) - .pipe(Effect.map((result) => ({ evidence: { resultCount: result.items.length }, result }))), + context.services.listManaged(input).pipe( + Effect.map((result) => ({ + evidence: { resultCount: result.items.length }, + result, + })), + ), (transaction, scope) => Effect.succeed(services(transaction, scope.tenantId, scope.principalId)), () => ({ kind: 'tenant', permission: 'manage_identity' }), ); diff --git a/app/packages/core-runtime/src/auth/principal-management-errors.ts b/app/packages/core-runtime/src/auth/principal-management-errors.ts index 43f8e5e9f..547def7c4 100644 --- a/app/packages/core-runtime/src/auth/principal-management-errors.ts +++ b/app/packages/core-runtime/src/auth/principal-management-errors.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { IdentityLifecycleConflictError } from './identity-lifecycle-conflict-error.ts'; import { IdentityPersistenceUnavailableError } from './identity-persistence-unavailable-error.ts'; import { IdentityTargetInvalidError } from './identity-target-invalid-error.ts'; diff --git a/app/packages/core-runtime/src/auth/principal-management.ts b/app/packages/core-runtime/src/auth/principal-management.ts index 24f6895f9..ba56da622 100644 --- a/app/packages/core-runtime/src/auth/principal-management.ts +++ b/app/packages/core-runtime/src/auth/principal-management.ts @@ -1,5 +1,6 @@ import { and, eq, isNull } from 'drizzle-orm'; import { Context, DateTime, Effect, Option } from 'effect'; + import type { BindingStatus, PrincipalKind, PrincipalStatus } from '../db/schema.ts'; import { principalAuthBindings, principals } from '../db/schema.ts'; import type { ScopedTransactionExecutor } from '../db/scoped-transaction.ts'; @@ -16,16 +17,24 @@ const persistenceFailure = (cause?: FailureCause) => { reason: 'Identity state could not be persisted', }); if (cause !== undefined) { - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); } return failure; }; const conflict = (reason: string) => - new IdentityLifecycleConflictError({ code: 'identity_lifecycle_conflict', reason }); -const invalid = (reason: string) => - new IdentityTargetInvalidError({ code: 'identity_target_invalid', reason }); + new IdentityLifecycleConflictError({ + code: 'identity_lifecycle_conflict', + reason, + }); +const invalid = (reason: string) => new IdentityTargetInvalidError({ code: 'identity_target_invalid', reason }); -type PrincipalRecord = Readonly<{ readonly kind: PrincipalKind; readonly status: PrincipalStatus }>; +type PrincipalRecord = Readonly<{ + readonly kind: PrincipalKind; + readonly status: PrincipalStatus; +}>; type ApiKeyBindingRecord = Readonly<{ readonly bindingStatus: BindingStatus; readonly principalKind: PrincipalKind; @@ -36,16 +45,10 @@ type SupportBindingRecord = Readonly<{ readonly authBindingId: string }>; export interface PrincipalManagementPersistence { readonly createPrincipal: ( input: CreateNonHumanPrincipalInput, - ) => Effect.Effect< - Option.Option<{ readonly principalId: string }>, - IdentityPersistenceUnavailableError - >; + ) => Effect.Effect, IdentityPersistenceUnavailableError>; readonly insertApiKeyBinding: ( input: BindApiKeyInput, - ) => Effect.Effect< - Option.Option<{ readonly authBindingId: string }>, - IdentityPersistenceUnavailableError - >; + ) => Effect.Effect, IdentityPersistenceUnavailableError>; readonly loadApiKeyBinding: ( input: SetApiKeyBindingStatusInput, ) => Effect.Effect, IdentityPersistenceUnavailableError>; @@ -61,37 +64,26 @@ export interface PrincipalManagementPersistence { }) => Effect.Effect; readonly updateApiKeyBindingStatus: ( input: SetApiKeyBindingStatusInput, - ) => Effect.Effect< - Option.Option<{ readonly status: BindingStatus }>, - IdentityPersistenceUnavailableError - >; + ) => Effect.Effect, IdentityPersistenceUnavailableError>; readonly updatePrincipalStatus: ( input: ChangePrincipalStatusInput, - ) => Effect.Effect< - Option.Option<{ readonly status: PrincipalStatus }>, - IdentityPersistenceUnavailableError - >; + ) => Effect.Effect, IdentityPersistenceUnavailableError>; } export interface PrincipalManagementRepositoryService { readonly bindApiKey: ( input: BindApiKeyInput, - ) => Effect.Effect< - { readonly authBindingId: string; readonly status: 'active' }, - PrincipalManagementError - >; - readonly changePrincipalStatus: ( - input: ChangePrincipalStatusInput, - ) => Effect.Effect< - { readonly previousStatus: PrincipalStatus; readonly status: PrincipalStatus }, + ) => Effect.Effect<{ readonly authBindingId: string; readonly status: 'active' }, PrincipalManagementError>; + readonly changePrincipalStatus: (input: ChangePrincipalStatusInput) => Effect.Effect< + { + readonly previousStatus: PrincipalStatus; + readonly status: PrincipalStatus; + }, PrincipalManagementError >; readonly createNonHumanPrincipal: ( input: CreateNonHumanPrincipalInput, - ) => Effect.Effect< - { readonly principalId: string; readonly status: 'active' }, - PrincipalManagementError - >; + ) => Effect.Effect<{ readonly principalId: string; readonly status: 'active' }, PrincipalManagementError>; readonly setApiKeyBindingStatus: ( input: SetApiKeyBindingStatusInput, ) => Effect.Effect< @@ -137,7 +129,9 @@ const principalManagementPersistenceFromTransaction = ( tenantId: input.tenantId, }) .onConflictDoNothing() - .returning({ authBindingId: principalAuthBindings.principalAuthBindingId }) + .returning({ + authBindingId: principalAuthBindings.principalAuthBindingId, + }) .pipe( Effect.mapError(persistenceFailure), Effect.map(([created]) => Option.fromNullishOr(created)), @@ -236,8 +230,7 @@ const principalManagementPersistenceFromTransaction = ( transaction .update(principals) .set({ - disabledAt: - input.newStatus === 'disabled' ? DateTime.toDateUtc(DateTime.nowUnsafe()) : null, + disabledAt: input.newStatus === 'disabled' ? DateTime.toDateUtc(DateTime.nowUnsafe()) : null, status: input.newStatus, }) .where( @@ -268,7 +261,10 @@ const createNonHumanPrincipalFor = (persistence: PrincipalManagementPersistence) if (Option.isNone(created)) { return yield* persistenceFailure(); } - return { principalId: created.value.principalId, status: 'active' as const }; + return { + principalId: created.value.principalId, + status: 'active' as const, + }; }); export interface ChangePrincipalStatusInput { @@ -279,8 +275,7 @@ export interface ChangePrincipalStatusInput { readonly tenantId: string; } -const hasStatusChangeReason = (reason: string | undefined): boolean => - reason !== undefined && reason.trim().length > 0; +const hasStatusChangeReason = (reason: string | undefined): boolean => reason !== undefined && reason.trim().length > 0; const principalTransitionAllowed = (current: PrincipalStatus, next: PrincipalStatus): boolean => (current === 'active' && ['disabled', 'archived'].includes(next)) || @@ -291,9 +286,7 @@ const bindingTransitionAllowed = (current: BindingStatus, next: BindingStatus): (current === 'disabled' && ['active', 'revoked'].includes(next)); const changePrincipalStatusFor = (persistence: PrincipalManagementPersistence) => - Effect.fn('PrincipalManagement.changePrincipalStatus')(function* changeStatus( - input: ChangePrincipalStatusInput, - ) { + Effect.fn('PrincipalManagement.changePrincipalStatus')(function* changeStatus(input: ChangePrincipalStatusInput) { const target = yield* persistence.loadPrincipal(input.tenantId, input.principalId); if (Option.isNone(target) || target.value.kind === 'human') { return yield* invalid('The target is not a tenant-local non-human principal'); @@ -311,7 +304,10 @@ const changePrincipalStatusFor = (persistence: PrincipalManagementPersistence) = if (Option.isNone(updated)) { return yield* conflict('The principal status changed concurrently'); } - return { previousStatus: input.expectedStatus, status: updated.value.status }; + return { + previousStatus: input.expectedStatus, + status: updated.value.status, + }; }); export interface BindApiKeyInput { @@ -324,21 +320,18 @@ export interface BindApiKeyInput { const bindApiKeyFor = (persistence: PrincipalManagementPersistence) => Effect.fn('PrincipalManagement.bindApiKey')(function* bindKey(input: BindApiKeyInput) { const target = yield* persistence.loadPrincipal(input.tenantId, input.principalId); - const allowedKinds: readonly PrincipalKind[] = input.managed - ? ['service', 'integration'] - : ['human']; - if ( - Option.isNone(target) || - target.value.status !== 'active' || - !allowedKinds.includes(target.value.kind) - ) { + const allowedKinds: readonly PrincipalKind[] = input.managed ? ['service', 'integration'] : ['human']; + if (Option.isNone(target) || target.value.status !== 'active' || !allowedKinds.includes(target.value.kind)) { return yield* invalid('The API key target is not eligible'); } const created = yield* persistence.insertApiKeyBinding(input); if (Option.isNone(created)) { return yield* conflict('The API key is already bound'); } - return { authBindingId: created.value.authBindingId, status: 'active' as const }; + return { + authBindingId: created.value.authBindingId, + status: 'active' as const, + }; }); export interface SetApiKeyBindingStatusInput { @@ -360,40 +353,34 @@ export interface ValidateSupportImpersonationInput { } const validateSupportImpersonationFor = (persistence: PrincipalManagementPersistence) => - Effect.fn('PrincipalManagement.validateSupportImpersonation')( - function* validateSupportParticipants(input: ValidateSupportImpersonationInput) { - const loadHumanBindings = (principalId: string, authBindingId?: string) => { - const query = { - activeOnly: input.checkpoint !== 'stopped', - principalId, - tenantId: input.tenantId, - }; - return persistence.loadSupportBindings( - authBindingId === undefined ? query : { ...query, authBindingId }, - ); + Effect.fn('PrincipalManagement.validateSupportImpersonation')(function* validateSupportParticipants( + input: ValidateSupportImpersonationInput, + ) { + const loadHumanBindings = (principalId: string, authBindingId?: string) => { + const query = { + activeOnly: input.checkpoint !== 'stopped', + principalId, + tenantId: input.tenantId, }; - const [original, target] = yield* Effect.all( - [ - loadHumanBindings(input.originalPrincipalId, input.originalAuthBindingId), - loadHumanBindings(input.targetPrincipalId), - ], - { concurrency: 1 }, + return persistence.loadSupportBindings(authBindingId === undefined ? query : { ...query, authBindingId }); + }; + const [original, target] = yield* Effect.all( + [ + loadHumanBindings(input.originalPrincipalId, input.originalAuthBindingId), + loadHumanBindings(input.targetPrincipalId), + ], + { concurrency: 1 }, + ); + if (original.length !== 1 || target.length === 0) { + return yield* invalid( + input.checkpoint === 'stopped' + ? 'The impersonation participants are not tenant-local users' + : 'The impersonation participants are not active tenant-local users', ); - if (original.length !== 1 || target.length === 0) { - return yield* invalid( - input.checkpoint === 'stopped' - ? 'The impersonation participants are not tenant-local users' - : 'The impersonation participants are not active tenant-local users', - ); - } - }, - ); + } + }); -const isEligibleBindingTarget = ( - managed: boolean, - status: PrincipalStatus, - kind: PrincipalKind, -): boolean => { +const isEligibleBindingTarget = (managed: boolean, status: PrincipalStatus, kind: PrincipalKind): boolean => { const allowedKinds: readonly PrincipalKind[] = managed ? ['service', 'integration'] : ['human']; return status === 'active' && allowedKinds.includes(kind); }; @@ -406,13 +393,7 @@ const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) if (Option.isNone(binding)) { return yield* invalid('The API key binding is unavailable'); } - if ( - !isEligibleBindingTarget( - input.managed, - binding.value.principalStatus, - binding.value.principalKind, - ) - ) { + if (!isEligibleBindingTarget(input.managed, binding.value.principalStatus, binding.value.principalKind)) { return yield* invalid('The API key binding target is not eligible'); } if (binding.value.bindingStatus !== input.expectedStatus) { @@ -428,7 +409,10 @@ const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) if (Option.isNone(updated)) { return yield* conflict('The binding status changed concurrently'); } - return { previousStatus: input.expectedStatus, status: updated.value.status }; + return { + previousStatus: input.expectedStatus, + status: updated.value.status, + }; }); export const principalManagementRepositoryFromPersistence = ( @@ -445,29 +429,19 @@ export const principalManagementRepositoryFromPersistence = ( export const principalManagementRepositoryFromTransaction = ( transaction: Pick, ): PrincipalManagementRepositoryService => - principalManagementRepositoryFromPersistence( - principalManagementPersistenceFromTransaction(transaction), - ); + principalManagementRepositoryFromPersistence(principalManagementPersistenceFromTransaction(transaction)); export const createNonHumanPrincipal = (input: CreateNonHumanPrincipalInput) => - PrincipalManagementRepository.pipe( - Effect.flatMap((repository) => repository.createNonHumanPrincipal(input)), - ); + PrincipalManagementRepository.pipe(Effect.flatMap((repository) => repository.createNonHumanPrincipal(input))); export const changePrincipalStatus = (input: ChangePrincipalStatusInput) => - PrincipalManagementRepository.pipe( - Effect.flatMap((repository) => repository.changePrincipalStatus(input)), - ); + PrincipalManagementRepository.pipe(Effect.flatMap((repository) => repository.changePrincipalStatus(input))); export const bindApiKey = (input: BindApiKeyInput) => PrincipalManagementRepository.pipe(Effect.flatMap((repository) => repository.bindApiKey(input))); export const validateSupportImpersonation = (input: ValidateSupportImpersonationInput) => - PrincipalManagementRepository.pipe( - Effect.flatMap((repository) => repository.validateSupportImpersonation(input)), - ); + PrincipalManagementRepository.pipe(Effect.flatMap((repository) => repository.validateSupportImpersonation(input))); export const setApiKeyBindingStatus = (input: SetApiKeyBindingStatusInput) => - PrincipalManagementRepository.pipe( - Effect.flatMap((repository) => repository.setApiKeyBindingStatus(input)), - ); + PrincipalManagementRepository.pipe(Effect.flatMap((repository) => repository.setApiKeyBindingStatus(input))); diff --git a/app/packages/core-runtime/src/auth/principal-resolver.ts b/app/packages/core-runtime/src/auth/principal-resolver.ts index a7d42212d..b081a21b1 100644 --- a/app/packages/core-runtime/src/auth/principal-resolver.ts +++ b/app/packages/core-runtime/src/auth/principal-resolver.ts @@ -1,15 +1,10 @@ import { and, eq } from 'drizzle-orm'; import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; import { Context, Effect, Layer, Schema } from 'effect'; + import { CoreDatabase } from '../db/client.ts'; import type { PrincipalKind } from '../db/schema.ts'; -import { - actionInvocations, - auditEvents, - principalAuthBindings, - principals, - tenants, -} from '../db/schema.ts'; +import { actionInvocations, auditEvents, principalAuthBindings, principals, tenants } from '../db/schema.ts'; import type { CoreDatabaseExecutor } from '../db/types.ts'; import type { PrincipalResolutionError } from './principal-resolver-errors.ts'; import { @@ -39,10 +34,9 @@ export interface ApiKeyBindingAdministration { readonly status: 'active' | 'disabled' | 'revoked'; } -const ProviderSubjectIdSchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(500), -).pipe(Schema.brand('ProviderSubjectId')); +const ProviderSubjectIdSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)).pipe( + Schema.brand('ProviderSubjectId'), +); export const ProviderSubjectSchema = Schema.Struct({ provider: Schema.Literal('better_auth'), @@ -79,17 +73,13 @@ export interface PrincipalResolutionRecord { readonly tenantStatus: string; } -type PrincipalResolutionRecordLoadResult = Effect.Effect< - readonly PrincipalResolutionRecord[], - EffectDrizzleQueryError ->; +type PrincipalResolutionRecordLoadResult = Effect.Effect; interface PrincipalResolutionRecordReader { readonly load: (subject: ProviderSubject, tenantId?: string) => Result; } -type PrincipalResolutionRecordRepository = - PrincipalResolutionRecordReader; +type PrincipalResolutionRecordRepository = PrincipalResolutionRecordReader; const attachCause = (failure: Failure, cause: unknown): Failure => cause === undefined ? failure : Object.defineProperty(failure, 'cause', { value: cause }); @@ -104,11 +94,7 @@ const loadPrincipalResolutionRecords = => repository .load(subject, tenantId) - .pipe( - Effect.mapError((cause) => - unavailable('Unable to resolve the authenticated principal', cause), - ), - ); + .pipe(Effect.mapError((cause) => unavailable('Unable to resolve the authenticated principal', cause))); const compareText = (left: string, right: string): number => { if (left < right) { @@ -166,9 +152,7 @@ const eligibleHumanRecords = ( eligibleRecords(records).pipe( Effect.flatMap((eligible) => { const humans = eligible.filter((record) => record.principalKind === 'human'); - return humans.length === 0 - ? Effect.fail(new PrincipalInactiveError()) - : Effect.succeed(humans); + return humans.length === 0 ? Effect.fail(new PrincipalInactiveError()) : Effect.succeed(humans); }), ); @@ -178,11 +162,11 @@ export const classifyAvailableTenants = ( eligibleHumanRecords(records).pipe( Effect.map((eligible) => eligible - .map((record) => ({ name: record.tenantName, tenantId: record.tenantId })) - .toSorted( - (left, right) => - compareText(left.name, right.name) || compareText(left.tenantId, right.tenantId), - ), + .map((record) => ({ + name: record.tenantName, + tenantId: record.tenantId, + })) + .toSorted((left, right) => compareText(left.name, right.name) || compareText(left.tenantId, right.tenantId)), ), ); @@ -208,9 +192,7 @@ export const classifyDefaultPrincipal = ( ), ), Effect.flatMap(([first]) => - first === undefined - ? Effect.fail(new PrincipalBindingMissingError()) - : Effect.succeed(toResolvedIdentity(first)), + first === undefined ? Effect.fail(new PrincipalBindingMissingError()) : Effect.succeed(toResolvedIdentity(first)), ), ); @@ -283,10 +265,9 @@ export interface PrincipalResolverService { }) => Effect.Effect; } -export class PrincipalResolver extends Context.Service< - PrincipalResolver, - PrincipalResolverService ->()('@app/core-runtime/auth/principal-resolver/PrincipalResolver') {} +export class PrincipalResolver extends Context.Service()( + '@app/core-runtime/auth/principal-resolver/PrincipalResolver', +) {} export const makePrincipalResolver = (database: { readonly executor: CoreDatabaseExecutor; @@ -354,29 +335,22 @@ export const makePrincipalResolver = (database: { ); return { - listAvailableTenants: (betterAuthUserId) => - listAvailableTenantsFromRepository(recordRepository, betterAuthUserId), + listAvailableTenants: (betterAuthUserId) => listAvailableTenantsFromRepository(recordRepository, betterAuthUserId), loadApiKeyBindingForAdministration: (input) => loadApiKeyBindingSubject(input).pipe( - Effect.flatMap( - (record): Effect.Effect => - record === undefined - ? Effect.fail(new PrincipalBindingMissingError()) - : Effect.succeed({ - providerSubjectId: record.providerSubjectId, - status: record.status, - }), + Effect.flatMap((record): Effect.Effect => + record === undefined + ? Effect.fail(new PrincipalBindingMissingError()) + : Effect.succeed({ + providerSubjectId: record.providerSubjectId, + status: record.status, + }), ), ), resolveApiKeyBindingSubject: (input) => loadApiKeyBindingSubject(input).pipe( Effect.flatMap( - ( - record, - ): Effect.Effect< - string, - PrincipalBindingInactiveError | PrincipalBindingMissingError - > => { + (record): Effect.Effect => { if (record === undefined) { return Effect.fail(new PrincipalBindingMissingError()); } @@ -419,19 +393,10 @@ export const makePrincipalResolver = (database: { ), ) .pipe( - Effect.mapError((cause) => - unavailable('Unable to resolve the principal provider subject', cause), - ), + Effect.mapError((cause) => unavailable('Unable to resolve the principal provider subject', cause)), Effect.flatMap( - ( - records, - ): Effect.Effect< - string, - PrincipalBindingAmbiguousError | PrincipalBindingMissingError - > => { - const active = records.filter( - (record) => record.status === 'active' && record.revokedAt === null, - ); + (records): Effect.Effect => { + const active = records.filter((record) => record.status === 'active' && record.revokedAt === null); if (active.length === 0) { return Effect.fail(new PrincipalBindingMissingError()); } @@ -493,9 +458,7 @@ export const makePrincipalResolver = (database: { ), ) .pipe( - Effect.mapError((cause) => - unavailable('Unable to verify the support impersonation lifecycle', cause), - ), + Effect.mapError((cause) => unavailable('Unable to verify the support impersonation lifecycle', cause)), Effect.map((records) => records.some(({ evidence }) => { if (!Schema.is(SupportImpersonationStartedEvidenceSchema)(evidence)) { diff --git a/app/packages/core-runtime/src/auth/support-recovery-principal-context-denied-error.ts b/app/packages/core-runtime/src/auth/support-recovery-principal-context-denied-error.ts index 9d9adc08a..1dcda8b11 100644 --- a/app/packages/core-runtime/src/auth/support-recovery-principal-context-denied-error.ts +++ b/app/packages/core-runtime/src/auth/support-recovery-principal-context-denied-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class SupportRecoveryPrincipalContextDeniedError extends Schema.TaggedError()( 'SupportRecoveryPrincipalContextDeniedError', - { code: Schema.Literal('support_recovery_context_denied'), reason: Schema.String }, + { + code: Schema.Literal('support_recovery_context_denied'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/support-recovery-principal-context-unavailable-error.ts b/app/packages/core-runtime/src/auth/support-recovery-principal-context-unavailable-error.ts index 1c1c95600..060095660 100644 --- a/app/packages/core-runtime/src/auth/support-recovery-principal-context-unavailable-error.ts +++ b/app/packages/core-runtime/src/auth/support-recovery-principal-context-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class SupportRecoveryPrincipalContextUnavailableError extends Schema.TaggedError()( 'SupportRecoveryPrincipalContextUnavailableError', - { code: Schema.Literal('support_recovery_context_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('support_recovery_context_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts b/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts index 47dc98b11..3df34cc1e 100644 --- a/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts +++ b/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { Context, Duration, Effect, Layer, Option, Schema } from 'effect'; + import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; import { CoreDatabase } from '../db/client.ts'; import { principalAuthBindings, principals, tenants } from '../db/schema.ts'; @@ -117,9 +118,7 @@ const supportRecoveryPrincipalContextRepositoryFromDatabase = (database: { }); const isInvalidRecoveryInput = ( - input: Parameters< - SupportRecoveryPrincipalContextResolverService['resolveStoppedImpersonation'] - >[0], + input: Parameters[0], ): boolean => !Schema.is(uuid)(input.originalAuthBindingId) || !Schema.is(uuid)(input.originalPrincipalId) || @@ -131,53 +130,52 @@ const isInvalidRecoveryInput = ( const supportRecoveryPrincipalContextResolverFromEffectRecordReader = ( repository: SupportRecoveryPrincipalContextEffectRecordReader, ): SupportRecoveryPrincipalContextResolverService => ({ - resolveStoppedImpersonation: Effect.fn( - 'SupportRecoveryPrincipalContext.resolveStoppedImpersonation', - )(function* resolveStoppedImpersonation(input): Effect.fn.Return< - TrustedPrincipalContext, - SupportRecoveryPrincipalContextError - > { - if (isInvalidRecoveryInput(input)) { - return yield* new SupportRecoveryPrincipalContextDeniedError({ - code: 'support_recovery_context_denied', - reason: 'The support recovery identity is invalid', - }); - } - const maybeRecord = yield* repository.load({ - originalAuthBindingId: input.originalAuthBindingId, - originalPrincipalId: input.originalPrincipalId, - tenantId: input.tenantId, - }); - if (Option.isNone(maybeRecord)) { - return yield* new SupportRecoveryPrincipalContextDeniedError({ - code: 'support_recovery_context_denied', - reason: 'The support recovery identity is not a historical tenant-local user binding', - }); - } - const record = maybeRecord.value; - if ( - record.bindingPrincipalId !== input.originalPrincipalId || - record.bindingTenantId !== input.tenantId || - record.principalKind !== 'human' || - record.principalTenantId !== input.tenantId || - record.tenantId !== input.tenantId - ) { - return yield* new SupportRecoveryPrincipalContextDeniedError({ - code: 'support_recovery_context_denied', - reason: 'The support recovery identity is not a historical tenant-local user binding', - }); - } - return trustSupportRecoveryPrincipalContext( - Object.freeze({ - authBindingId: input.originalAuthBindingId, - authContextRef: `better-auth-session:${input.originalSessionId}`, - authMethod: 'session' as const, - principalId: input.originalPrincipalId, + resolveStoppedImpersonation: Effect.fn('SupportRecoveryPrincipalContext.resolveStoppedImpersonation')( + function* resolveStoppedImpersonation( + input, + ): Effect.fn.Return { + if (isInvalidRecoveryInput(input)) { + return yield* new SupportRecoveryPrincipalContextDeniedError({ + code: 'support_recovery_context_denied', + reason: 'The support recovery identity is invalid', + }); + } + const maybeRecord = yield* repository.load({ + originalAuthBindingId: input.originalAuthBindingId, + originalPrincipalId: input.originalPrincipalId, tenantId: input.tenantId, - }), - recordSupportImpersonationAction, - ); - }), + }); + if (Option.isNone(maybeRecord)) { + return yield* new SupportRecoveryPrincipalContextDeniedError({ + code: 'support_recovery_context_denied', + reason: 'The support recovery identity is not a historical tenant-local user binding', + }); + } + const record = maybeRecord.value; + if ( + record.bindingPrincipalId !== input.originalPrincipalId || + record.bindingTenantId !== input.tenantId || + record.principalKind !== 'human' || + record.principalTenantId !== input.tenantId || + record.tenantId !== input.tenantId + ) { + return yield* new SupportRecoveryPrincipalContextDeniedError({ + code: 'support_recovery_context_denied', + reason: 'The support recovery identity is not a historical tenant-local user binding', + }); + } + return trustSupportRecoveryPrincipalContext( + Object.freeze({ + authBindingId: input.originalAuthBindingId, + authContextRef: `better-auth-session:${input.originalSessionId}`, + authMethod: 'session' as const, + principalId: input.originalPrincipalId, + tenantId: input.tenantId, + }), + recordSupportImpersonationAction, + ); + }, + ), }); export const supportRecoveryPrincipalContextResolverFromRepository = ( @@ -195,9 +193,7 @@ export const makeSupportRecoveryPrincipalContextResolver = (database: { export class SupportRecoveryPrincipalContextResolver extends Context.Service< SupportRecoveryPrincipalContextResolver, SupportRecoveryPrincipalContextResolverService ->()( - '@app/core-runtime/auth/support-recovery-principal-context/SupportRecoveryPrincipalContextResolver', -) {} +>()('@app/core-runtime/auth/support-recovery-principal-context/SupportRecoveryPrincipalContextResolver') {} export const SupportRecoveryPrincipalContextResolverLive = Layer.effect( SupportRecoveryPrincipalContextResolver, diff --git a/app/packages/core-runtime/src/auth/system-principal-context-denied-error.ts b/app/packages/core-runtime/src/auth/system-principal-context-denied-error.ts index 0b166d6f0..9ed83e841 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context-denied-error.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context-denied-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class SystemPrincipalContextDeniedError extends Schema.TaggedError()( 'SystemPrincipalContextDeniedError', - { code: Schema.Literal('system_principal_context_denied'), reason: Schema.String }, + { + code: Schema.Literal('system_principal_context_denied'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/system-principal-context-invalid-error.ts b/app/packages/core-runtime/src/auth/system-principal-context-invalid-error.ts index 7a42d0f1f..56e72fe5d 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context-invalid-error.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context-invalid-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class SystemPrincipalContextInvalidError extends Schema.TaggedError()( 'SystemPrincipalContextInvalidError', - { code: Schema.Literal('system_principal_context_invalid'), reason: Schema.String }, + { + code: Schema.Literal('system_principal_context_invalid'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/system-principal-context-provenance.ts b/app/packages/core-runtime/src/auth/system-principal-context-provenance.ts index 256bb42ee..3b03d133a 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context-provenance.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context-provenance.ts @@ -1,19 +1,21 @@ import { Effect, Schema, Predicate } from 'effect'; + import { TrustedPrincipalContextSchema } from '../actions/principal-context.ts'; import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; -const SystemPrincipalContextSchema = Schema.Struct({ authMethod: Schema.Literal('system') }); -const SessionPrincipalContextSchema = Schema.Struct({ authMethod: Schema.Literal('session') }); +const SystemPrincipalContextSchema = Schema.Struct({ + authMethod: Schema.Literal('system'), +}); +const SessionPrincipalContextSchema = Schema.Struct({ + authMethod: Schema.Literal('session'), +}); const systemProvenance = Object.freeze({ kind: 'system' }); const supportRecoveryProvenance = Object.freeze({ kind: 'support_recovery' }); const provenanceAccessProperty = '__ontosCorePrincipalContextProvenanceAccess'; -const PrincipalContextProvenanceInvariant = Schema.TaggedError()( - 'PrincipalContextProvenanceInvariant', - { - reason: Schema.String, - }, -); +const PrincipalContextProvenanceInvariant = Schema.TaggedError()('PrincipalContextProvenanceInvariant', { + reason: Schema.String, +}); export class TrustedPrincipalContextDecodeError extends Schema.TaggedError()( 'TrustedPrincipalContextDecodeError', @@ -48,7 +50,9 @@ const attachPrincipalContextProvenance = < } return provenance === systemProvenance ? true : (actionRegistration ?? false); }; - Object.defineProperty(carrier, provenanceAccessProperty, { value: accessProvenance }); + Object.defineProperty(carrier, provenanceAccessProperty, { + value: accessProvenance, + }); return Object.isFrozen(context) ? Object.freeze(carrier) : carrier; }; @@ -102,17 +106,12 @@ export const trustSupportRecoveryPrincipalContext = < actionRegistration: Registration, ): Context => { if (!Schema.is(SessionPrincipalContextSchema)(context)) { - return failProvenanceInvariant( - 'Only resolved session contexts can carry support recovery provenance', - ); + return failProvenanceInvariant('Only resolved session contexts can carry support recovery provenance'); } return attachPrincipalContextProvenance(context, supportRecoveryProvenance, actionRegistration); }; -export const isTrustedSupportRecoveryPrincipalContext = < - Context, - Registration extends object = object, ->( +export const isTrustedSupportRecoveryPrincipalContext = ( context: Context, actionRegistration?: Registration, ): boolean => { @@ -124,10 +123,7 @@ export const isTrustedSupportRecoveryPrincipalContext = < ); }; -export const preserveSystemPrincipalContextTrust = < - Source, - Context extends TrustedPrincipalContext, ->( +export const preserveSystemPrincipalContextTrust = ( source: Source, context: Context, ): Context => { @@ -149,7 +145,9 @@ export const decodeTrustedPrincipalContext = ( } return Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)(input).pipe( Effect.mapError((cause) => - Object.defineProperty(new TrustedPrincipalContextDecodeError(), 'cause', { value: cause }), + Object.defineProperty(new TrustedPrincipalContextDecodeError(), 'cause', { + value: cause, + }), ), Effect.map((context) => preserveSystemPrincipalContextTrust(input, context)), ); diff --git a/app/packages/core-runtime/src/auth/system-principal-context-unavailable-error.ts b/app/packages/core-runtime/src/auth/system-principal-context-unavailable-error.ts index fcd224036..f2b24c52d 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context-unavailable-error.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class SystemPrincipalContextUnavailableError extends Schema.TaggedError()( 'SystemPrincipalContextUnavailableError', - { code: Schema.Literal('system_principal_context_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('system_principal_context_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/auth/system-principal-context.ts b/app/packages/core-runtime/src/auth/system-principal-context.ts index 8babe4471..fde5728a1 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { Duration, Effect, Option, Schema } from 'effect'; + import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; import { principals, tenants } from '../db/schema.ts'; import type { CoreDatabaseExecutor } from '../db/types.ts'; @@ -62,9 +63,7 @@ type SystemPrincipalContextRepositoryLoadResult = Effect.Effect< SystemPrincipalContextUnavailableError >; -interface SystemPrincipalContextRecordReader< - Result extends SystemPrincipalContextRepositoryLoadResult, -> { +interface SystemPrincipalContextRecordReader { readonly load: (input: { readonly principalId: string; readonly tenantId: string }) => Result; } @@ -82,15 +81,11 @@ const unavailable = (cause?: unknown): SystemPrincipalContextUnavailableError => const DATABASE_OPERATION_TIMEOUT = Duration.seconds(30); -const loadSystemPrincipalContextRecord = < - Result extends SystemPrincipalContextRepositoryLoadResult, ->( +const loadSystemPrincipalContextRecord = ( repository: SystemPrincipalContextRecordReader, input: { readonly principalId: string; readonly tenantId: string }, -): Effect.Effect< - Option.Option, - SystemPrincipalContextUnavailableError -> => repository.load(input); +): Effect.Effect, SystemPrincipalContextUnavailableError> => + repository.load(input); const systemPrincipalContextRepositoryFromDatabase = (database: { readonly executor: Pick; @@ -104,9 +99,7 @@ const systemPrincipalContextRepositoryFromDatabase = (database: { }) .from(principals) .innerJoin(tenants, eq(tenants.tenantId, principals.tenantId)) - .where( - and(eq(principals.tenantId, input.tenantId), eq(principals.principalId, input.principalId)), - ) + .where(and(eq(principals.tenantId, input.tenantId), eq(principals.principalId, input.principalId))) .limit(1) .pipe( Effect.mapError(unavailable), @@ -122,14 +115,11 @@ const isEligibleSystemPrincipal = ( record: SystemPrincipalContextRecord, registration: SystemWorkloadRegistration, ): boolean => { - const kindAllowed = - record.kind === 'system' || (registration.allowServicePrincipal && record.kind === 'service'); + const kindAllowed = record.kind === 'system' || (registration.allowServicePrincipal && record.kind === 'service'); return record.principalStatus === 'active' && record.tenantStatus === 'active' && kindAllowed; }; -export const systemPrincipalContextResolverFromRepository = < - Result extends SystemPrincipalContextRepositoryLoadResult, ->( +export const systemPrincipalContextResolverFromRepository = ( repository: SystemPrincipalContextRecordReader, ) => ({ resolve: Effect.fn('systemPrincipalContextResolverFromRepository.resolve')( @@ -181,7 +171,4 @@ export const systemPrincipalContextResolverFromRepository = < export const makeSystemPrincipalContextResolver = (database: { readonly executor: Pick; -}) => - systemPrincipalContextResolverFromRepository( - systemPrincipalContextRepositoryFromDatabase(database), - ); +}) => systemPrincipalContextResolverFromRepository(systemPrincipalContextRepositoryFromDatabase(database)); diff --git a/app/packages/core-runtime/src/auth/tenant-inactive-error.ts b/app/packages/core-runtime/src/auth/tenant-inactive-error.ts index 53f7a986d..eb72f51dc 100644 --- a/app/packages/core-runtime/src/auth/tenant-inactive-error.ts +++ b/app/packages/core-runtime/src/auth/tenant-inactive-error.ts @@ -1,6 +1,3 @@ import { Schema } from 'effect'; -export class TenantInactiveError extends Schema.TaggedError()( - 'TenantInactiveError', - {}, -) {} +export class TenantInactiveError extends Schema.TaggedError()('TenantInactiveError', {}) {} diff --git a/app/packages/core-runtime/src/authorization/entrypoint-classification.ts b/app/packages/core-runtime/src/authorization/entrypoint-classification.ts index 74aa6db3f..6d87e8309 100644 --- a/app/packages/core-runtime/src/authorization/entrypoint-classification.ts +++ b/app/packages/core-runtime/src/authorization/entrypoint-classification.ts @@ -48,9 +48,7 @@ export const EntrypointAuthorizationSchema = Schema.Union([ ]); export type EntrypointAuthorization = Schema.Schema.Type; -export type ActionExecutionAuthorization = Schema.Schema.Type< - typeof ActionExecutionAuthorizationSchema ->; +export type ActionExecutionAuthorization = Schema.Schema.Type; export const decodeEntrypointAuthorization = (input: Input): EntrypointAuthorization => Object.freeze( diff --git a/app/packages/core-runtime/src/authorization/rollout-decision.ts b/app/packages/core-runtime/src/authorization/rollout-decision.ts index dae1523dc..9c12d3ffc 100644 --- a/app/packages/core-runtime/src/authorization/rollout-decision.ts +++ b/app/packages/core-runtime/src/authorization/rollout-decision.ts @@ -62,10 +62,7 @@ const nonBypassableReasons = new Set([ 'wrong_audience', ]); -const isReportOnlyActive = ( - contract: AuthorizationRolloutRuntimeContract, - nowEpochMs: number, -): boolean => +const isReportOnlyActive = (contract: AuthorizationRolloutRuntimeContract, nowEpochMs: number): boolean => contract.mode === 'report_only' && nowEpochMs >= contract.activatedAtEpochMs && nowEpochMs < contract.expiresAtEpochMs; diff --git a/app/packages/core-runtime/src/database/driver-failure.ts b/app/packages/core-runtime/src/database/driver-failure.ts index 73de43fc4..bbd2d1188 100644 --- a/app/packages/core-runtime/src/database/driver-failure.ts +++ b/app/packages/core-runtime/src/database/driver-failure.ts @@ -1,4 +1,5 @@ import { Option, Schema } from 'effect'; + import { findPostgresFailure } from './postgres-failure.ts'; export const DatabaseDriverFailureKindSchema = Schema.Literals(['socket', 'sqlstate']); @@ -16,16 +17,12 @@ const DatabaseCommitAcknowledgementAmbiguousContract = Schema.TaggedStruct( type DatabaseCommitAcknowledgementAmbiguousSelf = Schema.Schema.Type< typeof DatabaseCommitAcknowledgementAmbiguousContract >; -export const DatabaseCommitAcknowledgementAmbiguous = - Schema.TaggedError()( - 'DatabaseCommitAcknowledgementAmbiguous', - driverFailureFields, - ); - -const DatabaseTransactionFailureContract = Schema.TaggedStruct( - 'DatabaseTransactionFailure', +export const DatabaseCommitAcknowledgementAmbiguous = Schema.TaggedError()( + 'DatabaseCommitAcknowledgementAmbiguous', driverFailureFields, ); + +const DatabaseTransactionFailureContract = Schema.TaggedStruct('DatabaseTransactionFailure', driverFailureFields); type DatabaseTransactionFailureSelf = Schema.Schema.Type; export const DatabaseTransactionFailure = Schema.TaggedError()( 'DatabaseTransactionFailure', @@ -36,14 +33,11 @@ const DatabaseDriverUnavailableFailureContract = Schema.TaggedStruct( 'DatabaseDriverUnavailableFailure', driverFailureFields, ); -type DatabaseDriverUnavailableFailureSelf = Schema.Schema.Type< - typeof DatabaseDriverUnavailableFailureContract ->; -export const DatabaseDriverUnavailableFailure = - Schema.TaggedError()( - 'DatabaseDriverUnavailableFailure', - driverFailureFields, - ); +type DatabaseDriverUnavailableFailureSelf = Schema.Schema.Type; +export const DatabaseDriverUnavailableFailure = Schema.TaggedError()( + 'DatabaseDriverUnavailableFailure', + driverFailureFields, +); export const DatabaseDriverFailureSchema = Schema.Union([ DatabaseCommitAcknowledgementAmbiguous, @@ -52,21 +46,15 @@ export const DatabaseDriverFailureSchema = Schema.Union([ ]); export type DatabaseDriverFailure = Schema.Schema.Type; export const DatabaseDriverFailureInputSchema = Schema.Unknown; -export type DatabaseDriverFailureInput = Schema.Schema.Type< - typeof DatabaseDriverFailureInputSchema ->; +export type DatabaseDriverFailureInput = Schema.Schema.Type; const connectionSqlStateClass = ['0', '8'].join(''); const transactionSqlStateClass = ['4', '0'].join(''); const administrativeShutdownSqlState = ['57', 'P01'].join(''); const unavailableSqlStateClasses = new Set(['08', '40', '53', '55', '57', '58']); -const unavailableSocketCodes = new Set( - 'ECONNREFUSED ECONNRESET EPIPE ETIMEDOUT'.split(' '), -); +const unavailableSocketCodes = new Set('ECONNREFUSED ECONNRESET EPIPE ETIMEDOUT'.split(' ')); const commitAcknowledgementSocketCodes = new Set( - 'ECONNABORTED ECONNRESET EHOSTDOWN EHOSTUNREACH ENETDOWN ENETRESET ENETUNREACH EPIPE ETIMEDOUT'.split( - ' ', - ), + 'ECONNABORTED ECONNRESET EHOSTDOWN EHOSTUNREACH ENETDOWN ENETRESET ENETUNREACH EPIPE ETIMEDOUT'.split(' '), ); const decodeDriverCodeFailure = (code: string): Option.Option => { @@ -91,9 +79,7 @@ const decodeDriverCodeFailure = (code: string): Option.Option unavailableSqlStateClasses.has(code.slice(0, 2)) || unavailableSocketCodes.has(code); -export const decodeDatabaseDriverFailure = ( - input: DatabaseDriverFailureInput, -): Option.Option => +export const decodeDatabaseDriverFailure = (input: DatabaseDriverFailureInput): Option.Option => Option.flatMap( findPostgresFailure(input, ({ code }) => Option.isSome(decodeDriverCodeFailure(code))), ({ code }) => decodeDriverCodeFailure(code), @@ -102,10 +88,5 @@ export const decodeDatabaseDriverFailure = ( export const isDatabaseUnavailableFailure = (input: DatabaseDriverFailureInput): boolean => Option.exists(decodeDatabaseDriverFailure(input), ({ code }) => isUnavailableDriverCode(code)); -export const isDatabaseCommitAcknowledgementAmbiguous = ( - input: DatabaseDriverFailureInput, -): boolean => - Option.exists( - decodeDatabaseDriverFailure(input), - Schema.is(DatabaseCommitAcknowledgementAmbiguous), - ); +export const isDatabaseCommitAcknowledgementAmbiguous = (input: DatabaseDriverFailureInput): boolean => + Option.exists(decodeDatabaseDriverFailure(input), Schema.is(DatabaseCommitAcknowledgementAmbiguous)); diff --git a/app/packages/core-runtime/src/database/postgres-failure.ts b/app/packages/core-runtime/src/database/postgres-failure.ts index 74e6adf4f..e9fcee57f 100644 --- a/app/packages/core-runtime/src/database/postgres-failure.ts +++ b/app/packages/core-runtime/src/database/postgres-failure.ts @@ -1,7 +1,9 @@ import { Cause, Option, Predicate, Schema } from 'effect'; const PostgresFailureCodeSchema = Schema.Struct({ code: Schema.String }); -const PostgresFailureConstraintSchema = Schema.Struct({ constraint: Schema.String }); +const PostgresFailureConstraintSchema = Schema.Struct({ + constraint: Schema.String, +}); const CauseWrapperSchema = Schema.Struct({ cause: Schema.Unknown }); export type PostgresFailureMetadata = Readonly<{ readonly code: string; @@ -24,9 +26,7 @@ const enqueueFailureReasons = (cause: Cause.Cause, pending: unknown[]): } }; -const decodeFailureMetadata = ( - current: PostgresFailureInput, -): Option.Option> => +const decodeFailureMetadata = (current: PostgresFailureInput): Option.Option> => Option.map(decodePostgresFailureCode(current), ({ code }) => { const constraint = decodePostgresFailureConstraint(current); const metadata: PostgresFailureMetadata = Option.isSome(constraint) diff --git a/app/packages/core-runtime/src/db/catalog.ts b/app/packages/core-runtime/src/db/catalog.ts index a37ca454e..3e26f7cbd 100644 --- a/app/packages/core-runtime/src/db/catalog.ts +++ b/app/packages/core-runtime/src/db/catalog.ts @@ -17,17 +17,12 @@ export interface CatalogDifference { readonly unexpected: readonly string[]; } -export const expectedCoreTableCatalog = CORE_TABLE_INVENTORY.map( - (tableName) => `${CORE_SCHEMA_NAME}.${tableName}`, -); +export const expectedCoreTableCatalog = CORE_TABLE_INVENTORY.map((tableName) => `${CORE_SCHEMA_NAME}.${tableName}`); export const compareApplicationCatalog = (entries: readonly CatalogEntry[]): CatalogDifference => { const actualTables = new Set( entries - .filter( - (entry): entry is Extract => - entry.kind === 'table', - ) + .filter((entry): entry is Extract => entry.kind === 'table') .map((entry) => `${entry.schemaName}.${entry.tableName}`), ); const expectedTables = new Set(expectedCoreTableCatalog); diff --git a/app/packages/core-runtime/src/db/client.ts b/app/packages/core-runtime/src/db/client.ts index 7a93119c6..df9cbc38b 100644 --- a/app/packages/core-runtime/src/db/client.ts +++ b/app/packages/core-runtime/src/db/client.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Redacted } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; import type { PoolConfig } from 'pg'; import { Pool } from 'pg'; + import type { DatabaseConfigValue } from './config.ts'; import { DatabaseConfig } from './config.ts'; import { DatabaseConnectionError } from './connection-error.ts'; @@ -40,8 +41,7 @@ export const acquirePoolResource = ( ): Effect.Effect => Effect.acquireRelease( Effect.try({ - catch: (cause) => - connectionFailure('Unable to initialize the PostgreSQL connection pool', cause), + catch: (cause) => connectionFailure('Unable to initialize the PostgreSQL connection pool', cause), try: acquire, }), // pg overloads end(callback); invoke it with no arguments so the AbortSignal is never a callback. @@ -65,11 +65,11 @@ export const makeCoreDatabase = Effect.fn('Client.makeCoreDatabase')(function* m ); const pool = yield* acquirePoolResource(() => poolFactory(poolConfiguration)); const reactivity = yield* Reactivity.make; - const client = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( + const client = yield* PgClient.fromPool({ + acquire: Effect.succeed(pool), + }).pipe( Effect.provideService(Reactivity.Reactivity, reactivity), - Effect.mapError((cause) => - connectionFailure('Unable to initialize the native PostgreSQL client', cause), - ), + Effect.mapError((cause) => connectionFailure('Unable to initialize the native PostgreSQL client', cause)), ); return { executor: yield* makeWithDefaults({ relations: coreRelations }).pipe( diff --git a/app/packages/core-runtime/src/db/config-error.ts b/app/packages/core-runtime/src/db/config-error.ts index 3a466e964..11286dd11 100644 --- a/app/packages/core-runtime/src/db/config-error.ts +++ b/app/packages/core-runtime/src/db/config-error.ts @@ -1,8 +1,5 @@ import { Schema } from 'effect'; -export class DatabaseConfigError extends Schema.TaggedError()( - 'DatabaseConfigError', - { - reason: Schema.String, - }, -) {} +export class DatabaseConfigError extends Schema.TaggedError()('DatabaseConfigError', { + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/db/config.ts b/app/packages/core-runtime/src/db/config.ts index 9a487cdad..24b8098ca 100644 --- a/app/packages/core-runtime/src/db/config.ts +++ b/app/packages/core-runtime/src/db/config.ts @@ -1,4 +1,5 @@ import { Config, ConfigProvider, Context, Effect, Layer, Redacted, Schema } from 'effect'; + import { loadDotEnvProvider } from '../environment/dotenv-provider.ts'; import { APP_ENV_PATH } from '../environment/workspace-environment.ts'; import { DatabaseConfigError } from './config-error.ts'; @@ -77,15 +78,12 @@ const hasValidDatabaseFields = ({ const readDatabaseUrl = Effect.fn('Config.readDatabaseUrl')(function* readDatabaseUrlEffect( options: ReadDatabaseUrlOptions, ) { - const connectionString = yield* Config.schema( - Schema.Redacted(requiredDatabaseUrlSchema), - options.configKey, - ) + const connectionString = yield* Config.schema(Schema.Redacted(requiredDatabaseUrlSchema), options.configKey) .parse(options.provider) .pipe(Effect.mapError((error) => configFailure(options.requiredReason, error))); - const parsed = yield* Schema.decodeEffect(Schema.URLFromString)( - Redacted.value(connectionString), - ).pipe(Effect.mapError((error) => configFailure(INVALID_DATABASE_URL_REASON, error))); + const parsed = yield* Schema.decodeEffect(Schema.URLFromString)(Redacted.value(connectionString)).pipe( + Effect.mapError((error) => configFailure(INVALID_DATABASE_URL_REASON, error)), + ); if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { return yield* configFailure(INVALID_DATABASE_URL_REASON); @@ -101,8 +99,7 @@ const readDatabaseUrl = Effect.fn('Config.readDatabaseUrl')(function* readDataba const host = parsed.hostname; const port = parsed.port.length > 0 ? Math.trunc(Number(parsed.port)) : 5432; const queryUser = parsed.searchParams.getAll('user').at(-1); - const user = - queryUser === undefined || queryUser.length === 0 ? decoded.authorityUser : queryUser; + const user = queryUser === undefined || queryUser.length === 0 ? decoded.authorityUser : queryUser; if (!hasValidDatabaseFields({ database: decoded.database, host, port, user })) { return yield* configFailure(INVALID_DATABASE_URL_REASON); @@ -143,9 +140,7 @@ const parseDatabaseConnectionPairWith = Effect.fn('Config.readDatabaseConnection admin.user === runtime.user || runtime.user === 'postgres' ) { - return yield* configFailure( - 'Administrative and runtime PostgreSQL identities must be distinct', - ); + return yield* configFailure('Administrative and runtime PostgreSQL identities must be distinct'); } return Object.freeze({ admin, runtime }); @@ -160,9 +155,7 @@ export const parseDatabaseConfig = ( export const parseDatabaseConnectionPair = ( environment: DatabaseEnvironment, ): Effect.Effect => - parseDatabaseConnectionPairWith( - ConfigProvider.fromUnknown(environment, { preserveEmptyStrings: true }), - ); + parseDatabaseConnectionPairWith(ConfigProvider.fromUnknown(environment, { preserveEmptyStrings: true })); const loadWithProvider = ( parse: (provider: ConfigProvider.ConfigProvider) => Effect.Effect, @@ -178,16 +171,13 @@ const loadWithProvider = ( return loadDotEnvProvider(envPath, configFailure).pipe( Effect.withSpan('Config.loadDotEnvProvider'), - Effect.flatMap((fileProvider) => - parse(ConfigProvider.orElse(environmentProvider, fileProvider)), - ), + Effect.flatMap((fileProvider) => parse(ConfigProvider.orElse(environmentProvider, fileProvider))), ); }; export const loadDatabaseConfig = ( options: LoadDatabaseConfigOptions = {}, -): Effect.Effect => - loadWithProvider(parseDatabaseConfigWith, options); +): Effect.Effect => loadWithProvider(parseDatabaseConfigWith, options); export const loadDatabaseConnectionPair = ( options: LoadDatabaseConfigOptions = {}, diff --git a/app/packages/core-runtime/src/db/connection-error.ts b/app/packages/core-runtime/src/db/connection-error.ts index 325170184..881ee98be 100644 --- a/app/packages/core-runtime/src/db/connection-error.ts +++ b/app/packages/core-runtime/src/db/connection-error.ts @@ -1,8 +1,5 @@ import { Schema } from 'effect'; -export class DatabaseConnectionError extends Schema.TaggedError()( - 'DatabaseConnectionError', - { - reason: Schema.String, - }, -) {} +export class DatabaseConnectionError extends Schema.TaggedError()('DatabaseConnectionError', { + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/db/pool-configuration.ts b/app/packages/core-runtime/src/db/pool-configuration.ts index e9ee3742c..b2e749bdd 100644 --- a/app/packages/core-runtime/src/db/pool-configuration.ts +++ b/app/packages/core-runtime/src/db/pool-configuration.ts @@ -1,5 +1,6 @@ import { Effect, Redacted } from 'effect'; import type { PoolConfig } from 'pg'; + import { DatabaseConnectionError } from './connection-error.ts'; export interface DatabasePoolDeadlines { @@ -62,8 +63,7 @@ export const configureDatabasePool = Effect.fn('PoolConfiguration.configureDatab ].some((key) => url.searchParams.has(key)) ) { return yield* new DatabaseConnectionError({ - reason: - 'Database URL deadline parameters and startup options are unsupported; use poolDeadlines', + reason: 'Database URL deadline parameters and startup options are unsupported; use poolDeadlines', }); } diff --git a/app/packages/core-runtime/src/db/schema.ts b/app/packages/core-runtime/src/db/schema.ts index 919554755..95e09fa85 100644 --- a/app/packages/core-runtime/src/db/schema.ts +++ b/app/packages/core-runtime/src/db/schema.ts @@ -15,7 +15,6 @@ import { uniqueIndex, uuid, } from 'drizzle-orm/pg-core'; - import type { AnyPgColumn } from 'drizzle-orm/pg-core'; export const CORE_SCHEMA_NAME = 'core'; @@ -55,12 +54,7 @@ export const ACTION_INVOCATION_STATUSES = [ export type ActionInvocationStatus = (typeof ACTION_INVOCATION_STATUSES)[number]; -export const ACTION_AUTH_METHODS = [ - 'session', - 'api_key', - 'system', - 'support_impersonation', -] as const; +export const ACTION_AUTH_METHODS = ['session', 'api_key', 'system', 'support_impersonation'] as const; export type ActionAuthMethod = (typeof ACTION_AUTH_METHODS)[number]; @@ -82,8 +76,7 @@ export const domainEventTenantSequence = coreSchema.sequence('domain_event_tenan const createdAt = () => timestamp('created_at', { withTimezone: true }).defaultNow().notNull(); const updatedAt = () => timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(); const occurredAt = () => timestamp('occurred_at', { withTimezone: true }).defaultNow().notNull(); -const enableCoreGovernedRls =
(table: { readonly enableRLS: () => Table }): Table => - table.enableRLS(); +const enableCoreGovernedRls =
(table: { readonly enableRLS: () => Table }): Table => table.enableRLS(); export const tenants = coreSchema.table( 'tenants', @@ -128,10 +121,7 @@ export const legalEntities = coreSchema.table( table.registrationNumber, ), index('core_legal_entities_tenant_idx').on(table.tenantId), - check( - 'core_legal_entities_status_ck', - sql`${table.status} in ('active', 'suspended', 'archived')`, - ), + check('core_legal_entities_status_ck', sql`${table.status} in ('active', 'suspended', 'archived')`), ], ); @@ -149,10 +139,7 @@ export const principals = coreSchema.table( (table) => [ uniqueIndex('core_principals_tenant_id_uk').on(table.tenantId, table.principalId), index('core_principals_tenant_kind_idx').on(table.tenantId, table.kind), - check( - 'core_principals_kind_ck', - sql`${table.kind} in ('human', 'service', 'integration', 'agent', 'system')`, - ), + check('core_principals_kind_ck', sql`${table.kind} in ('human', 'service', 'integration', 'agent', 'system')`), check('core_principals_status_ck', sql`${table.status} in ('active', 'disabled', 'archived')`), ], ); @@ -194,10 +181,7 @@ export const principalAuthBindings = coreSchema.table( }).onDelete('restrict'), check('core_auth_bindings_provider_ck', sql`${table.provider} in ('better_auth')`), check('core_auth_bindings_subject_type_ck', sql`${table.subjectType} in ('user', 'api_key')`), - check( - 'core_auth_bindings_status_ck', - sql`${table.status} in ('active', 'revoked', 'disabled')`, - ), + check('core_auth_bindings_status_ck', sql`${table.status} in ('active', 'revoked', 'disabled')`), check( 'core_auth_bindings_lifecycle_ck', sql`(${table.status} = 'revoked' and ${table.revokedAt} is not null) or (${table.status} in ('active', 'disabled') and ${table.revokedAt} is null)`, @@ -284,10 +268,7 @@ export const actionInvocations = coreSchema.table( completedAt: timestamp('completed_at', { withTimezone: true }), }, (table) => [ - uniqueIndex('core_action_invocations_tenant_id_uk').on( - table.tenantId, - table.actionInvocationId, - ), + uniqueIndex('core_action_invocations_tenant_id_uk').on(table.tenantId, table.actionInvocationId), uniqueIndex('core_action_invocations_idempotency_uk') .on(table.tenantId, table.actionKey, table.principalId, table.idempotencyKey) .where(sql`${table.idempotencyKey} is not null`), @@ -332,11 +313,7 @@ export const tenantModuleStateChanges = coreSchema.table( occurredAt: occurredAt(), }, (table) => [ - index('core_module_state_changes_tenant_module_idx').on( - table.tenantId, - table.moduleKey, - table.occurredAt, - ), + index('core_module_state_changes_tenant_module_idx').on(table.tenantId, table.moduleKey, table.occurredAt), foreignKey({ columns: [table.tenantId, table.changedByPrincipalId], foreignColumns: [principals.tenantId, principals.principalId], @@ -347,10 +324,7 @@ export const tenantModuleStateChanges = coreSchema.table( foreignColumns: [actionInvocations.tenantId, actionInvocations.actionInvocationId], name: 'core_module_state_changes_tenant_invocation_fk', }).onDelete('restrict'), - check( - 'core_module_state_changes_source_ck', - sql`${table.changeSource} in ('user', 'support', 'system')`, - ), + check('core_module_state_changes_source_ck', sql`${table.changeSource} in ('user', 'support', 'system')`), check( 'core_module_state_changes_new_state_ck', sql`${table.newState} in ('inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived')`, @@ -396,18 +370,12 @@ export const auditEvents = coreSchema.table( name: 'core_audit_events_tenant_invocation_fk', }).onDelete('restrict'), ...authContextForeignKeys('core_audit_events', table), - check( - 'core_audit_events_outcome_ck', - sql`${table.outcome} in ('allowed', 'denied', 'succeeded', 'failed')`, - ), + check('core_audit_events_outcome_ck', sql`${table.outcome} in ('allowed', 'denied', 'succeeded', 'failed')`), check( 'core_audit_events_stage_ck', sql`${table.outcomeStage} in ('system', 'authn', 'authz', 'policy', 'validation', 'execution')`, ), - check( - 'core_audit_events_profile_ck', - sql`${table.auditProfile} in ('standard', 'sensitive', 'minimal')`, - ), + check('core_audit_events_profile_ck', sql`${table.auditProfile} in ('standard', 'sensitive', 'minimal')`), ], ); @@ -455,10 +423,7 @@ export const dataAccessEvents = coreSchema.table( name: 'core_data_access_events_tenant_invocation_fk', }).onDelete('restrict'), ...authContextForeignKeys('core_data_access_events', table), - check( - 'core_data_access_events_outcome_ck', - sql`${table.outcome} in ('allowed', 'denied', 'failed')`, - ), + check('core_data_access_events_outcome_ck', sql`${table.outcome} in ('allowed', 'denied', 'failed')`), check( 'core_data_access_events_stage_ck', sql`${table.outcomeStage} in ('authn', 'context', 'module_state', 'authz', 'policy', 'execution', 'evidence')`, @@ -559,7 +524,9 @@ export const outboxDeliveries = coreSchema.table( outboxDeliveryId: uuid('outbox_delivery_id').defaultRandom().primaryKey(), outboxMessageId: uuid('outbox_message_id') .notNull() - .references(() => outboxMessages.outboxMessageId, { onDelete: 'cascade' }), + .references(() => outboxMessages.outboxMessageId, { + onDelete: 'cascade', + }), workerKey: text('worker_key').notNull(), consumerModuleKey: text('consumer_module_key').notNull(), status: text('status').default('pending').notNull(), @@ -572,19 +539,13 @@ export const outboxDeliveries = coreSchema.table( updatedAt: updatedAt(), }, (table) => [ - uniqueIndex('core_outbox_deliveries_message_worker_uk').on( - table.outboxMessageId, - table.workerKey, - ), + uniqueIndex('core_outbox_deliveries_message_worker_uk').on(table.outboxMessageId, table.workerKey), index('core_outbox_deliveries_pending_idx') .on(table.availableAt) .where(sql`${table.status} = 'pending'`), index('core_outbox_deliveries_message_idx').on(table.outboxMessageId), index('core_outbox_deliveries_worker_status_idx').on(table.workerKey, table.status), - check( - 'core_outbox_deliveries_status_ck', - sql`${table.status} in ('pending', 'processing', 'done', 'dead')`, - ), + check('core_outbox_deliveries_status_ck', sql`${table.status} in ('pending', 'processing', 'done', 'dead')`), check('core_outbox_deliveries_attempts_count_ck', sql`${table.attemptsCount} >= 0`), ], ); @@ -595,14 +556,14 @@ export const outboxAttempts = coreSchema.table( outboxAttemptId: uuid('outbox_attempt_id').defaultRandom().primaryKey(), outboxDeliveryId: uuid('outbox_delivery_id') .notNull() - .references(() => outboxDeliveries.outboxDeliveryId, { onDelete: 'cascade' }), + .references(() => outboxDeliveries.outboxDeliveryId, { + onDelete: 'cascade', + }), startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(), finishedAt: timestamp('finished_at', { withTimezone: true }), errorMessage: text('error_message'), }, - (table) => [ - index('core_outbox_attempts_delivery_started_idx').on(table.outboxDeliveryId, table.startedAt), - ], + (table) => [index('core_outbox_attempts_delivery_started_idx').on(table.outboxDeliveryId, table.startedAt)], ); export const mediaAssets = coreSchema.table( @@ -696,10 +657,7 @@ export const mediaLinks = coreSchema.table( foreignColumns: [actionInvocations.tenantId, actionInvocations.actionInvocationId], name: 'core_media_links_tenant_invocation_fk', }).onDelete('restrict'), - check( - 'core_media_links_source_ck', - sql`${table.linkSource} in ('user', 'integration', 'import', 'system')`, - ), + check('core_media_links_source_ck', sql`${table.linkSource} in ('user', 'integration', 'import', 'system')`), ], ); @@ -725,9 +683,13 @@ export const evidenceReferences = coreSchema.table( storageLockScope: text('storage_lock_scope').notNull(), storageLockMode: text('storage_lock_mode').notNull(), storageLegalHold: boolean('storage_legal_hold').default(false).notNull(), - storageRetainUntil: timestamp('storage_retain_until', { withTimezone: true }), + storageRetainUntil: timestamp('storage_retain_until', { + withTimezone: true, + }), storageLockStatus: text('storage_lock_status').notNull(), - storageLockVerifiedAt: timestamp('storage_lock_verified_at', { withTimezone: true }), + storageLockVerifiedAt: timestamp('storage_lock_verified_at', { + withTimezone: true, + }), storageLockEvidenceJson: jsonb('storage_lock_evidence_json') .notNull() .default(sql`'{}'::jsonb`), @@ -926,10 +888,7 @@ export const searchProjectionRebuilds = enableCoreGovernedRls( columns: [table.tenantId, table.sourceModuleKey, table.sourceResourceType], }), check('core_search_projection_rebuilds_version_ck', sql`${table.rebuildVersion} > 0`), - check( - 'core_search_projection_rebuilds_fingerprint_ck', - sql`${table.fingerprint} ~ '^[a-f0-9]{64}$'`, - ), + check('core_search_projection_rebuilds_fingerprint_ck', sql`${table.fingerprint} ~ '^[a-f0-9]{64}$'`), pgPolicy('core_search_projection_rebuilds_tenant_select', { for: 'select', to: 'ontos_runtime', diff --git a/app/packages/core-runtime/src/db/scoped-transaction.ts b/app/packages/core-runtime/src/db/scoped-transaction.ts index 41b046ac7..89b211b1a 100644 --- a/app/packages/core-runtime/src/db/scoped-transaction.ts +++ b/app/packages/core-runtime/src/db/scoped-transaction.ts @@ -1,7 +1,9 @@ import { sql } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; import type { AnyPgColumn } from 'drizzle-orm/pg-core'; import { pgPolicy } from 'drizzle-orm/pg-core'; import { Context, Effect, Option } from 'effect'; + import type { OperationalScope } from '../operations/context.ts'; import { OperationContextUnavailable } from '../operations/errors.ts'; import type { CoreTransaction } from './types.ts'; @@ -42,7 +44,10 @@ const operationContextUnavailable = (cause?: unknown) => { reason: 'The database operation scope could not be installed', }); if (cause !== undefined) { - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); } return failure; }; @@ -76,27 +81,27 @@ const operationalScopeTransactionFromCoreTransaction = ( ), }); -export const installOperationalScopeFromTransactionService = Effect.fn( - 'installOperationalScopeFromTransactionService', -)(function* installOperationalScopeFromTransactionServiceEffect(scope: OperationalScope) { - const transaction = yield* OperationalScopeTransaction; - yield* transaction.install(scope); - const setting = yield* transaction.verify; - if ( - Option.isNone(setting) || - setting.value.tenant_id !== scope.tenantId || - setting.value.legal_entity_id !== (scope.legalEntityId ?? '') - ) { - return yield* operationContextUnavailable(); - } - return Object.freeze({ - delete: transaction.delete.bind(transaction), - insert: transaction.insert.bind(transaction), - [scopedTransaction]: true as const, - select: transaction.select.bind(transaction), - update: transaction.update.bind(transaction), - }); -}); +export const installOperationalScopeFromTransactionService = Effect.fn('installOperationalScopeFromTransactionService')( + function* installOperationalScopeFromTransactionServiceEffect(scope: OperationalScope) { + const transaction = yield* OperationalScopeTransaction; + yield* transaction.install(scope); + const setting = yield* transaction.verify; + if ( + Option.isNone(setting) || + setting.value.tenant_id !== scope.tenantId || + setting.value.legal_entity_id !== (scope.legalEntityId ?? '') + ) { + return yield* operationContextUnavailable(); + } + return Object.freeze({ + delete: transaction.delete.bind(transaction), + insert: transaction.insert.bind(transaction), + [scopedTransaction]: true as const, + select: transaction.select.bind(transaction), + update: transaction.update.bind(transaction), + }); + }, +); export const installOperationalScope = ( transaction: CoreTransaction, @@ -104,27 +109,38 @@ export const installOperationalScope = ( ): Effect.Effect => installOperationalScopeFromTransactionService(scope).pipe( Effect.updateContext((context: Context.Context) => - Context.add( - context, - OperationalScopeTransaction, - operationalScopeTransactionFromCoreTransaction(transaction), - ), + Context.add(context, OperationalScopeTransaction, operationalScopeTransactionFromCoreTransaction(transaction)), ), ); -export const tenantRlsPolicies = (prefix: string, tenantColumn: AnyPgColumn) => { - const predicate = sql`${tenantColumn} = nullif(current_setting('ontos.tenant_id', true), '')::uuid`; - return [ - pgPolicy(`${prefix}_select`, { for: 'select', to: 'ontos_runtime', using: predicate }), - pgPolicy(`${prefix}_insert`, { for: 'insert', to: 'ontos_runtime', withCheck: predicate }), +const operationalRlsPolicies = (prefix: string, predicate: SQL) => + [ + pgPolicy(`${prefix}_select`, { + for: 'select', + to: 'ontos_runtime', + using: predicate, + }), + pgPolicy(`${prefix}_insert`, { + for: 'insert', + to: 'ontos_runtime', + withCheck: predicate, + }), pgPolicy(`${prefix}_update`, { for: 'update', to: 'ontos_runtime', using: predicate, withCheck: predicate, }), - pgPolicy(`${prefix}_delete`, { for: 'delete', to: 'ontos_runtime', using: predicate }), + pgPolicy(`${prefix}_delete`, { + for: 'delete', + to: 'ontos_runtime', + using: predicate, + }), ] as const; + +export const tenantRlsPolicies = (prefix: string, tenantColumn: AnyPgColumn) => { + const predicate = sql`${tenantColumn} = nullif(current_setting('ontos.tenant_id', true), '')::uuid`; + return operationalRlsPolicies(prefix, predicate); }; export const tenantLegalEntityRlsPolicies = ( @@ -133,15 +149,5 @@ export const tenantLegalEntityRlsPolicies = ( legalEntityColumn: AnyPgColumn, ) => { const predicate = sql`${tenantColumn} = nullif(current_setting('ontos.tenant_id', true), '')::uuid and ${legalEntityColumn} = nullif(current_setting('ontos.legal_entity_id', true), '')::uuid`; - return [ - pgPolicy(`${prefix}_select`, { for: 'select', to: 'ontos_runtime', using: predicate }), - pgPolicy(`${prefix}_insert`, { for: 'insert', to: 'ontos_runtime', withCheck: predicate }), - pgPolicy(`${prefix}_update`, { - for: 'update', - to: 'ontos_runtime', - using: predicate, - withCheck: predicate, - }), - pgPolicy(`${prefix}_delete`, { for: 'delete', to: 'ontos_runtime', using: predicate }), - ] as const; + return operationalRlsPolicies(prefix, predicate); }; diff --git a/app/packages/core-runtime/src/db/types.ts b/app/packages/core-runtime/src/db/types.ts index a77fbc185..99401d28b 100644 --- a/app/packages/core-runtime/src/db/types.ts +++ b/app/packages/core-runtime/src/db/types.ts @@ -1,4 +1,5 @@ import type { EffectPgDatabase } from 'drizzle-orm/effect-postgres'; + import type { coreRelations } from './schema.ts'; export type CoreDatabaseExecutor = EffectPgDatabase; diff --git a/app/packages/core-runtime/src/environment/dotenv-provider.ts b/app/packages/core-runtime/src/environment/dotenv-provider.ts index 462faeefa..d0c0b06f5 100644 --- a/app/packages/core-runtime/src/environment/dotenv-provider.ts +++ b/app/packages/core-runtime/src/environment/dotenv-provider.ts @@ -2,9 +2,10 @@ import { ConfigProvider, Effect, Match, Predicate } from 'effect'; const nodeFileSystem = process.getBuiltinModule('node:fs'); -export const loadDotEnvProvider = Effect.fn('Config.loadDotEnvProvider')(function* loadProvider< - Failure, ->(envPath: string, configFailure: (reason: string, cause: unknown) => Failure) { +export const loadDotEnvProvider = Effect.fn('Config.loadDotEnvProvider')(function* loadProvider( + envPath: string, + configFailure: (reason: string, cause: unknown) => Failure, +) { const result = yield* Effect.sync(() => { try { return { @@ -29,7 +30,11 @@ export const loadDotEnvProvider = Effect.fn('Config.loadDotEnvProvider')(functio Match.discriminatorsExhaustive('status')({ failed: ({ error }) => Effect.fail(error), loaded: ({ contents }) => - Effect.succeed(ConfigProvider.fromDotEnvContents(contents, { preserveEmptyStrings: true })), + Effect.succeed( + ConfigProvider.fromDotEnvContents(contents, { + preserveEmptyStrings: true, + }), + ), missing: () => Effect.succeed(ConfigProvider.fromUnknown({})), }), ); diff --git a/app/packages/core-runtime/src/environment/drizzle-config.ts b/app/packages/core-runtime/src/environment/drizzle-config.ts index 3987b6c1d..1514fd213 100644 --- a/app/packages/core-runtime/src/environment/drizzle-config.ts +++ b/app/packages/core-runtime/src/environment/drizzle-config.ts @@ -1,21 +1,20 @@ import { defineConfig } from 'drizzle-kit'; import { Redacted, Result, Schema } from 'effect'; + import { APP_ENV_PATH } from './workspace-environment.ts'; const nodeFileSystem = process.getBuiltinModule('node:fs'); const nodeProcess = process.getBuiltinModule('node:process'); const nodeUtilities = process.getBuiltinModule('node:util'); const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) - ? Result.getOrThrow( - Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), - ) + ? Result.getOrThrow(Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8')))) : {}; const configValues = { ...fileConfig, ...nodeProcess.env }; const databaseUrl = Redacted.value( Result.getOrThrow( - Schema.decodeUnknownResult( - Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), - )(configValues['DATABASE_ADMIN_URL']), + Schema.decodeUnknownResult(Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))))( + configValues['DATABASE_ADMIN_URL'], + ), ), ); diff --git a/app/packages/core-runtime/src/environment/workspace-environment-bootstrap.cjs b/app/packages/core-runtime/src/environment/workspace-environment-bootstrap.cjs index 41fe429d2..6b7affe79 100644 --- a/app/packages/core-runtime/src/environment/workspace-environment-bootstrap.cjs +++ b/app/packages/core-runtime/src/environment/workspace-environment-bootstrap.cjs @@ -38,13 +38,9 @@ const resolveAppWorkspaceRootSync = (startDirectory) => { * @returns {{ APP_ENV_PATH: string, APP_WORKSPACE_ROOT: string }} The resolved workspace paths. */ const resolveWorkspaceEnvironmentSync = (candidates) => { - const usableCandidates = candidates.filter( - (candidate) => candidate !== undefined && candidate.length > 0, - ); + const usableCandidates = candidates.filter((candidate) => candidate !== undefined && candidate.length > 0); const APP_WORKSPACE_ROOT = - usableCandidates - .map(resolveAppWorkspaceRootSync) - .find((candidate) => candidate !== undefined) ?? candidates[1]; + usableCandidates.map(resolveAppWorkspaceRootSync).find((candidate) => candidate !== undefined) ?? candidates[1]; return { APP_ENV_PATH: path.join(APP_WORKSPACE_ROOT, '.env'), APP_WORKSPACE_ROOT, diff --git a/app/packages/core-runtime/src/environment/workspace-environment.ts b/app/packages/core-runtime/src/environment/workspace-environment.ts index a9514a508..bb94487c4 100644 --- a/app/packages/core-runtime/src/environment/workspace-environment.ts +++ b/app/packages/core-runtime/src/environment/workspace-environment.ts @@ -1,23 +1,22 @@ import { Effect, FileSystem, Option, Path } from 'effect'; + import bootstrapEnvironment from './workspace-environment-bootstrap.cjs'; -const isAppWorkspace = Effect.fn('WorkspaceEnvironment.isAppWorkspace')( - function* isAppWorkspaceEffect(candidate: string) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return ( - (yield* fileSystem.exists(path.join(candidate, 'pnpm-workspace.yaml'))) && - (yield* fileSystem.exists(path.join(candidate, 'packages/core-runtime/package.json'))) - ); - }, -); +const isAppWorkspace = Effect.fn('WorkspaceEnvironment.isAppWorkspace')(function* isAppWorkspaceEffect( + candidate: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return ( + (yield* fileSystem.exists(path.join(candidate, 'pnpm-workspace.yaml'))) && + (yield* fileSystem.exists(path.join(candidate, 'packages/core-runtime/package.json'))) + ); +}); const workspaceCandidates = (path: Path.Path, candidate: string): readonly string[] => { const nestedApp = path.join(candidate, 'app'); const parent = path.dirname(candidate); - return parent === candidate - ? [candidate, nestedApp] - : [candidate, nestedApp, ...workspaceCandidates(path, parent)]; + return parent === candidate ? [candidate, nestedApp] : [candidate, nestedApp, ...workspaceCandidates(path, parent)]; }; /** @@ -26,13 +25,13 @@ const workspaceCandidates = (path: Path.Path, candidate: string): readonly strin * Modern.js bundles server modules into a cache directory, so module-relative * paths do not identify the source workspace at runtime. */ -export const resolveAppWorkspaceRootEffect = Effect.fn( - 'WorkspaceEnvironment.resolveAppWorkspaceRootEffect', -)(function* resolveAppWorkspaceRootEffect(startDirectory: string) { - const path = yield* Path.Path; - const candidates = workspaceCandidates(path, path.resolve(startDirectory)); - return Option.getOrUndefined(yield* Effect.findFirst(candidates, isAppWorkspace)); -}); +export const resolveAppWorkspaceRootEffect = Effect.fn('WorkspaceEnvironment.resolveAppWorkspaceRootEffect')( + function* resolveAppWorkspaceRootEffect(startDirectory: string) { + const path = yield* Path.Path; + const candidates = workspaceCandidates(path, path.resolve(startDirectory)); + return Option.getOrUndefined(yield* Effect.findFirst(candidates, isAppWorkspace)); + }, +); export const resolveAppWorkspaceRoot: (startDirectory: string) => string | undefined = bootstrapEnvironment.resolveAppWorkspaceRootSync; diff --git a/app/packages/core-runtime/src/http/governed-read.ts b/app/packages/core-runtime/src/http/governed-read.ts index 2506c2b3f..d3b773a9c 100644 --- a/app/packages/core-runtime/src/http/governed-read.ts +++ b/app/packages/core-runtime/src/http/governed-read.ts @@ -2,6 +2,7 @@ import { Cause, Effect, Match, Redacted } from 'effect'; import type { Schema } from 'effect'; import { HttpEffect, HttpServerResponse } from 'effect/unstable/http'; import type { HttpServerRequest } from 'effect/unstable/http'; + import type { TrustedPrincipalContext } from '../actions/context.ts'; import type { ReadRegistration } from '../reads/definition.ts'; import type { ReadCoreError } from '../reads/errors.ts'; @@ -76,15 +77,7 @@ export function classifyReadCoreError< PolicyIneligible, Unavailable >, -): - | Authentication - | Forbidden - | Internal - | Invalid - | NotFound - | PolicyConflict - | PolicyIneligible - | Unavailable; +): Authentication | Forbidden | Internal | Invalid | NotFound | PolicyConflict | PolicyIneligible | Unavailable; export function classifyReadCoreError( error: ReadCoreError, problems: GovernedReadHttpProblemSet< @@ -129,9 +122,7 @@ const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) ); const failProblem = >(problem: Problem) => - (problem.status === 401 ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); + (problem.status === 401 ? bearerChallenge : Effect.void).pipe(Effect.andThen(Effect.fail(problem))); interface GovernedReadRequest { readonly payload: Payload; @@ -170,14 +161,7 @@ export const makeGovernedReadHttpHandler = < PolicyIneligible, Unavailable >; - readonly registration: ReadRegistration< - InputSchema, - ResultSchema, - Owner, - Services, - HandlerError, - ReadRequirements - >; + readonly registration: ReadRegistration; }) => Effect.fn('GovernedReadHttp.handle')(function* handleGovernedRead({ payload, @@ -189,13 +173,10 @@ export const makeGovernedReadHttpHandler = < if (correlationId === undefined || correlationId.trim().length === 0) { return yield* Effect.fail(options.problems.invalid()); } - const principal = yield* options.authenticatePrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: options.problems.authentication, - unavailable: options.problems.unavailable, - }, - ); + const principal = yield* options.authenticatePrincipal(Redacted.make(request.headers['authorization']), { + authentication: options.problems.authentication, + unavailable: options.problems.unavailable, + }); const runtime = yield* ReadRuntime; return yield* runtime .runRead({ @@ -210,10 +191,7 @@ export const makeGovernedReadHttpHandler = < return yield* execute.pipe( Effect.catchCauseIf(Cause.hasDies, () => Effect.annotateLogs(Effect.logError('Unexpected governed-read HTTP defect'), { - correlationId: - correlationId === undefined || correlationId.trim().length === 0 - ? 'missing' - : correlationId, + correlationId: correlationId === undefined || correlationId.trim().length === 0 ? 'missing' : correlationId, }).pipe(Effect.andThen(Effect.fail(options.problems.internal()))), ), ); diff --git a/app/packages/core-runtime/src/http/http-instrumentation-seam.ts b/app/packages/core-runtime/src/http/http-instrumentation-seam.ts index a785b6c92..d2e6e337e 100644 --- a/app/packages/core-runtime/src/http/http-instrumentation-seam.ts +++ b/app/packages/core-runtime/src/http/http-instrumentation-seam.ts @@ -1,11 +1,12 @@ import { Cause, Effect, Exit, Schema } from 'effect'; import type { Redacted } from 'effect'; -import { ActionRuntime } from '../actions/runtime.ts'; -import type { ActionRegistration } from '../actions/definition.ts'; + import type { ActionTransportMetadata } from '../actions/context.ts'; +import type { ActionRegistration } from '../actions/definition.ts'; import type { ActionCoreError } from '../actions/errors.ts'; import type { DomainEventContractMap } from '../actions/events.ts'; import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; +import { ActionRuntime } from '../actions/runtime.ts'; export interface ActionHttpEndpointHeaders { readonly idempotencyKey: string | undefined; @@ -153,9 +154,9 @@ export const runGovernedActionHttp = < } const principal = yield* input.principal.authenticate(input.requestHeaders.authorization); - const encodedPayload = yield* Schema.encodeEffect(input.registration.descriptor.payloadSchema)( - input.payload, - ).pipe(Effect.orDie); + const encodedPayload = yield* Schema.encodeEffect(input.registration.descriptor.payloadSchema)(input.payload).pipe( + Effect.orDie, + ); const runtime = yield* ActionRuntime; let transport: ActionTransportMetadata; if (input.endpointHeaders.idempotencyKey === undefined) { @@ -166,7 +167,10 @@ export const runGovernedActionHttp = < } else { transport = input.endpointHeaders.traceId === undefined - ? { correlationId, idempotencyKey: input.endpointHeaders.idempotencyKey } + ? { + correlationId, + idempotencyKey: input.endpointHeaders.idempotencyKey, + } : { correlationId, idempotencyKey: input.endpointHeaders.idempotencyKey, @@ -199,7 +203,9 @@ export const bindGovernedActionHttp = < PayloadSchema extends Schema.ConstraintDecoder & Schema.ConstraintEncoder, ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder<{ readonly _tag: string }>, + DomainErrorSchema extends Schema.ConstraintDecoder<{ + readonly _tag: string; + }>, DomainEvents extends DomainEventContractMap, Owner extends string, Services, diff --git a/app/packages/core-runtime/src/http/principal-authentication.ts b/app/packages/core-runtime/src/http/principal-authentication.ts index 4bf341397..67983fabd 100644 --- a/app/packages/core-runtime/src/http/principal-authentication.ts +++ b/app/packages/core-runtime/src/http/principal-authentication.ts @@ -1,9 +1,10 @@ -import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; import { Effect, Schema } from 'effect'; import type { Redacted } from 'effect'; import { HttpEffect, HttpServerResponse } from 'effect/unstable/http'; import type { HttpServerRequest } from 'effect/unstable/http'; +import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; + const verificationErrorFields = { reason: Schema.String }; export const OperationPrincipalVerificationErrorSchema = Schema.Union([ Schema.TaggedStruct('ActionPrincipalConfigurationError', verificationErrorFields), @@ -13,8 +14,7 @@ export const OperationPrincipalVerificationErrorSchema = Schema.Union([ Schema.TaggedStruct('ActionPrincipalScopeError', verificationErrorFields), Schema.TaggedStruct('ActionPrincipalUnavailableError', verificationErrorFields), ]); -export type OperationPrincipalVerificationError = - typeof OperationPrincipalVerificationErrorSchema.Type; +export type OperationPrincipalVerificationError = typeof OperationPrincipalVerificationErrorSchema.Type; export interface PrincipalAuthenticationProblems { readonly authentication: () => AuthenticationProblem; @@ -43,8 +43,7 @@ export const makeMicroverticalHttpPrincipalAuthentication = AuthenticationProblem | UnavailableProblem, HttpServerRequest.HttpServerRequest | Requirements > => { - const authentication = () => - bearerChallenge.pipe(Effect.andThen(Effect.fail(problems.authentication()))); + const authentication = () => bearerChallenge.pipe(Effect.andThen(Effect.fail(problems.authentication()))); const unavailable = () => Effect.fail(problems.unavailable()); return verify(authorization).pipe( Effect.catchTags({ diff --git a/app/packages/core-runtime/src/index.ts b/app/packages/core-runtime/src/index.ts index bd4e2bb7b..8d9f8a635 100644 --- a/app/packages/core-runtime/src/index.ts +++ b/app/packages/core-runtime/src/index.ts @@ -82,10 +82,7 @@ export type { SupportRecoveryPrincipalContextError, SupportRecoveryPrincipalContextResolverService, } from './auth/support-recovery-principal-context.ts'; -export type { - SystemPrincipalContextError, - SystemWorkloadRegistration, -} from './auth/system-principal-context.ts'; +export type { SystemPrincipalContextError, SystemWorkloadRegistration } from './auth/system-principal-context.ts'; export { IdentityLifecycleConflictError, IdentityPersistenceUnavailableError, @@ -93,10 +90,7 @@ export { PrincipalManagementErrorSchema, } from './auth/principal-management-errors.ts'; export type { PrincipalManagementError } from './auth/principal-management-errors.ts'; -export { - managedPrincipalsRead, - selfApiKeyBindingsRead, -} from './auth/principal-administration-reads.ts'; +export { managedPrincipalsRead, selfApiKeyBindingsRead } from './auth/principal-administration-reads.ts'; export { LegalEntityContext, LegalEntityContextAmbiguousError, @@ -139,12 +133,7 @@ export { PRINCIPAL_KINDS, PRINCIPAL_STATUSES, } from './db/schema.ts'; -export type { - BindingStatus, - BindingSubjectType, - PrincipalKind, - PrincipalStatus, -} from './db/schema.ts'; +export type { BindingStatus, BindingSubjectType, PrincipalKind, PrincipalStatus } from './db/schema.ts'; export { tenantLegalEntityRlsPolicies, tenantRlsPolicies } from './db/scoped-transaction.ts'; export { DatabaseCommitAcknowledgementAmbiguous, @@ -184,11 +173,7 @@ export type { ResourceAccessTarget, TenantPermissionKey, } from './permissions/context-access.ts'; -export { - defineAction, - defineActionResourcePermission, - isActionRegistration, -} from './actions/definition.ts'; +export { defineAction, defineActionResourcePermission, isActionRegistration } from './actions/definition.ts'; export type { ActionAuditProfile, ActionDescriptor, @@ -204,12 +189,7 @@ export type { ActionTenantPermission, AnyActionRegistration, } from './actions/definition.ts'; -export { - PolicyDenied, - defineGlobalPolicy, - defineMicroverticalPolicy, - denyPolicy, -} from './actions/policy.ts'; +export { PolicyDenied, defineGlobalPolicy, defineMicroverticalPolicy, denyPolicy } from './actions/policy.ts'; export type { ActionPolicy, ActionPolicyEvaluator, @@ -221,12 +201,7 @@ export type { GlobalActionPolicy, MicroverticalActionPolicy, } from './actions/policy.ts'; -export { - ActionRuntime, - ActionRuntimeLive, - resolveActionCommit, - runAction, -} from './actions/runtime.ts'; +export { ActionRuntime, ActionRuntimeLive, resolveActionCommit, runAction } from './actions/runtime.ts'; export type { ActionCommitOpen, ActionRuntimeService, @@ -287,11 +262,7 @@ export type { ReadServiceFactory, ResolvedReadPermissionTarget, } from './reads/definition.ts'; -export type { - ReadEvidenceMetadata, - ReadHandlerContext, - ReadHandlerResult, -} from './reads/context.ts'; +export type { ReadEvidenceMetadata, ReadHandlerContext, ReadHandlerResult } from './reads/context.ts'; export { READ_RUNTIME_STAGES, ReadRuntime, ReadRuntimeLive } from './reads/runtime.ts'; export type { ReadRuntimeOptions, ReadRuntimeService, ReadRuntimeStage } from './reads/runtime.ts'; export { @@ -361,10 +332,7 @@ export type { CoreSearchPartyLifecycleTopic, CoreSearchPartyProjectorWorkerKey, } from './search/ingestion.ts'; -export { - CoreSearchWorkerSnapshot, - CoreSearchWorkerSnapshotLive, -} from './search/worker-snapshot.ts'; +export { CoreSearchWorkerSnapshot, CoreSearchWorkerSnapshotLive } from './search/worker-snapshot.ts'; export type { CoreSearchSnapshotReadExecutor, CoreSearchWorkerSnapshotService, @@ -463,10 +431,7 @@ export type { SystemModuleEntrypoint, TenantModuleEntrypoint, } from './modules/module-entrypoint.ts'; -export { - ModuleStateCheckUnavailableError, - ModuleStateDeniedError, -} from './modules/module-state-gate-errors.ts'; +export { ModuleStateCheckUnavailableError, ModuleStateDeniedError } from './modules/module-state-gate-errors.ts'; export type { ModuleStateGateError } from './modules/module-state-gate-errors.ts'; export { ModuleStateGate, @@ -475,10 +440,7 @@ export { tenantStatesAllowingAccess, } from './modules/module-state-gate.ts'; export type { ModuleStateGateService, ModuleStateSnapshot } from './modules/module-state-gate.ts'; -export { - ModuleEntrypointGateway, - ModuleEntrypointGatewayLive, -} from './modules/module-entrypoint-gateway.ts'; +export { ModuleEntrypointGateway, ModuleEntrypointGatewayLive } from './modules/module-entrypoint-gateway.ts'; export type { ModuleEntrypointGatewayService, RunGatedModuleEntrypointInput, @@ -527,10 +489,7 @@ export { ShellTimelineContributionSchema, validateShellContributions, } from './modules/shell-contribution.ts'; -export type { - OntosShellContributions, - ShellContributionReferenceSets, -} from './modules/shell-contribution.ts'; +export type { OntosShellContributions, ShellContributionReferenceSets } from './modules/shell-contribution.ts'; export type { OntosActionContract, OntosApiContract, @@ -627,16 +586,8 @@ export type { RunOutboxPollingLoopInput, } from './outbox/poller.ts'; export { OutboxRepositoryLive } from './outbox/repository.ts'; -export type { - RunOutboxWorkerProcessInput, - StartOutboxWorkerProcessInput, -} from './outbox/process.ts'; -export { - OutboxRuntime, - OutboxRuntimeLive, - matchOutboxMessages, - runOutboxCycle, -} from './outbox/runtime.ts'; +export type { RunOutboxWorkerProcessInput, StartOutboxWorkerProcessInput } from './outbox/process.ts'; +export { OutboxRuntime, OutboxRuntimeLive, matchOutboxMessages, runOutboxCycle } from './outbox/runtime.ts'; export type { MatchOutboxMessagesInput, OutboxCycleError, diff --git a/app/packages/core-runtime/src/install/action-authorization-provisioning.ts b/app/packages/core-runtime/src/install/action-authorization-provisioning.ts index 2dee8ff90..0d814fc86 100644 --- a/app/packages/core-runtime/src/install/action-authorization-provisioning.ts +++ b/app/packages/core-runtime/src/install/action-authorization-provisioning.ts @@ -1,5 +1,6 @@ import { v1 } from '@authzed/authzed-node'; import { Effect, Option, Schema } from 'effect'; + import { fullyConsistent } from '../permissions/client.ts'; import { ONTOS_SPICEDB_SCHEMA } from '../permissions/schema.ts'; import { toSpiceDbActionObjectId } from '../permissions/service.ts'; @@ -44,9 +45,7 @@ export interface ActionAuthorizationProvisioningClient { readonly writeRelationships: ( request: v1.WriteRelationshipsRequest, ) => Effect.Effect; - readonly writeSchema: ( - request: v1.WriteSchemaRequest, - ) => Effect.Effect; + readonly writeSchema: (request: v1.WriteSchemaRequest) => Effect.Effect; } export class ActionAuthorizationProvisioningError extends Schema.TaggedError()( @@ -67,8 +66,7 @@ export class ActionAuthorizationProvisioningError extends Schema.TaggedError - new ActionAuthorizationProvisioningError({ code, reason }); +): ActionAuthorizationProvisioningError => new ActionAuthorizationProvisioningError({ code, reason }); const hasInvalidActions = (actions: readonly ActionAuthorizationProvisioningAction[]): boolean => { const actionKeys = actions.map(({ actionKey }) => actionKey); @@ -76,10 +74,7 @@ const hasInvalidActions = (actions: readonly ActionAuthorizationProvisioningActi actions.length === 0 || actionKeys.some((actionKey) => actionKey.length === 0 || actionKey.length > 256) || new Set(actionKeys).size !== actionKeys.length || - actions.some( - ({ provisioning }) => - provisioning !== 'tenant_membership_default' && provisioning !== 'explicit', - ) + actions.some(({ provisioning }) => provisioning !== 'tenant_membership_default' && provisioning !== 'explicit') ); }; @@ -96,8 +91,7 @@ const isInvalidExplicitAssertionSet = ( assertions.length < 2 || new Set(assertions.map(({ principalId }) => principalId)).size !== assertions.length || assertions.some( - ({ expected, principalId }) => - principalId.length === 0 || (expected !== 'allowed' && expected !== 'denied'), + ({ expected, principalId }) => principalId.length === 0 || (expected !== 'allowed' && expected !== 'denied'), ) || !assertions.some(({ expected }) => expected === 'allowed') || !assertions.some(({ expected }) => expected === 'denied'); @@ -107,24 +101,14 @@ const hasInvalidExplicitAssertions = ( explicitActionKeys: ReadonlySet, ): boolean => explicitActionAssertions.length !== explicitActionKeys.size || - explicitActionAssertions.some((assertionSet) => - isInvalidExplicitAssertionSet(assertionSet, explicitActionKeys), - ) || - new Set(explicitActionAssertions.map(({ actionKey }) => actionKey)).size !== - explicitActionAssertions.length; + explicitActionAssertions.some((assertionSet) => isInvalidExplicitAssertionSet(assertionSet, explicitActionKeys)) || + new Set(explicitActionAssertions.map(({ actionKey }) => actionKey)).size !== explicitActionAssertions.length; const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => { - const actions = input.actions.toSorted((left, right) => - left.actionKey.localeCompare(right.actionKey), - ); - const contexts = input.contexts.toSorted((left, right) => - left.tenantId.localeCompare(right.tenantId), - ); + const actions = input.actions.toSorted((left, right) => left.actionKey.localeCompare(right.actionKey)); + const contexts = input.contexts.toSorted((left, right) => left.tenantId.localeCompare(right.tenantId)); if (hasInvalidActions(actions)) { - throw failure( - 'action_authorization_input_invalid', - 'Current Action discovery must produce a non-empty unique set', - ); + throw failure('action_authorization_input_invalid', 'Current Action discovery must produce a non-empty unique set'); } if (hasInvalidContexts(contexts)) { throw failure( @@ -133,10 +117,7 @@ const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => ); } const deniedPrincipalId = input.deniedPrincipalId ?? ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID; - if ( - deniedPrincipalId.length === 0 || - contexts.some(({ principalId }) => principalId === deniedPrincipalId) - ) { + if (deniedPrincipalId.length === 0 || contexts.some(({ principalId }) => principalId === deniedPrincipalId)) { throw failure( 'action_authorization_input_invalid', 'The denied verification Principal must be outside the fixed context set', @@ -164,7 +145,10 @@ const tenantAccessRequest = (context: ActionAuthorizationContext) => v1.CheckPermissionRequest.create({ consistency: fullyConsistent, permission: 'access', - resource: v1.ObjectReference.create({ objectId: context.tenantId, objectType: 'tenant' }), + resource: v1.ObjectReference.create({ + objectId: context.tenantId, + objectType: 'tenant', + }), subject: v1.SubjectReference.create({ object: v1.ObjectReference.create({ objectId: context.principalId, @@ -182,7 +166,10 @@ const actionExecuteRequest = (actionKey: string, principalId: string) => objectType: 'action', }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: principalId, objectType: 'principal' }), + object: v1.ObjectReference.create({ + objectId: principalId, + objectType: 'principal', + }), }), }); @@ -200,7 +187,10 @@ export const buildActionAuthorizationRelationships = ( objectType: 'action', }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: tenantId, objectType: 'tenant' }), + object: v1.ObjectReference.create({ + objectId: tenantId, + objectType: 'tenant', + }), optionalRelation: 'member', }), }), @@ -264,108 +254,101 @@ const checkNoPermission = ( ), ); -export const provisionActionAuthorization = Effect.fn( - 'ActionAuthorizationProvisioning.provisionActionAuthorization', -)(function* provisionActionAuthorizationEffect( - client: ActionAuthorizationProvisioningClient, - input: ActionAuthorizationProvisioningInput, -): Effect.fn.Return { - const { actions, contexts, deniedPrincipalId, explicitActionAssertions } = yield* Effect.try({ - catch: (error) => - Schema.is(ActionAuthorizationProvisioningError)(error) ? error : serviceFailure(error), - try: () => assertProvisioningInput(input), - }); +export const provisionActionAuthorization = Effect.fn('ActionAuthorizationProvisioning.provisionActionAuthorization')( + function* provisionActionAuthorizationEffect( + client: ActionAuthorizationProvisioningClient, + input: ActionAuthorizationProvisioningInput, + ): Effect.fn.Return { + const { actions, contexts, deniedPrincipalId, explicitActionAssertions } = yield* Effect.try({ + catch: (error) => (Schema.is(ActionAuthorizationProvisioningError)(error) ? error : serviceFailure(error)), + try: () => assertProvisioningInput(input), + }); - yield* callClient( - client.writeSchema(v1.WriteSchemaRequest.create({ schema: ONTOS_SPICEDB_SCHEMA })), - ); + yield* callClient(client.writeSchema(v1.WriteSchemaRequest.create({ schema: ONTOS_SPICEDB_SCHEMA }))); - yield* Effect.forEach( - contexts, - (context) => - checkHasPermission( - client, - tenantAccessRequest(context), - failure( - 'action_authorization_membership_missing', - 'A fixed provisioning Principal is not an active member of its Tenant', + yield* Effect.forEach( + contexts, + (context) => + checkHasPermission( + client, + tenantAccessRequest(context), + failure( + 'action_authorization_membership_missing', + 'A fixed provisioning Principal is not an active member of its Tenant', + ), ), - ), - { concurrency: 1, discard: true }, - ); + { concurrency: 1, discard: true }, + ); - const defaultActionKeys = actions.flatMap(({ actionKey, provisioning }) => - provisioning === 'tenant_membership_default' ? [actionKey] : [], - ); - const relationships = buildActionAuthorizationRelationships(defaultActionKeys, contexts); - yield* callClient( - client.writeRelationships( - v1.WriteRelationshipsRequest.create({ - updates: relationships.map((relationship) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.TOUCH, - relationship, - }), - ), - }), - ), - ); + const defaultActionKeys = actions.flatMap(({ actionKey, provisioning }) => + provisioning === 'tenant_membership_default' ? [actionKey] : [], + ); + const relationships = buildActionAuthorizationRelationships(defaultActionKeys, contexts); + yield* callClient( + client.writeRelationships( + v1.WriteRelationshipsRequest.create({ + updates: relationships.map((relationship) => + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.TOUCH, + relationship, + }), + ), + }), + ), + ); - yield* Effect.forEach( - defaultActionKeys, - (actionKey) => - Effect.forEach( - contexts, - (context) => - checkHasPermission( - client, - actionExecuteRequest(actionKey, context.principalId), - failure( - 'action_authorization_verification_failed', - 'An expected fixed Tenant Action grant did not verify', + yield* Effect.forEach( + defaultActionKeys, + (actionKey) => + Effect.forEach( + contexts, + (context) => + checkHasPermission( + client, + actionExecuteRequest(actionKey, context.principalId), + failure( + 'action_authorization_verification_failed', + 'An expected fixed Tenant Action grant did not verify', + ), ), - ), - { concurrency: 1, discard: true }, - ).pipe( - Effect.andThen( - checkNoPermission(client, actionExecuteRequest(actionKey, deniedPrincipalId)), + { concurrency: 1, discard: true }, + ).pipe(Effect.andThen(checkNoPermission(client, actionExecuteRequest(actionKey, deniedPrincipalId)))), + { concurrency: 1, discard: true }, + ); + yield* Effect.forEach( + explicitActionAssertions, + ({ actionKey, assertions }) => + Effect.forEach( + assertions, + (assertion) => { + const request = actionExecuteRequest(actionKey, assertion.principalId); + return assertion.expected === 'allowed' + ? checkHasPermission( + client, + request, + failure( + 'action_authorization_verification_failed', + 'An explicit Action allowed assertion did not verify', + ), + ) + : checkNoPermission( + client, + request, + failure( + 'action_authorization_verification_failed', + 'An explicit Action denied assertion did not verify', + ), + ); + }, + { concurrency: 1, discard: true }, ), - ), - { concurrency: 1, discard: true }, - ); - yield* Effect.forEach( - explicitActionAssertions, - ({ actionKey, assertions }) => - Effect.forEach( - assertions, - (assertion) => { - const request = actionExecuteRequest(actionKey, assertion.principalId); - return assertion.expected === 'allowed' - ? checkHasPermission( - client, - request, - failure( - 'action_authorization_verification_failed', - 'An explicit Action allowed assertion did not verify', - ), - ) - : checkNoPermission( - client, - request, - failure( - 'action_authorization_verification_failed', - 'An explicit Action denied assertion did not verify', - ), - ); - }, - { concurrency: 1, discard: true }, - ), - { concurrency: 1, discard: true }, - ); + { concurrency: 1, discard: true }, + ); - return { - actionCount: actions.length, - grantCount: relationships.length, - tenantCount: contexts.length, - }; -}); + return { + actionCount: actions.length, + grantCount: relationships.length, + tenantCount: contexts.length, + }; + }, +); diff --git a/app/packages/core-runtime/src/install/context-bootstrap-shared.ts b/app/packages/core-runtime/src/install/context-bootstrap-shared.ts index 0ae17ea37..8d40a9606 100644 --- a/app/packages/core-runtime/src/install/context-bootstrap-shared.ts +++ b/app/packages/core-runtime/src/install/context-bootstrap-shared.ts @@ -1,5 +1,6 @@ import { v1 } from '@authzed/authzed-node'; import { and, eq, or } from 'drizzle-orm'; + import { legalEntities, principalAuthBindings, principals } from '../db/schema.ts'; import type { CoreTransaction } from '../db/types.ts'; @@ -14,10 +15,7 @@ interface BootstrapIdentity { readonly tenantId: string; } -export const selectBootstrapLegalEntities = ( - transaction: CoreTransaction, - context: BootstrapIdentity, -) => +export const selectBootstrapLegalEntities = (transaction: CoreTransaction, context: BootstrapIdentity) => transaction .select({ legalEntityId: legalEntities.legalEntityId, @@ -40,10 +38,7 @@ export const selectBootstrapLegalEntities = ( ) .limit(2); -export const selectBootstrapPrincipals = ( - transaction: CoreTransaction, - context: BootstrapIdentity, -) => +export const selectBootstrapPrincipals = (transaction: CoreTransaction, context: BootstrapIdentity) => transaction .select({ displayName: principals.displayName, diff --git a/app/packages/core-runtime/src/install/spicedb-database-config.ts b/app/packages/core-runtime/src/install/spicedb-database-config.ts index c3632e2a8..471ef677b 100644 --- a/app/packages/core-runtime/src/install/spicedb-database-config.ts +++ b/app/packages/core-runtime/src/install/spicedb-database-config.ts @@ -14,9 +14,7 @@ const SpiceDbDatabaseBootstrapEnvironmentSchema = Schema.Struct({ const PostgreSqlUrlSchema = Schema.URLFromString.check( Schema.makeFilter((url) => - url.protocol === 'postgres:' || url.protocol === 'postgresql:' - ? undefined - : 'URL must use PostgreSQL', + url.protocol === 'postgres:' || url.protocol === 'postgresql:' ? undefined : 'URL must use PostgreSQL', ), ); @@ -71,28 +69,20 @@ export type SpiceDbDatabaseBootstrapConfig = ReturnType { - const source = Result.getOrThrow( - Schema.decodeUnknownResult(SpiceDbDatabaseBootstrapEnvironmentSchema)(environment), - ); - const admin = Result.getOrThrow( - Schema.decodeUnknownResult(PostgreSqlUrlSchema)(source.DATABASE_ADMIN_URL), - ); - const spicedb = Result.getOrThrow( - Schema.decodeUnknownResult(PostgreSqlUrlSchema)(source.SPICEDB_DATABASE_URL), - ); + const source = Result.getOrThrow(Schema.decodeUnknownResult(SpiceDbDatabaseBootstrapEnvironmentSchema)(environment)); + const admin = Result.getOrThrow(Schema.decodeResult(PostgreSqlUrlSchema)(source.DATABASE_ADMIN_URL)); + const spicedb = Result.getOrThrow(Schema.decodeResult(PostgreSqlUrlSchema)(source.SPICEDB_DATABASE_URL)); const pair = Result.getOrThrow( - Schema.decodeUnknownResult(SpiceDbDatabasePairSchema)({ + Schema.decodeResult(SpiceDbDatabasePairSchema)({ admin, spicedb, spicedbUser: decodeURIComponent( - Result.getOrThrow( - Schema.decodeUnknownResult(PercentEncodedUriComponentSchema)(spicedb.username), - ), + Result.getOrThrow(Schema.decodeResult(PercentEncodedUriComponentSchema)(spicedb.username)), ), }), ); const encodedPassword = Result.getOrThrow( - Schema.decodeUnknownResult(PercentEncodedUriComponentSchema)(pair.spicedb.password), + Schema.decodeResult(PercentEncodedUriComponentSchema)(pair.spicedb.password), ); return makeSpiceDbDatabaseBootstrapConfig({ diff --git a/app/packages/core-runtime/src/install/stage-context-bootstrap.ts b/app/packages/core-runtime/src/install/stage-context-bootstrap.ts index 4bb2135a2..2b5923dfa 100644 --- a/app/packages/core-runtime/src/install/stage-context-bootstrap.ts +++ b/app/packages/core-runtime/src/install/stage-context-bootstrap.ts @@ -1,32 +1,24 @@ -import { - bootstrapPrincipalRecord, - bootstrapRelationshipRequest, - selectBootstrapLegalEntities, - selectBootstrapPrincipals, - selectBootstrapAuthBindings, -} from './context-bootstrap-shared.ts'; import { v1 } from '@authzed/authzed-node'; import { and, eq, or } from 'drizzle-orm'; import { Config, Effect, Option, Redacted, Schema } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; + // This installer composes the privileged database used only for stage initialization. // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Native scoped database composition at the installer boundary. import { makeCoreDatabase } from '../db/client.ts'; import { parseDatabaseConfig } from '../db/config.ts'; -import { - legalEntities, - principalAuthBindings, - principals, - tenantModuleStates, - tenants, -} from '../db/schema.ts'; +import { legalEntities, principalAuthBindings, principals, tenantModuleStates, tenants } from '../db/schema.ts'; import type { CoreDatabaseExecutor, CoreTransaction } from '../db/types.ts'; import { spiceDbClientSecurity } from '../permissions/client.ts'; import { parseSpiceDbConfig } from '../permissions/config.ts'; +import { toLegalEntityAccessObjectId, toModuleAccessObjectId } from '../permissions/context-access.ts'; import { - toLegalEntityAccessObjectId, - toModuleAccessObjectId, -} from '../permissions/context-access.ts'; + bootstrapPrincipalRecord, + bootstrapRelationshipRequest, + selectBootstrapLegalEntities, + selectBootstrapPrincipals, + selectBootstrapAuthBindings, +} from './context-bootstrap-shared.ts'; type Comparable = boolean | null | number | string; type ExactRecord = Readonly>; @@ -89,10 +81,7 @@ export interface StageContextBootstrapResult { } export type StageContextBootstrapProviderUserIds = readonly [string, string]; -export type StageContextBootstrapResults = readonly [ - StageContextBootstrapResult, - StageContextBootstrapResult, -]; +export type StageContextBootstrapResults = readonly [StageContextBootstrapResult, StageContextBootstrapResult]; export class StageContextBootstrapError extends Schema.TaggedError()( 'StageContextBootstrapError', @@ -136,32 +125,21 @@ const tryBootstrapPromise = ( }), ); -const loadConfiguration = (): Effect.Effect< - StageContextBootstrapConfiguration, - StageContextBootstrapError -> => +const loadConfiguration = (): Effect.Effect => Effect.gen(function* loadStageContextBootstrapConfiguration() { const source = yield* Effect.all( { - databaseAdminUrl: Config.schema( - Schema.Redacted(TrimmedNonEmptyString), - 'DATABASE_ADMIN_URL', - ).pipe(Effect.mapError((cause) => failure('DATABASE_ADMIN_URL is required', cause))), - deploymentEnvironment: Config.schema( - StageEnvironmentSchema, - 'ULTRAMODERN_DEPLOYMENT_ENVIRONMENT', - ).pipe( - Effect.mapError((cause) => - failure('The Core installation bootstrap can run only in stage', cause), - ), + databaseAdminUrl: Config.schema(Schema.Redacted(TrimmedNonEmptyString), 'DATABASE_ADMIN_URL').pipe( + Effect.mapError((cause) => failure('DATABASE_ADMIN_URL is required', cause)), + ), + deploymentEnvironment: Config.schema(StageEnvironmentSchema, 'ULTRAMODERN_DEPLOYMENT_ENVIRONMENT').pipe( + Effect.mapError((cause) => failure('The Core installation bootstrap can run only in stage', cause)), ), spiceDbEndpoint: Config.schema(TrimmedNonEmptyString, 'SPICEDB_ENDPOINT').pipe( Effect.mapError((cause) => failure('SPICEDB_ENDPOINT is required', cause)), ), spiceDbInsecure: Config.schema(Schema.Trim, 'SPICEDB_INSECURE').pipe( - Effect.mapError((cause) => - failure('SPICEDB_INSECURE must be explicitly true or false', cause), - ), + Effect.mapError((cause) => failure('SPICEDB_INSECURE must be explicitly true or false', cause)), ), spiceDbPreSharedKey: Config.redacted('SPICEDB_PRESHARED_KEY').pipe( Effect.mapError((cause) => failure('SPICEDB_PRESHARED_KEY is required', cause)), @@ -169,9 +147,9 @@ const loadConfiguration = (): Effect.Effect< }, { concurrency: 5 }, ); - yield* parseDatabaseConfig({ DATABASE_URL: Redacted.value(source.databaseAdminUrl) }).pipe( - Effect.mapError((error) => failure(error.reason, error)), - ); + yield* parseDatabaseConfig({ + DATABASE_URL: Redacted.value(source.databaseAdminUrl), + }).pipe(Effect.mapError((error) => failure(error.reason, error))); const spiceDb = yield* parseSpiceDbConfig({ SPICEDB_ENDPOINT: source.spiceDbEndpoint, SPICEDB_INSECURE: source.spiceDbInsecure, @@ -197,162 +175,132 @@ const classifyExactRecord = ( if (existing === undefined) { return Effect.succeed('create'); } - const conflictingFields = Object.entries(expected).flatMap(([key, value]) => - existing[key] === value ? [] : [key], - ); + const conflictingFields = Object.entries(expected).flatMap(([key, value]) => (existing[key] === value ? [] : [key])); if (conflictingFields.length > 0) { return Effect.fail( - failure( - `Existing ${label} conflicts with the stage bootstrap definition (${conflictingFields.join(', ')})`, - ), + failure(`Existing ${label} conflicts with the stage bootstrap definition (${conflictingFields.join(', ')})`), ); } return Effect.succeed('existing'); }; -const reconcilePostgresTransaction = Effect.fn( - 'StageContextBootstrap.reconcilePostgresTransaction', -)(function* reconcileStagePostgresTransaction( - transaction: CoreTransaction, - context: StageContext, - authUserId: string, -): Effect.fn.Return { - const tenantCandidates = yield* transaction - .select({ - defaultLocale: tenants.defaultLocale, - name: tenants.name, - slug: tenants.slug, - status: tenants.status, - tenantId: tenants.tenantId, - }) - .from(tenants) - .where(or(eq(tenants.tenantId, context.tenantId), eq(tenants.slug, context.tenantSlug))) - .limit(2) - .pipe(Effect.mapError(bootstrapFailureFromCause)); - if (tenantCandidates.length > 1) { - return yield* failure('The stage tenant identity conflicts'); - } - const expectedTenant = { - defaultLocale: context.defaultLocale, - name: context.tenantName, - slug: context.tenantSlug, - status: 'active', - tenantId: context.tenantId, - } as const; - if ((yield* classifyExactRecord('tenant', tenantCandidates[0], expectedTenant)) === 'create') { - yield* transaction - .insert(tenants) - .values(expectedTenant) +const reconcilePostgresTransaction = Effect.fn('StageContextBootstrap.reconcilePostgresTransaction')( + function* reconcileStagePostgresTransaction( + transaction: CoreTransaction, + context: StageContext, + authUserId: string, + ): Effect.fn.Return { + const tenantCandidates = yield* transaction + .select({ + defaultLocale: tenants.defaultLocale, + name: tenants.name, + slug: tenants.slug, + status: tenants.status, + tenantId: tenants.tenantId, + }) + .from(tenants) + .where(or(eq(tenants.tenantId, context.tenantId), eq(tenants.slug, context.tenantSlug))) + .limit(2) .pipe(Effect.mapError(bootstrapFailureFromCause)); - } + if (tenantCandidates.length > 1) { + return yield* failure('The stage tenant identity conflicts'); + } + const expectedTenant = { + defaultLocale: context.defaultLocale, + name: context.tenantName, + slug: context.tenantSlug, + status: 'active', + tenantId: context.tenantId, + } as const; + if ((yield* classifyExactRecord('tenant', tenantCandidates[0], expectedTenant)) === 'create') { + yield* transaction.insert(tenants).values(expectedTenant).pipe(Effect.mapError(bootstrapFailureFromCause)); + } - const legalEntityCandidates = yield* selectBootstrapLegalEntities(transaction, context).pipe( - Effect.mapError(bootstrapFailureFromCause), - ); - if (legalEntityCandidates.length > 1) { - return yield* failure('The stage legal-entity identity conflicts'); - } - const expectedLegalEntity = { - legalEntityId: context.legalEntityId, - legalName: context.legalName, - registrationCountry: context.registrationCountry, - registrationNumber: context.registrationNumber, - status: 'active', - tenantId: context.tenantId, - } as const; - if ( - (yield* classifyExactRecord('legal entity', legalEntityCandidates[0], expectedLegalEntity)) === - 'create' - ) { - yield* transaction - .insert(legalEntities) - .values(expectedLegalEntity) - .pipe(Effect.mapError(bootstrapFailureFromCause)); - } + const legalEntityCandidates = yield* selectBootstrapLegalEntities(transaction, context).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); + if (legalEntityCandidates.length > 1) { + return yield* failure('The stage legal-entity identity conflicts'); + } + const expectedLegalEntity = { + legalEntityId: context.legalEntityId, + legalName: context.legalName, + registrationCountry: context.registrationCountry, + registrationNumber: context.registrationNumber, + status: 'active', + tenantId: context.tenantId, + } as const; + if ((yield* classifyExactRecord('legal entity', legalEntityCandidates[0], expectedLegalEntity)) === 'create') { + yield* transaction + .insert(legalEntities) + .values(expectedLegalEntity) + .pipe(Effect.mapError(bootstrapFailureFromCause)); + } - const expectedPrincipal = bootstrapPrincipalRecord(context); - const principalCandidates = yield* selectBootstrapPrincipals(transaction, context).pipe( - Effect.mapError(bootstrapFailureFromCause), - ); - if ( - (yield* classifyExactRecord('principal', principalCandidates[0], expectedPrincipal)) === - 'create' - ) { - yield* transaction - .insert(principals) - .values(expectedPrincipal) - .pipe(Effect.mapError(bootstrapFailureFromCause)); - } + const expectedPrincipal = bootstrapPrincipalRecord(context); + const principalCandidates = yield* selectBootstrapPrincipals(transaction, context).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); + if ((yield* classifyExactRecord('principal', principalCandidates[0], expectedPrincipal)) === 'create') { + yield* transaction.insert(principals).values(expectedPrincipal).pipe(Effect.mapError(bootstrapFailureFromCause)); + } - const bindingCandidates = yield* selectBootstrapAuthBindings( - transaction, - context, - authUserId, - ).pipe(Effect.mapError(bootstrapFailureFromCause)); - if (bindingCandidates.length > 1) { - return yield* failure('The stage authentication binding conflicts'); - } - const expectedBinding = { - principalAuthBindingId: context.authBindingId, - principalId: context.principalId, - provider: 'better_auth', - providerSubjectId: authUserId, - status: 'active', - subjectType: 'user', - tenantId: context.tenantId, - } as const; - if ( - (yield* classifyExactRecord( - 'authentication binding', - bindingCandidates[0], - expectedBinding, - )) === 'create' - ) { - yield* transaction - .insert(principalAuthBindings) - .values(expectedBinding) - .pipe(Effect.mapError(bootstrapFailureFromCause)); - } + const bindingCandidates = yield* selectBootstrapAuthBindings(transaction, context, authUserId).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); + if (bindingCandidates.length > 1) { + return yield* failure('The stage authentication binding conflicts'); + } + const expectedBinding = { + principalAuthBindingId: context.authBindingId, + principalId: context.principalId, + provider: 'better_auth', + providerSubjectId: authUserId, + status: 'active', + subjectType: 'user', + tenantId: context.tenantId, + } as const; + if ((yield* classifyExactRecord('authentication binding', bindingCandidates[0], expectedBinding)) === 'create') { + yield* transaction + .insert(principalAuthBindings) + .values(expectedBinding) + .pipe(Effect.mapError(bootstrapFailureFromCause)); + } - const moduleStateCandidates = yield* transaction - .select({ - moduleKey: tenantModuleStates.moduleKey, - state: tenantModuleStates.state, - tenantId: tenantModuleStates.tenantId, - tenantModuleStateId: tenantModuleStates.tenantModuleStateId, - }) - .from(tenantModuleStates) - .where( - or( - eq(tenantModuleStates.tenantModuleStateId, context.moduleStateId), - and( - eq(tenantModuleStates.tenantId, context.tenantId), - eq(tenantModuleStates.moduleKey, context.moduleId), + const moduleStateCandidates = yield* transaction + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + tenantId: tenantModuleStates.tenantId, + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, + }) + .from(tenantModuleStates) + .where( + or( + eq(tenantModuleStates.tenantModuleStateId, context.moduleStateId), + and(eq(tenantModuleStates.tenantId, context.tenantId), eq(tenantModuleStates.moduleKey, context.moduleId)), ), - ), - ) - .limit(2) - .pipe(Effect.mapError(bootstrapFailureFromCause)); - if (moduleStateCandidates.length > 1) { - return yield* failure('The stage module-state identity conflicts'); - } - const expectedModuleState = { - moduleKey: context.moduleId, - state: 'active', - tenantId: context.tenantId, - tenantModuleStateId: context.moduleStateId, - } as const; - if ( - (yield* classifyExactRecord('module state', moduleStateCandidates[0], expectedModuleState)) === - 'create' - ) { - yield* transaction - .insert(tenantModuleStates) - .values(expectedModuleState) + ) + .limit(2) .pipe(Effect.mapError(bootstrapFailureFromCause)); - } - return yield* Effect.void; -}); + if (moduleStateCandidates.length > 1) { + return yield* failure('The stage module-state identity conflicts'); + } + const expectedModuleState = { + moduleKey: context.moduleId, + state: 'active', + tenantId: context.tenantId, + tenantModuleStateId: context.moduleStateId, + } as const; + if ((yield* classifyExactRecord('module state', moduleStateCandidates[0], expectedModuleState)) === 'create') { + yield* transaction + .insert(tenantModuleStates) + .values(expectedModuleState) + .pipe(Effect.mapError(bootstrapFailureFromCause)); + } + return yield* Effect.void; + }, +); const reconcilePostgresContext = Effect.fn('StageContextBootstrap.reconcilePostgresContext')( function* reconcileStagePostgresContext( @@ -363,12 +311,8 @@ const reconcilePostgresContext = Effect.fn('StageContextBootstrap.reconcilePostg const transactionBody = (transaction: CoreTransaction) => reconcilePostgresTransaction(transaction, context, authUserId); yield* database.transaction(transactionBody).pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), - Effect.catchTag('SqlError', (sqlFailure) => - Effect.fail(bootstrapFailureFromCause(sqlFailure)), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), + Effect.catchTag('SqlError', (sqlFailure) => Effect.fail(bootstrapFailureFromCause(sqlFailure))), ); }, ); @@ -377,15 +321,8 @@ const buildRelationships = Effect.fn('StageContextBootstrap.buildRelationships') function* buildStageContextRelationships( context: StageContext, ): Effect.fn.Return { - const legalEntityObjectId = toLegalEntityAccessObjectId( - context.tenantId, - context.legalEntityId, - ); - const moduleObjectId = toModuleAccessObjectId( - context.tenantId, - context.legalEntityId, - context.moduleId, - ); + const legalEntityObjectId = toLegalEntityAccessObjectId(context.tenantId, context.legalEntityId); + const moduleObjectId = toModuleAccessObjectId(context.tenantId, context.legalEntityId, context.moduleId); if (legalEntityObjectId === undefined || moduleObjectId === undefined) { return yield* failure('The stage authorization object IDs are invalid'); } @@ -447,10 +384,12 @@ const touchRelationships = Effect.fn('StageContextBootstrap.touchRelationships') ), }), (client) => - tryBootstrapPromise(client.promises.writeRelationships.bind(client.promises, request)).pipe( - Effect.asVoid, - ), - (client) => Effect.try({ catch: bootstrapFailureFromCause, try: () => client.close() }), + tryBootstrapPromise(client.promises.writeRelationships.bind(client.promises, request)).pipe(Effect.asVoid), + (client) => + Effect.try({ + catch: bootstrapFailureFromCause, + try: () => client.close(), + }), ); }, ); @@ -459,51 +398,54 @@ const touchRelationships = Effect.fn('StageContextBootstrap.touchRelationships') * Reconciles the complete fixed set of stage contexts before their principals/tenants can exist. * The caller supplies only the Shell-owned Better Auth user IDs in the documented fixed order. */ -export const reconcileStageContextBootstraps = Effect.fn( - 'StageContextBootstrap.reconcileStageContextBootstraps', -)(function* reconcileFixedStageContexts( - providerUserIds: StageContextBootstrapProviderUserIds, -): Effect.fn.Return { - const [techsioProviderUserId, siamparkProviderUserId] = providerUserIds; - if (techsioProviderUserId.trim().length === 0 || siamparkProviderUserId.trim().length === 0) { - return yield* failure('Both Better Auth provider user IDs are required'); - } - if (techsioProviderUserId === siamparkProviderUserId) { - return yield* failure('The stage contexts require distinct Better Auth provider user IDs'); - } - const contexts = [ - { context: STAGE_CONTEXTS.techsio, providerUserId: techsioProviderUserId }, - { context: STAGE_CONTEXTS.siampark, providerUserId: siamparkProviderUserId }, - ] as const; - const configuration = yield* loadConfiguration(); - yield* Effect.scoped( - Effect.gen(function* reconcileStageDatabase() { - const databaseConfiguration = yield* parseDatabaseConfig({ - DATABASE_URL: Redacted.value(configuration.databaseAdminUrl), - }).pipe(Effect.mapError(bootstrapFailureFromCause)); - const { executor } = yield* makeCoreDatabase(databaseConfiguration).pipe( - Effect.mapError(bootstrapFailureFromCause), - ); - yield* Effect.forEach( - contexts, - ({ context, providerUserId }) => - reconcilePostgresContext(executor, context, providerUserId).pipe( - Effect.andThen(touchRelationships(configuration, context)), - ), - { concurrency: 1, discard: true }, - ); - }), - ); - return [ - { - legalEntityId: STAGE_CONTEXTS.techsio.legalEntityId, - principalId: STAGE_CONTEXTS.techsio.principalId, - tenantId: STAGE_CONTEXTS.techsio.tenantId, - }, - { - legalEntityId: STAGE_CONTEXTS.siampark.legalEntityId, - principalId: STAGE_CONTEXTS.siampark.principalId, - tenantId: STAGE_CONTEXTS.siampark.tenantId, - }, - ]; -}); +export const reconcileStageContextBootstraps = Effect.fn('StageContextBootstrap.reconcileStageContextBootstraps')( + function* reconcileFixedStageContexts( + providerUserIds: StageContextBootstrapProviderUserIds, + ): Effect.fn.Return { + const [techsioProviderUserId, siamparkProviderUserId] = providerUserIds; + if (techsioProviderUserId.trim().length === 0 || siamparkProviderUserId.trim().length === 0) { + return yield* failure('Both Better Auth provider user IDs are required'); + } + if (techsioProviderUserId === siamparkProviderUserId) { + return yield* failure('The stage contexts require distinct Better Auth provider user IDs'); + } + const contexts = [ + { context: STAGE_CONTEXTS.techsio, providerUserId: techsioProviderUserId }, + { + context: STAGE_CONTEXTS.siampark, + providerUserId: siamparkProviderUserId, + }, + ] as const; + const configuration = yield* loadConfiguration(); + yield* Effect.scoped( + Effect.gen(function* reconcileStageDatabase() { + const databaseConfiguration = yield* parseDatabaseConfig({ + DATABASE_URL: Redacted.value(configuration.databaseAdminUrl), + }).pipe(Effect.mapError(bootstrapFailureFromCause)); + const { executor } = yield* makeCoreDatabase(databaseConfiguration).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); + yield* Effect.forEach( + contexts, + ({ context, providerUserId }) => + reconcilePostgresContext(executor, context, providerUserId).pipe( + Effect.andThen(touchRelationships(configuration, context)), + ), + { concurrency: 1, discard: true }, + ); + }), + ); + return [ + { + legalEntityId: STAGE_CONTEXTS.techsio.legalEntityId, + principalId: STAGE_CONTEXTS.techsio.principalId, + tenantId: STAGE_CONTEXTS.techsio.tenantId, + }, + { + legalEntityId: STAGE_CONTEXTS.siampark.legalEntityId, + principalId: STAGE_CONTEXTS.siampark.principalId, + tenantId: STAGE_CONTEXTS.siampark.tenantId, + }, + ]; + }, +); diff --git a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts index 4c6cca535..34d3c8a4d 100644 --- a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts +++ b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts @@ -2,21 +2,19 @@ // @ontos-action-owner core.identity // @ontos-action-slug bind-managed-api-key import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const PrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PrincipalId')); -const ProviderSubjectIdSchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(500), -).pipe(Schema.brand('ProviderSubjectId')); -const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('AuthBindingId'), +const ProviderSubjectIdSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)).pipe( + Schema.brand('ProviderSubjectId'), ); +const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('AuthBindingId')); const BindManagedApiKeyPayloadSchema = Schema.Struct({ principalId: PrincipalIdSchema, providerSubjectId: ProviderSubjectIdSchema, @@ -65,7 +63,10 @@ export const bindManagedApiKeyAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.bind-managed-api-key', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts b/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts index a7907a090..80ae3b135 100644 --- a/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts +++ b/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts @@ -2,20 +2,18 @@ // @ontos-action-owner core.identity // @ontos-action-slug bind-self-api-key import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; -const ProviderSubjectIdSchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(500), -).pipe(Schema.brand('ProviderSubjectId')); -const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('AuthBindingId'), +const ProviderSubjectIdSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)).pipe( + Schema.brand('ProviderSubjectId'), ); +const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('AuthBindingId')); const BindSelfApiKeyPayloadSchema = Schema.Struct({ providerSubjectId: ProviderSubjectIdSchema, }); @@ -29,10 +27,7 @@ type Input = Parameters[0]; type Result = ReturnType; const handle = Effect.fn('BindSelfApiKeyAction.handle')(function* bindSelfApiKeyActionHandle( payload: BindSelfApiKeyPayload, - context: ActionHandlerContext< - Readonly>, - { readonly bind: (input: Input) => Result } - >, + context: ActionHandlerContext>, { readonly bind: (input: Input) => Result }>, ) { const result = yield* context.services.bind({ managed: false, @@ -66,7 +61,10 @@ export const bindSelfApiKeyAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.bind-self-api-key', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts index 21558ed78..a1ab28d69 100644 --- a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts @@ -2,11 +2,12 @@ // @ontos-action-owner core.identity // @ontos-action-slug change-principal-status import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const PrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PrincipalId')); @@ -26,31 +27,35 @@ const ChangePrincipalStatusPayloadSchema = Schema.Union([ reason, }), ]); -export type ChangePrincipalStatusPayload = Schema.Schema.Type< - typeof ChangePrincipalStatusPayloadSchema ->; -const ChangePrincipalStatusResultSchema = Schema.Struct({ previousStatus: status, status }); -const handle = Effect.fn('ChangePrincipalStatusAction.handle')( - function* changePrincipalStatusActionHandle( - payload: ChangePrincipalStatusPayload, - context: ActionHandlerContext< - Readonly>, - { readonly change: PrincipalManagementRepositoryService['changePrincipalStatus'] } - >, - ) { - const result = yield* context.services.change({ ...payload, tenantId: context.scope.tenantId }); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `principal-status-prior:${payload.principalId}`, - resultCount: 1, - servingModuleKey: 'core.identity', - targetModuleKey: 'core.identity', - targetResourceId: payload.principalId, - targetResourceType: 'principal', - }); - return result; - }, -); +export type ChangePrincipalStatusPayload = Schema.Schema.Type; +const ChangePrincipalStatusResultSchema = Schema.Struct({ + previousStatus: status, + status, +}); +const handle = Effect.fn('ChangePrincipalStatusAction.handle')(function* changePrincipalStatusActionHandle( + payload: ChangePrincipalStatusPayload, + context: ActionHandlerContext< + Readonly>, + { + readonly change: PrincipalManagementRepositoryService['changePrincipalStatus']; + } + >, +) { + const result = yield* context.services.change({ + ...payload, + tenantId: context.scope.tenantId, + }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `principal-status-prior:${payload.principalId}`, + resultCount: 1, + servingModuleKey: 'core.identity', + targetModuleKey: 'core.identity', + targetResourceId: payload.principalId, + targetResourceType: 'principal', + }); + return result; +}); export const changePrincipalStatusAction = defineAction( { @@ -64,7 +69,10 @@ export const changePrincipalStatusAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.change-principal-status', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts b/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts index dcc74c771..69f36af8a 100644 --- a/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts +++ b/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts @@ -2,8 +2,11 @@ // @ontos-action-owner core.modules // @ontos-action-slug change-tenant-module-state import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { InstalledModuleCatalogService } from '../catalog.ts'; +import { OntosModuleIdSchema } from '../manifest.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; import { TenantModuleStateConcurrentChangeError, @@ -15,6 +18,7 @@ import { TenantModuleStateUnsupportedStateError, TenantModuleStateValidationUnavailableError, } from '../tenant-module-state-errors.ts'; +import type { TenantModuleStateTransitionError } from '../tenant-module-state-errors.ts'; import { TenantModuleStateSchema, persistTenantModuleStateChange, @@ -24,16 +28,8 @@ import type { PersistTenantModuleStateChangeInput, PersistTenantModuleStateChangeResult, } from '../tenant-module-state-service.ts'; -import type { TenantModuleStateTransitionError } from '../tenant-module-state-errors.ts'; -import { InstalledModuleCatalogService } from '../catalog.ts'; -import { OntosModuleIdSchema } from '../manifest.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -50,9 +46,7 @@ const ChangeTenantModuleStatePayloadSchema = Schema.Struct({ newState: TenantModuleStateSchema, reason: Schema.optionalKey(reasonSchema), }); -export type ChangeTenantModuleStatePayload = Schema.Schema.Type< - typeof ChangeTenantModuleStatePayloadSchema ->; +export type ChangeTenantModuleStatePayload = Schema.Schema.Type; const ChangeTenantModuleStateResultSchema = Schema.Struct({ moduleKey: moduleKeySchema, @@ -71,9 +65,7 @@ const ChangeTenantModuleStateError = Schema.Union([ TenantModuleStateValidationUnavailableError, ]); -type ChangeTenantModuleStateDomainEvents = Readonly< - Record> ->; +type ChangeTenantModuleStateDomainEvents = Readonly>>; interface ChangeTenantModuleStateServices { readonly persist: ( @@ -81,55 +73,52 @@ interface ChangeTenantModuleStateServices { ) => Effect.Effect; } -const handleChangeTenantModuleState = Effect.fn( - 'ChangeTenantModuleStateAction.handleChangeTenantModuleState', -)(function* changeTenantModuleStateHandler( - payload: ChangeTenantModuleStatePayload, - context: ActionHandlerContext< - ChangeTenantModuleStateDomainEvents, - ChangeTenantModuleStateServices - >, -) { - const installedCatalog = yield* InstalledModuleCatalogService; - const catalog = yield* installedCatalog.load; - yield* validateTenantModuleStateTransition(catalog, payload.moduleKey, payload.newState); - const result = yield* context.services.persist( - withOptionalProperty( +const handleChangeTenantModuleState = Effect.fn('ChangeTenantModuleStateAction.handleChangeTenantModuleState')( + function* changeTenantModuleStateHandler( + payload: ChangeTenantModuleStatePayload, + context: ActionHandlerContext, + ) { + const installedCatalog = yield* InstalledModuleCatalogService; + const catalog = yield* installedCatalog.load; + yield* validateTenantModuleStateTransition(catalog, payload.moduleKey, payload.newState); + const result = yield* context.services.persist( withOptionalProperty( + withOptionalProperty( + { + actionInvocationId: context.actionInvocationId, + authMethod: context.scope.authMethod, + }, + payload.expectedState !== undefined, + 'expectedState', + payload.expectedState, + { + moduleKey: payload.moduleKey, + newState: payload.newState, + principalId: context.scope.principalId, + }, + ), + payload.reason !== undefined, + 'reason', + payload.reason, { - actionInvocationId: context.actionInvocationId, - authMethod: context.scope.authMethod, - }, - payload.expectedState !== undefined, - 'expectedState', - payload.expectedState, - { - moduleKey: payload.moduleKey, - newState: payload.newState, - principalId: context.scope.principalId, + tenantId: context.scope.tenantId, }, ), - payload.reason !== undefined, - 'reason', - payload.reason, - { - tenantId: context.scope.tenantId, - }, - ), - ); + ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `tenant-module-state-prior:${payload.moduleKey}`, - resultCount: result.previousState === null ? 0 : 1, - servingModuleKey: 'core.modules', - targetModuleKey: payload.moduleKey, - targetResourceId: payload.moduleKey, - targetResourceType: 'tenant-module-state', - }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `tenant-module-state-prior:${payload.moduleKey}`, + resultCount: result.previousState === null ? 0 : 1, + servingModuleKey: 'core.modules', + targetModuleKey: payload.moduleKey, + targetResourceId: payload.moduleKey, + targetResourceType: 'tenant-module-state', + }); - return result; -}); + return result; + }, +); export const changeTenantModuleStateAction = defineAction( { @@ -143,7 +132,10 @@ export const changeTenantModuleStateAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.modules.change-tenant-module-state', moduleKey: 'core.modules', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts index 4a950f315..383adc591 100644 --- a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts +++ b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts @@ -2,11 +2,12 @@ // @ontos-action-owner core.identity // @ontos-action-slug create-non-human-principal import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const uuid = Schema.String.check(Schema.isUUID()); @@ -16,9 +17,7 @@ const CreateNonHumanPrincipalPayloadSchema = Schema.Struct({ displayName, kind: Schema.Literals(['service', 'integration', 'system']), }); -export type CreateNonHumanPrincipalPayload = Schema.Schema.Type< - typeof CreateNonHumanPrincipalPayloadSchema ->; +export type CreateNonHumanPrincipalPayload = Schema.Schema.Type; const CreateNonHumanPrincipalResultSchema = Schema.Struct({ principalId: PrincipalIdSchema, status: Schema.Literal('active'), @@ -28,7 +27,9 @@ const handle = ( payload: CreateNonHumanPrincipalPayload, context: ActionHandlerContext< Readonly>, - { readonly create: PrincipalManagementRepositoryService['createNonHumanPrincipal'] } + { + readonly create: PrincipalManagementRepositoryService['createNonHumanPrincipal']; + } >, ) => context.services.create({ ...payload, tenantId: context.scope.tenantId }).pipe( @@ -50,7 +51,10 @@ export const createNonHumanPrincipalAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.create-non-human-principal', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts b/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts index 827e0ab50..8ff7ed304 100644 --- a/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts +++ b/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts @@ -2,14 +2,12 @@ // @ontos-action-owner core.identity // @ontos-action-slug record-support-impersonation import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { IdentityTargetInvalidError, PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { - IdentityTargetInvalidError, - PrincipalManagementErrorSchema, -} from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const PrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PrincipalId')); @@ -24,87 +22,85 @@ const checkpointFields = { targetPrincipalId: PrincipalIdSchema, }; const RecordSupportImpersonationPayloadSchema = Schema.Union([ - Schema.Struct({ ...checkpointFields, checkpoint: Schema.Literal('requested') }), + Schema.Struct({ + ...checkpointFields, + checkpoint: Schema.Literal('requested'), + }), Schema.Struct({ ...checkpointFields, checkpoint: Schema.Literals(['started', 'stopped']), sessionRef: safeSessionRef, }), ]); -export type RecordSupportImpersonationPayload = Schema.Schema.Type< - typeof RecordSupportImpersonationPayloadSchema ->; +export type RecordSupportImpersonationPayload = Schema.Schema.Type; const RecordSupportImpersonationResultSchema = Schema.Struct({ checkpoint: Schema.Literals(['requested', 'started', 'stopped']), recorded: Schema.Literal(true), }); -type ValidateSupportImpersonation = - PrincipalManagementRepositoryService['validateSupportImpersonation']; -const handle = Effect.fn('RecordSupportImpersonationAction.handle')( - function* recordSupportImpersonationActionHandle( - payload: RecordSupportImpersonationPayload, - context: ActionHandlerContext< - Readonly>, - { - readonly validate: ( - input: Parameters[0], - ) => ReturnType; - } - >, - ) { - if ( - payload.originalPrincipalId !== context.scope.principalId || - payload.targetPrincipalId === payload.originalPrincipalId || - context.scope.authMethod !== 'session' || - context.scope.authBindingId === undefined - ) { - return yield* new IdentityTargetInvalidError({ - code: 'identity_target_invalid', - reason: 'The impersonation checkpoint is invalid', - }); +type ValidateSupportImpersonation = PrincipalManagementRepositoryService['validateSupportImpersonation']; +const handle = Effect.fn('RecordSupportImpersonationAction.handle')(function* recordSupportImpersonationActionHandle( + payload: RecordSupportImpersonationPayload, + context: ActionHandlerContext< + Readonly>, + { + readonly validate: ( + input: Parameters[0], + ) => ReturnType; } - yield* context.services.validate({ - checkpoint: payload.checkpoint, - originalAuthBindingId: context.scope.authBindingId, - originalPrincipalId: payload.originalPrincipalId, - targetPrincipalId: payload.targetPrincipalId, - tenantId: context.scope.tenantId, - }); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `support-original-eligibility:${payload.originalPrincipalId}`, - resultCount: 1, - servingModuleKey: 'core.identity', - targetModuleKey: 'core.identity', - targetResourceId: payload.originalPrincipalId, - targetResourceType: 'principal', - }); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `support-target-eligibility:${payload.targetPrincipalId}`, - resultCount: 1, - servingModuleKey: 'core.identity', - targetModuleKey: 'core.identity', - targetResourceId: payload.targetPrincipalId, - targetResourceType: 'principal', + >, +) { + if ( + payload.originalPrincipalId !== context.scope.principalId || + payload.targetPrincipalId === payload.originalPrincipalId || + context.scope.authMethod !== 'session' || + context.scope.authBindingId === undefined + ) { + return yield* new IdentityTargetInvalidError({ + code: 'identity_target_invalid', + reason: 'The impersonation checkpoint is invalid', }); - yield* payload.checkpoint === 'requested' - ? context.recordAuditEvidence({ - checkpoint: payload.checkpoint, - originalPrincipalId: payload.originalPrincipalId, - reason: payload.reason, - targetPrincipalId: payload.targetPrincipalId, - }) - : context.recordAuditEvidence({ - checkpoint: payload.checkpoint, - originalPrincipalId: payload.originalPrincipalId, - reason: payload.reason, - sessionRef: payload.sessionRef, - targetPrincipalId: payload.targetPrincipalId, - }); - return { checkpoint: payload.checkpoint, recorded: true as const }; - }, -); + } + yield* context.services.validate({ + checkpoint: payload.checkpoint, + originalAuthBindingId: context.scope.authBindingId, + originalPrincipalId: payload.originalPrincipalId, + targetPrincipalId: payload.targetPrincipalId, + tenantId: context.scope.tenantId, + }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `support-original-eligibility:${payload.originalPrincipalId}`, + resultCount: 1, + servingModuleKey: 'core.identity', + targetModuleKey: 'core.identity', + targetResourceId: payload.originalPrincipalId, + targetResourceType: 'principal', + }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `support-target-eligibility:${payload.targetPrincipalId}`, + resultCount: 1, + servingModuleKey: 'core.identity', + targetModuleKey: 'core.identity', + targetResourceId: payload.targetPrincipalId, + targetResourceType: 'principal', + }); + yield* payload.checkpoint === 'requested' + ? context.recordAuditEvidence({ + checkpoint: payload.checkpoint, + originalPrincipalId: payload.originalPrincipalId, + reason: payload.reason, + targetPrincipalId: payload.targetPrincipalId, + }) + : context.recordAuditEvidence({ + checkpoint: payload.checkpoint, + originalPrincipalId: payload.originalPrincipalId, + reason: payload.reason, + sessionRef: payload.sessionRef, + targetPrincipalId: payload.targetPrincipalId, + }); + return { checkpoint: payload.checkpoint, recorded: true as const }; +}); export const recordSupportImpersonationAction = defineAction( { accessEvidencePolicy: { @@ -118,7 +114,10 @@ export const recordSupportImpersonationAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.record-support-impersonation', moduleKey: 'core.identity', role: 'action', @@ -135,6 +134,8 @@ export const recordSupportImpersonationAction = defineAction( handle, (transaction) => { const repository = principalManagementRepositoryFromTransaction(transaction); - return Effect.succeed({ validate: repository.validateSupportImpersonation }); + return Effect.succeed({ + validate: repository.validateSupportImpersonation, + }); }, ); diff --git a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts index 66382e9f3..b3d965b5f 100644 --- a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts @@ -2,11 +2,12 @@ // @ontos-action-owner core.identity // @ontos-action-slug set-managed-api-key-binding-status import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const uuid = Schema.String.check(Schema.isUUID()); @@ -42,7 +43,9 @@ const handle = Effect.fn('SetManagedApiKeyBindingStatusAction.handle')( payload: SetManagedApiKeyBindingStatusPayload, context: ActionHandlerContext< Readonly>, - { readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus'] } + { + readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus']; + } >, ) { const result = yield* context.services.setStatus({ @@ -74,7 +77,10 @@ export const setManagedApiKeyBindingStatusAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.set-managed-api-key-binding-status', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts index 22080e8c6..3fc134e37 100644 --- a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts @@ -2,16 +2,15 @@ // @ontos-action-owner core.identity // @ontos-action-slug set-self-api-key-binding-status import { Effect, Schema } from 'effect'; + import type { ActionHandlerContext } from '../../actions/context.ts'; import { defineAction } from '../../actions/definition.ts'; +import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { principalManagementRepositoryFromTransaction } from '../../auth/principal-management.ts'; import type { PrincipalManagementRepositoryService } from '../../auth/principal-management.ts'; -import { PrincipalManagementErrorSchema } from '../../auth/principal-management-errors.ts'; import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; -const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('AuthBindingId'), -); +const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('AuthBindingId')); const status = Schema.Literals(['active', 'disabled', 'revoked']); const reason = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); const SetSelfApiKeyBindingStatusPayloadSchema = Schema.Union([ @@ -28,39 +27,37 @@ const SetSelfApiKeyBindingStatusPayloadSchema = Schema.Union([ reason, }), ]); -export type SetSelfApiKeyBindingStatusPayload = Schema.Schema.Type< - typeof SetSelfApiKeyBindingStatusPayloadSchema ->; +export type SetSelfApiKeyBindingStatusPayload = Schema.Schema.Type; const SetSelfApiKeyBindingStatusResultSchema = Schema.Struct({ previousStatus: status, status, }); -const handle = Effect.fn('SetSelfApiKeyBindingStatusAction.handle')( - function* setSelfApiKeyBindingStatusActionHandle( - payload: SetSelfApiKeyBindingStatusPayload, - context: ActionHandlerContext< - Readonly>, - { readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus'] } - >, - ) { - const result = yield* context.services.setStatus({ - ...payload, - managed: false, - principalId: context.scope.principalId, - tenantId: context.scope.tenantId, - }); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `api-key-binding-prior:${payload.authBindingId}`, - resultCount: 1, - servingModuleKey: 'core.identity', - targetModuleKey: 'core.identity', - targetResourceId: payload.authBindingId, - targetResourceType: 'principal-auth-binding', - }); - return result; - }, -); +const handle = Effect.fn('SetSelfApiKeyBindingStatusAction.handle')(function* setSelfApiKeyBindingStatusActionHandle( + payload: SetSelfApiKeyBindingStatusPayload, + context: ActionHandlerContext< + Readonly>, + { + readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus']; + } + >, +) { + const result = yield* context.services.setStatus({ + ...payload, + managed: false, + principalId: context.scope.principalId, + tenantId: context.scope.tenantId, + }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `api-key-binding-prior:${payload.authBindingId}`, + resultCount: 1, + servingModuleKey: 'core.identity', + targetModuleKey: 'core.identity', + targetResourceId: payload.authBindingId, + targetResourceType: 'principal-auth-binding', + }); + return result; +}); export const setSelfApiKeyBindingStatusAction = defineAction( { accessEvidencePolicy: { @@ -73,7 +70,10 @@ export const setSelfApiKeyBindingStatusAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.identity.set-self-api-key-binding-status', moduleKey: 'core.identity', role: 'action', diff --git a/app/packages/core-runtime/src/modules/application-composition.ts b/app/packages/core-runtime/src/modules/application-composition.ts index 57b4c0aa0..9afdc0028 100644 --- a/app/packages/core-runtime/src/modules/application-composition.ts +++ b/app/packages/core-runtime/src/modules/application-composition.ts @@ -1,9 +1,6 @@ import { Effect, Order, Predicate, Result, Schema } from 'effect'; -import { - OntosComponentContractSchema, - OntosDeploymentIdentitySchema, - OntosModuleIdSchema, -} from './manifest.ts'; + +import { OntosComponentContractSchema, OntosDeploymentIdentitySchema, OntosModuleIdSchema } from './manifest.ts'; export const ONTOS_APPLICATION_COMPOSITION_SCHEMA_VERSION = '1' as const; @@ -79,8 +76,7 @@ export const ApplicationCompositionSchema = Schema.Struct({ export type ApplicationComposition = typeof ApplicationCompositionSchema.Type; export type ApplicationCompositionModule = typeof ApplicationCompositionModuleSchema.Type; -export type ApplicationCompositionVersionedIdentity = - typeof ApplicationCompositionVersionedIdentitySchema.Type; +export type ApplicationCompositionVersionedIdentity = typeof ApplicationCompositionVersionedIdentitySchema.Type; const observedContractSchema = Schema.Struct({ contractUrl: artifactUrl, @@ -117,22 +113,16 @@ export class ApplicationCompositionValidationError extends Schema.TaggedError - `${identity.id}@${identity.version}`; +const identityKey = (identity: ApplicationCompositionVersionedIdentity): string => `${identity.id}@${identity.version}`; const identityOrder = Order.mapInput(Order.String, identityKey); -const moduleOrder = Order.mapInput( - Order.String, - (module: ApplicationCompositionModule) => module.moduleId, -); +const moduleOrder = Order.mapInput(Order.String, (module: ApplicationCompositionModule) => module.moduleId); const singletonOrder = Order.Struct({ packageName: Order.String, version: Order.String, }); const sameDeployment = Schema.toEquivalence(OntosDeploymentIdentitySchema); -const samePublicContract = Schema.toEquivalence( - ApplicationCompositionModuleSchema.fields.publicContract, -); +const samePublicContract = Schema.toEquivalence(ApplicationCompositionModuleSchema.fields.publicContract); const sameUniqueStrings = (left: readonly string[], right: readonly string[]): boolean => { const leftSet = new Set(left); @@ -160,11 +150,7 @@ const sameVersionClaims = ( ); }; -const claim = Effect.fnUntraced(function* claimUnique( - claims: Set, - value: string, - label: string, -) { +const claim = Effect.fnUntraced(function* claimUnique(claims: Set, value: string, label: string) { if (claims.has(value)) { return yield* new ApplicationCompositionValidationError({ reason: `duplicate ${label} ${value}`, @@ -180,25 +166,26 @@ const assertAcyclicDependencies = Effect.fnUntraced(function* checkCycles( const dependencies = new Map(modules.map((module) => [module.moduleId, module.dependencies])); const visiting = new Set(); const visited = new Set(); - const visit: (moduleId: string) => Effect.Effect = - Effect.fn('assertAcyclicDependencies.visit')(function* visitDependency(moduleId) { - if (visiting.has(moduleId)) { - return yield* new ApplicationCompositionValidationError({ - reason: `dependency cycle includes module ${moduleId}`, - }); - } - if (visited.has(moduleId)) { - return yield* Effect.void; - } - visiting.add(moduleId); - yield* Effect.forEach(dependencies.get(moduleId) ?? [], visit, { - concurrency: 1, - discard: true, + const visit: (moduleId: string) => Effect.Effect = Effect.fn( + 'assertAcyclicDependencies.visit', + )(function* visitDependency(moduleId) { + if (visiting.has(moduleId)) { + return yield* new ApplicationCompositionValidationError({ + reason: `dependency cycle includes module ${moduleId}`, }); - visiting.delete(moduleId); - visited.add(moduleId); + } + if (visited.has(moduleId)) { return yield* Effect.void; + } + visiting.add(moduleId); + yield* Effect.forEach(dependencies.get(moduleId) ?? [], visit, { + concurrency: 1, + discard: true, }); + visiting.delete(moduleId); + visited.add(moduleId); + return yield* Effect.void; + }); yield* Effect.forEach(modules, ({ moduleId }) => visit(moduleId), { concurrency: 1, discard: true, @@ -317,11 +304,7 @@ const assertObservedFederationManifest = Effect.fnUntraced(function* checkFedera manifest.remoteName !== module.federation.remoteName || manifest.sha256 !== module.federation.manifest.sha256 || !sameUniqueStrings(module.federation.exposes, manifest.exposes) || - !sameVersionClaims( - module.sharedSingletons, - manifest.sharedSingletons, - ({ packageName }) => packageName, - ) + !sameVersionClaims(module.sharedSingletons, manifest.sharedSingletons, ({ packageName }) => packageName) ) { return yield* new ApplicationCompositionValidationError({ reason: `module ${module.moduleId} does not match its observed Module Federation manifest`, @@ -337,11 +320,7 @@ const assertObservedRuntime = Effect.fnUntraced(function* checkRuntime( if ( identityKey(shell.contributionAbi) !== identityKey(runtime.contributionAbi) || !sameVersionClaims(shell.coreCapabilities, runtime.coreCapabilities, ({ id }) => id) || - !sameVersionClaims( - shell.sharedSingletons, - runtime.sharedSingletons, - ({ packageName }) => packageName, - ) + !sameVersionClaims(shell.sharedSingletons, runtime.sharedSingletons, ({ packageName }) => packageName) ) { return yield* new ApplicationCompositionValidationError({ reason: 'Shell and Core claims do not match the observed runtime contract', @@ -393,7 +372,7 @@ export const validateApplicationCompositionCandidate = Effect.fnUntraced(functio ), ), ); - const observed = yield* Schema.decodeUnknownEffect(candidateEvidenceSchema)(evidence).pipe( + const observed = yield* Schema.decodeEffect(candidateEvidenceSchema)(evidence).pipe( Effect.catchTag('SchemaError', () => Effect.fail( new ApplicationCompositionValidationError({ @@ -452,12 +431,7 @@ export const validateApplicationCompositionCandidate = Effect.fnUntraced(functio { concurrency: 1, discard: true }, ); yield* assertDependenciesPresent(module, moduleIds); - yield* assertShellCompatibility( - module, - composition.shell, - shellCapabilities, - shellSingletons, - ); + yield* assertShellCompatibility(module, composition.shell, shellCapabilities, shellSingletons); yield* assertObservedDeployment(module, observed.contracts[module.deployment.appId]); yield* assertObservedFederationManifest(module, observed.federationManifests[manifestUrl]); return yield* Effect.void; diff --git a/app/packages/core-runtime/src/modules/catalog.ts b/app/packages/core-runtime/src/modules/catalog.ts index 1b3553d91..14edf502b 100644 --- a/app/packages/core-runtime/src/modules/catalog.ts +++ b/app/packages/core-runtime/src/modules/catalog.ts @@ -1,5 +1,6 @@ import { Context, Schema } from 'effect'; import type { Cause, Effect } from 'effect'; + import type { OntosDeploymentAppId, OntosModuleDeploymentContract, @@ -10,23 +11,20 @@ import { decodeOntosModuleDeploymentContract } from './manifest.ts'; import { validateShellContributions } from './shell-contribution.ts'; import type { TenantModuleStateValidationUnavailableError } from './tenant-module-state-errors.ts'; -const OntosModuleCatalogValidationErrorContract = Schema.TaggedStruct( +const OntosModuleCatalogValidationErrorContract = Schema.TaggedStruct('OntosModuleCatalogValidationError', { + code: Schema.Literal('ontos_module_catalog_invalid'), + reason: Schema.String, +}); +type OntosModuleCatalogValidationErrorSelf = typeof OntosModuleCatalogValidationErrorContract.Type & + Cause.YieldableError; +const OntosModuleCatalogValidationErrorValue = Schema.TaggedError()( 'OntosModuleCatalogValidationError', { code: Schema.Literal('ontos_module_catalog_invalid'), reason: Schema.String, }, ); -type OntosModuleCatalogValidationErrorSelf = typeof OntosModuleCatalogValidationErrorContract.Type & - Cause.YieldableError; -const OntosModuleCatalogValidationErrorValue = - Schema.TaggedError()('OntosModuleCatalogValidationError', { - code: Schema.Literal('ontos_module_catalog_invalid'), - reason: Schema.String, - }); -export type OntosModuleCatalogValidationError = InstanceType< - typeof OntosModuleCatalogValidationErrorValue ->; +export type OntosModuleCatalogValidationError = InstanceType; export { OntosModuleCatalogValidationErrorValue as OntosModuleCatalogValidationError }; export interface InstalledDeploymentContractInput { @@ -34,11 +32,7 @@ export interface InstalledDeploymentContractInput { readonly expectedAppId: OntosDeploymentAppId; } -const InstalledDeploymentFailureReasonSchema = Schema.Literals([ - 'incompatible', - 'timeout', - 'unavailable', -]); +const InstalledDeploymentFailureReasonSchema = Schema.Literals(['incompatible', 'timeout', 'unavailable']); export type InstalledDeploymentFailureReason = typeof InstalledDeploymentFailureReasonSchema.Type; export type InstalledDeploymentStatus = @@ -78,9 +72,7 @@ export interface InstalledModuleCatalog { readonly contracts: readonly OntosModuleDeploymentContract[]; readonly deploymentAppIds: readonly OntosDeploymentAppId[]; readonly deploymentStatuses: readonly InstalledDeploymentStatus[]; - readonly getByDeploymentAppId: ( - appId: OntosDeploymentAppId, - ) => OntosModuleDeploymentContract | undefined; + readonly getByDeploymentAppId: (appId: OntosDeploymentAppId) => OntosModuleDeploymentContract | undefined; readonly getByModuleId: (moduleId: OntosModuleId) => OntosModuleDeploymentContract | undefined; readonly moduleIds: readonly OntosModuleId[]; readonly outboxSubscriptions: readonly OntosOutboxSubscriptionContract[]; @@ -155,28 +147,30 @@ const assembleInstalledModuleCatalog = ( contractsInput: readonly OntosModuleDeploymentContract[], deploymentStatuses: readonly InstalledDeploymentStatus[], ): InstalledModuleCatalog => { - const byAppId = new Map( - contractsInput.map((contract) => [contract.deployment.appId, contract] as const), - ); - const byModuleId = new Map( - contractsInput.map((contract) => [contract.manifest.module.id, contract] as const), - ); + const byAppId = new Map(contractsInput.map((contract) => [contract.deployment.appId, contract] as const)); + const byModuleId = new Map(contractsInput.map((contract) => [contract.manifest.module.id, contract] as const)); const outboxSubscriptions = Object.freeze( contractsInput - .flatMap(({ runtime }) => runtime.outboxSubscriptions) + .flatMap(({ runtime }) => + runtime.outboxSubscriptions.map((subscription) => + Object.freeze({ + ...subscription, + entrypoint: Object.freeze({ + ...subscription.entrypoint, + authorization: Object.freeze({ + ...subscription.entrypoint.authorization, + }), + }), + }), + ), + ) .toSorted((left, right) => left.workerKey.localeCompare(right.workerKey)), ); const contracts = Object.freeze( - [...contractsInput].toSorted((left, right) => - left.manifest.module.id.localeCompare(right.manifest.module.id), - ), - ); - const deploymentAppIds = Object.freeze( - [...byAppId.keys()].toSorted((left, right) => left.localeCompare(right)), - ); - const moduleIds = Object.freeze( - [...byModuleId.keys()].toSorted((left, right) => left.localeCompare(right)), + [...contractsInput].toSorted((left, right) => left.manifest.module.id.localeCompare(right.manifest.module.id)), ); + const deploymentAppIds = Object.freeze([...byAppId.keys()].toSorted((left, right) => left.localeCompare(right))); + const moduleIds = Object.freeze([...byModuleId.keys()].toSorted((left, right) => left.localeCompare(right))); return Object.freeze({ contracts, deploymentAppIds, @@ -198,12 +192,15 @@ const collectAuthoritativeDeploymentStatuses = ( const statuses = new Map(); for (const input of inputs) { if (input.outcome === 'revoked') { - statuses.set(input.expectedAppId, { appId: input.expectedAppId, status: 'revoked' }); - } else if ( - input.outcome === 'disabled' && - statuses.get(input.expectedAppId)?.status !== 'revoked' - ) { - statuses.set(input.expectedAppId, { appId: input.expectedAppId, status: 'disabled' }); + statuses.set(input.expectedAppId, { + appId: input.expectedAppId, + status: 'revoked', + }); + } else if (input.outcome === 'disabled' && statuses.get(input.expectedAppId)?.status !== 'revoked') { + statuses.set(input.expectedAppId, { + appId: input.expectedAppId, + status: 'disabled', + }); } } return statuses; @@ -331,9 +328,7 @@ export const resolveInstalledModuleCatalog = ( const candidates = collectDeploymentCandidates(inputs, authoritativeStatuses, statuses); const conflictingAppIds = findConflictingDeploymentAppIds(candidates); - const healthy = candidates.filter( - (contract) => !conflictingAppIds.has(contract.deployment.appId), - ); + const healthy = candidates.filter((contract) => !conflictingAppIds.has(contract.deployment.appId)); for (const contract of candidates) { const { deployment: { appId }, diff --git a/app/packages/core-runtime/src/modules/manifest.ts b/app/packages/core-runtime/src/modules/manifest.ts index f694d0b26..c48c5a81b 100644 --- a/app/packages/core-runtime/src/modules/manifest.ts +++ b/app/packages/core-runtime/src/modules/manifest.ts @@ -1,5 +1,6 @@ import { Predicate, Result, Schema } from 'effect'; import { HttpApi } from 'effect/unstable/httpapi'; + import type { AnyActionRegistration } from '../actions/definition.ts'; import { isActionRegistration } from '../actions/definition.ts'; import { TENANT_PERMISSION_KEYS } from '../permissions/context-access.ts'; @@ -12,24 +13,21 @@ export const ONTOS_MODULE_CONTRACT_PATH = '/.well-known/ontos-module-manifest.js export const ONTOS_MODULE_CONTRACT_MAX_BYTES = 1024 * 1024; export const ONTOS_MODULE_CONTRACT_TIMEOUT_MS = 5000; -const dottedIdentifierPattern = - /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; +const dottedIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; const deploymentIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const moduleFederationBoundaryPattern = /^[A-Za-z][A-Za-z0-9]*$/u; const schemaVersionPattern = /^[0-9]+$/u; const nonEmptyString = Schema.String.check(Schema.isMinLength(1)); -export const OntosModuleIdSchema = Schema.String.check( - Schema.isPattern(dottedIdentifierPattern), -).pipe(Schema.brand('OntosModuleId'), Schema.decodeTo(Schema.String)); -export const OntosDeploymentAppIdSchema = Schema.String.check( - Schema.isPattern(deploymentIdPattern), -).pipe(Schema.brand('OntosDeploymentAppId'), Schema.decodeTo(Schema.String)); -export const OntosModuleKindSchema = Schema.Literals([ - 'business_module', - 'foundational_module', - 'system_module', -]); +export const OntosModuleIdSchema = Schema.String.check(Schema.isPattern(dottedIdentifierPattern)).pipe( + Schema.brand('OntosModuleId'), + Schema.decodeTo(Schema.String), +); +export const OntosDeploymentAppIdSchema = Schema.String.check(Schema.isPattern(deploymentIdPattern)).pipe( + Schema.brand('OntosDeploymentAppId'), + Schema.decodeTo(Schema.String), +); +export const OntosModuleKindSchema = Schema.Literals(['business_module', 'foundational_module', 'system_module']); export const OntosModuleActivationStateSchema = Schema.Literals([ 'inactive', 'active', @@ -44,15 +42,8 @@ export type OntosDeploymentAppId = typeof OntosDeploymentAppIdSchema.Type; export type OntosModuleKind = typeof OntosModuleKindSchema.Type; export type OntosModuleActivationState = typeof OntosModuleActivationStateSchema.Type; -const OntosAccessFilteringSchema = Schema.Literals([ - 'legal_entity_scope', - 'resource_permission', - 'tenant_scope', -]); -const OntosOperationKeySchema = nonEmptyString.pipe( - Schema.brand('OntosOperationKey'), - Schema.decodeTo(Schema.String), -); +const OntosAccessFilteringSchema = Schema.Literals(['legal_entity_scope', 'resource_permission', 'tenant_scope']); +const OntosOperationKeySchema = nonEmptyString.pipe(Schema.brand('OntosOperationKey'), Schema.decodeTo(Schema.String)); const OntosModuleFederationBoundaryIdSchema = Schema.String.check( Schema.isPattern(moduleFederationBoundaryPattern), ).pipe(Schema.brand('OntosModuleFederationBoundaryId'), Schema.decodeTo(Schema.String)); @@ -138,7 +129,9 @@ export const OntosOutboxSubscriptionContractSchema = Schema.Struct({ consumerModuleKey: OntosModuleIdSchema, entrypoint: Schema.Struct({ access: Schema.Literal('background'), - authorization: Schema.Struct({ kind: Schema.Literal('owner_local_background') }), + authorization: Schema.Struct({ + kind: Schema.Literal('owner_local_background'), + }), entrypointKey: OntosModuleIdSchema, moduleKey: OntosModuleIdSchema, role: Schema.Literal('worker'), @@ -221,8 +214,7 @@ export interface OntosModuleManifestInput { readonly publicSurface: OntosAuthoredPublicSurface; } -export type OntosModuleManifest = - Readonly; +export type OntosModuleManifest = Readonly; class OntosModuleManifestValidationError extends Schema.TaggedError()( 'OntosModuleManifestValidationError', @@ -232,17 +224,10 @@ class OntosModuleManifestValidationError extends Schema.TaggedError new OntosModuleManifestValidationError({ message }); -const exactDecode = , Value>( - schema: S, - value: Value, -): S['Type'] => +const exactDecode = , Value>(schema: S, value: Value): S['Type'] => Result.getOrThrow(Schema.decodeUnknownResult(schema, { onExcessProperty: 'error' })(value)); -const assertExactKeys = ( - value: Value, - keys: readonly string[], - label: string, -): void => { +const assertExactKeys = (value: Value, keys: readonly string[], label: string): void => { const allowed = new Set(keys); for (const key of Reflect.ownKeys(value)) { if (!Predicate.isString(key) || !allowed.has(key)) { @@ -283,36 +268,19 @@ const assertOwner = (owner: string, expected: string, label: string): void => { } }; -export const validateOntosModuleManifestFields = < - Input extends object, - PublicSurface extends object, ->( +export const validateOntosModuleManifestFields = ( input: Input, publicSurface: PublicSurface, ): void => { assertExactKeys(input, ['activation', 'module', 'publicSurface'], 'manifest'); assertExactKeys( publicSurface, - [ - 'actions', - 'api', - 'components', - 'events', - 'reports', - 'resourceTypes', - 'search', - 'shellContributions', - ], + ['actions', 'api', 'components', 'events', 'reports', 'resourceTypes', 'search', 'shellContributions'], 'manifest public surface', ); }; -export const validateOntosModuleExecutableReferences = < - ActionValue, - ApiValue, - ComponentValue, - EventPayloadSchema, ->( +export const validateOntosModuleExecutableReferences = ( actions: readonly ActionValue[], apiValues: readonly ApiValue[], componentValues: readonly ComponentValue[], @@ -346,17 +314,10 @@ const validateSearchDescriptorReferences = ( ): void => { assertOwner(descriptor.owningModuleId, moduleId, 'search descriptor'); if (!resourceSet.has(descriptor.resourceType)) { - throw invalidManifest( - `search descriptor references undeclared resource type ${descriptor.resourceType}`, - ); + throw invalidManifest(`search descriptor references undeclared resource type ${descriptor.resourceType}`); } - if ( - (descriptor.accessFiltering === 'tenant_scope') !== - (descriptor.tenantPermission !== undefined) - ) { - throw invalidManifest( - 'tenant-scoped search requires exactly one explicit Tenant permission declaration', - ); + if ((descriptor.accessFiltering === 'tenant_scope') !== (descriptor.tenantPermission !== undefined)) { + throw invalidManifest('tenant-scoped search requires exactly one explicit Tenant permission declaration'); } if ( descriptor.requestFilters !== undefined && @@ -394,9 +355,7 @@ export const defineOntosModuleManifest = descriptor.actionKey); assertUnique(actionKeys, 'Action key'); - const resources = input.publicSurface.resourceTypes.map((resource) => - exactDecode(OntosResourceTypeSchema, resource), - ); + const resources = input.publicSurface.resourceTypes.map((resource) => exactDecode(OntosResourceTypeSchema, resource)); const resourceKeys = resources.map(({ key }) => key); assertUnique(resourceKeys, 'resource type key'); for (const resource of resources) { @@ -434,9 +393,7 @@ export const defineOntosModuleManifest = - exactDecode(OntosSearchDescriptorSchema, descriptor), - ); + const search = input.publicSurface.search.map((descriptor) => exactDecode(OntosSearchDescriptorSchema, descriptor)); assertUnique( search.map(({ key }) => key), 'search descriptor key', @@ -445,9 +402,7 @@ export const defineOntosModuleManifest = - exactDecode(OntosReportDescriptorSchema, descriptor), - ); + const reports = input.publicSurface.reports.map((descriptor) => exactDecode(OntosReportDescriptorSchema, descriptor)); assertUnique( reports.map(({ key }) => key), 'report descriptor key', @@ -456,21 +411,15 @@ export const defineOntosModuleManifest = `${input.module.id}.${key}`), - ); - const apiKeys = new Set( - Object.keys(input.publicSurface.api).map((key) => `${input.module.id}.${key}`), - ); + const componentKeys = new Set(Object.keys(input.publicSurface.components).map((key) => `${input.module.id}.${key}`)); + const apiKeys = new Set(Object.keys(input.publicSurface.api).map((key) => `${input.module.id}.${key}`)); const shellContributions = validateShellContributions(input.publicSurface.shellContributions, { actionKeys: new Set(actionKeys), apiKeys, @@ -504,9 +453,7 @@ export const defineOntosModuleManifest = - freezePlain({ ...value, capabilities: { ...value.capabilities } }), - ), + resources.map((value) => freezePlain({ ...value, capabilities: { ...value.capabilities } })), ), search: Object.freeze(search.map((value) => freezePlain({ ...value }))), shellContributions: freezePlain({ @@ -524,6 +471,5 @@ export const defineOntosModuleManifest = ( - value: Value, -): OntosModuleDeploymentContract => exactDecode(OntosModuleDeploymentContractSchema, value); +export const decodeOntosModuleDeploymentContract = (value: Value): OntosModuleDeploymentContract => + exactDecode(OntosModuleDeploymentContractSchema, value); diff --git a/app/packages/core-runtime/src/modules/module-entrypoint-gateway.ts b/app/packages/core-runtime/src/modules/module-entrypoint-gateway.ts index ecda0e442..e3867a7f3 100644 --- a/app/packages/core-runtime/src/modules/module-entrypoint-gateway.ts +++ b/app/packages/core-runtime/src/modules/module-entrypoint-gateway.ts @@ -1,11 +1,12 @@ import { Context, Effect, Layer } from 'effect'; + import type { TrustedPrincipalContext } from '../actions/context.ts'; import { decodeTrustedPrincipalContext } from '../auth/system-principal-context-provenance.ts'; import type { ModuleEntrypointDescriptor } from './module-entrypoint.ts'; -import { ModuleStateGate } from './module-state-gate.ts'; -import type { ModuleStateGateService, ModuleStateSnapshot } from './module-state-gate.ts'; import { ModuleStateCheckUnavailableError } from './module-state-gate-errors.ts'; import type { ModuleStateGateError } from './module-state-gate-errors.ts'; +import { ModuleStateGate } from './module-state-gate.ts'; +import type { ModuleStateGateService, ModuleStateSnapshot } from './module-state-gate.ts'; const unavailable = (cause?: unknown) => { const error = new ModuleStateCheckUnavailableError({ @@ -14,7 +15,10 @@ const unavailable = (cause?: unknown) => { }); return cause === undefined ? error - : Object.defineProperty(error, 'cause', { configurable: true, value: cause }); + : Object.defineProperty(error, 'cause', { + configurable: true, + value: cause, + }); }; export interface RunGatedModuleEntrypointInput { @@ -42,31 +46,23 @@ export interface ModuleEntrypointGatewayService { export const makeModuleEntrypointGateway = ( gate: Gate, ): ModuleEntrypointGatewayService => { - const prepareSnapshotInput = ( - context: Input, - entrypoints: readonly ModuleEntrypointDescriptor[], - ) => + const prepareSnapshotInput = (context: Input, entrypoints: readonly ModuleEntrypointDescriptor[]) => decodeTrustedPrincipalContext(context).pipe( Effect.mapError(unavailable), - Effect.flatMap((trustedContext) => - gate.prepareSnapshot(trustedContext.tenantId, entrypoints), - ), + Effect.flatMap((trustedContext) => gate.prepareSnapshot(trustedContext.tenantId, entrypoints)), ); return { check: gate.check, prepareSnapshot: prepareSnapshotInput, prepareSnapshotInput, run: (input) => - gate - .check(input.snapshot, input.entrypoint) - .pipe(Effect.andThen(input.authorize), Effect.andThen(input.load)), + gate.check(input.snapshot, input.entrypoint).pipe(Effect.andThen(input.authorize), Effect.andThen(input.load)), }; }; -export class ModuleEntrypointGateway extends Context.Service< - ModuleEntrypointGateway, - ModuleEntrypointGatewayService ->()('@app/core-runtime/modules/module-entrypoint-gateway/ModuleEntrypointGateway') {} +export class ModuleEntrypointGateway extends Context.Service()( + '@app/core-runtime/modules/module-entrypoint-gateway/ModuleEntrypointGateway', +) {} export const ModuleEntrypointGatewayLive = Layer.effect( ModuleEntrypointGateway, diff --git a/app/packages/core-runtime/src/modules/module-entrypoint.ts b/app/packages/core-runtime/src/modules/module-entrypoint.ts index 1f50bec7d..78850b3d1 100644 --- a/app/packages/core-runtime/src/modules/module-entrypoint.ts +++ b/app/packages/core-runtime/src/modules/module-entrypoint.ts @@ -1,4 +1,5 @@ import { Match, Result, Schema } from 'effect'; + import { EntrypointAuthorizationSchema } from '../authorization/entrypoint-classification.ts'; import type { EntrypointAuthorization } from '../authorization/entrypoint-classification.ts'; @@ -11,12 +12,7 @@ export const MODULE_ENTRYPOINT_ROLES = [ 'report', 'worker', ] as const; -export const MODULE_ENTRYPOINT_ACCESSES = [ - 'read', - 'historical_read', - 'write', - 'background', -] as const; +export const MODULE_ENTRYPOINT_ACCESSES = ['read', 'historical_read', 'write', 'background'] as const; export const MODULE_ENTRYPOINT_SCOPES = ['tenant', 'system'] as const; export const ModuleEntrypointRoleSchema = Schema.Literals(MODULE_ENTRYPOINT_ROLES); @@ -79,43 +75,27 @@ const roleAllowsAccess = (role: ModuleEntrypointRole, access: ModuleEntrypointAc Match.value(role).pipe( Match.when('action', () => access === 'write'), Match.when('worker', () => access === 'background'), - Match.whenOr( - 'api', - 'report', - () => access === 'read' || access === 'historical_read' || access === 'write', - ), - Match.whenOr( - 'page', - 'public_component', - 'search', - () => access === 'read' || access === 'historical_read', - ), + Match.whenOr('api', 'report', () => access === 'read' || access === 'historical_read' || access === 'write'), + Match.whenOr('page', 'public_component', 'search', () => access === 'read' || access === 'historical_read'), Match.exhaustive, ); -const ModuleEntrypointInvariantError = Schema.TaggedError()( - 'ModuleEntrypointInvariantError', - { message: Schema.String }, -); +const ModuleEntrypointInvariantError = Schema.TaggedError()('ModuleEntrypointInvariantError', { + message: Schema.String, +}); const failModuleEntrypointInvariant = (message: string): never => { throw new ModuleEntrypointInvariantError({ message }); }; -const roleAllowsAuthorization = ( - role: ModuleEntrypointRole, - authorization: EntrypointAuthorization, -): boolean => { +const roleAllowsAuthorization = (role: ModuleEntrypointRole, authorization: EntrypointAuthorization): boolean => { if (role === 'action') { return authorization.kind === 'action_execution'; } if (role === 'worker') { return authorization.kind === 'owner_local_background'; } - if ( - authorization.kind === 'action_execution' || - authorization.kind === 'owner_local_background' - ) { + if (authorization.kind === 'action_execution' || authorization.kind === 'owner_local_background') { return false; } return authorization.kind !== 'capability_issuance' || role === 'api'; @@ -135,15 +115,15 @@ const defineEntrypoint = < scope, }; const validatedDescriptor = Result.getOrThrow( - Schema.decodeUnknownResult(ModuleEntrypointSchema, { onExcessProperty: 'error' })(descriptor), + Schema.decodeUnknownResult(ModuleEntrypointSchema, { + onExcessProperty: 'error', + })(descriptor), ); if (!roleAllowsAccess(validatedDescriptor.role, validatedDescriptor.access)) { return failModuleEntrypointInvariant('Module entrypoint role and access are inconsistent'); } if (!roleAllowsAuthorization(validatedDescriptor.role, validatedDescriptor.authorization)) { - return failModuleEntrypointInvariant( - 'Module entrypoint role and authorization are inconsistent', - ); + return failModuleEntrypointInvariant('Module entrypoint role and authorization are inconsistent'); } return Object.freeze({ ...descriptor, @@ -153,7 +133,9 @@ const defineEntrypoint = < export const decodeTenantModuleEntrypoint = (input: Input): TenantModuleEntrypoint => { const descriptor = Result.getOrThrow( - Schema.decodeUnknownResult(ModuleEntrypointSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownResult(ModuleEntrypointSchema, { + onExcessProperty: 'error', + })({ ...input, scope: 'tenant', }), diff --git a/app/packages/core-runtime/src/modules/module-state-check-unavailable-error.ts b/app/packages/core-runtime/src/modules/module-state-check-unavailable-error.ts index 656a272e2..522605158 100644 --- a/app/packages/core-runtime/src/modules/module-state-check-unavailable-error.ts +++ b/app/packages/core-runtime/src/modules/module-state-check-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class ModuleStateCheckUnavailableError extends Schema.TaggedError()( 'ModuleStateCheckUnavailableError', - { code: Schema.Literal('module_state_check_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('module_state_check_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/module-state-denied-error.ts b/app/packages/core-runtime/src/modules/module-state-denied-error.ts index d581bb942..c6f37894e 100644 --- a/app/packages/core-runtime/src/modules/module-state-denied-error.ts +++ b/app/packages/core-runtime/src/modules/module-state-denied-error.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class ModuleStateDeniedError extends Schema.TaggedError()( - 'ModuleStateDeniedError', - { code: Schema.Literal('module_state_denied'), reason: Schema.String }, -) {} +export class ModuleStateDeniedError extends Schema.TaggedError()('ModuleStateDeniedError', { + code: Schema.Literal('module_state_denied'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/modules/module-state-gate.ts b/app/packages/core-runtime/src/modules/module-state-gate.ts index 72f6adc83..0818d62e2 100644 --- a/app/packages/core-runtime/src/modules/module-state-gate.ts +++ b/app/packages/core-runtime/src/modules/module-state-gate.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { Clock, Context, Effect, Layer, Schema } from 'effect'; + import { tenantModuleStates, tenants } from '../db/schema.ts'; import type { CoreTransaction } from '../db/types.ts'; import type { @@ -8,16 +9,10 @@ import type { TenantModuleEntrypoint, } from './module-entrypoint.ts'; import type { ModuleStateGateError } from './module-state-gate-errors.ts'; -import { - ModuleStateCheckUnavailableError, - ModuleStateDeniedError, -} from './module-state-gate-errors.ts'; +import { ModuleStateCheckUnavailableError, ModuleStateDeniedError } from './module-state-gate-errors.ts'; import type { ModuleStateSnapshot } from './module-state-snapshot.ts'; import { ModuleStateSnapshotValue } from './module-state-snapshot.ts'; -import type { - TenantModuleState, - TenantModuleStateServiceContract, -} from './tenant-module-state-service.ts'; +import type { TenantModuleState, TenantModuleStateServiceContract } from './tenant-module-state-service.ts'; import { TENANT_MODULE_STATES, TenantModuleStateSchema, @@ -29,9 +24,7 @@ export type { ModuleStateSnapshot } from './module-state-snapshot.ts'; const ModuleStateDecisionSchema = Schema.Literals(['allow', 'deny']); export type ModuleStateDecision = typeof ModuleStateDecisionSchema.Type; -const allowedAccessByState: Readonly< - Record> -> = Object.freeze({ +const allowedAccessByState: Readonly>> = Object.freeze({ active: new Set(['background', 'historical_read', 'read', 'write']), archived: new Set(['historical_read']), deprecated: new Set(['historical_read', 'read']), @@ -44,31 +37,19 @@ const allowedAccessByState: Readonly< export const decideModuleStateAccess = ( state: TenantModuleState | null, access: ModuleEntrypointAccess, -): ModuleStateDecision => - state !== null && allowedAccessByState[state].has(access) ? 'allow' : 'deny'; +): ModuleStateDecision => (state !== null && allowedAccessByState[state].has(access) ? 'allow' : 'deny'); -export const tenantStatesAllowingAccess = ( - access: ModuleEntrypointAccess, -): readonly TenantModuleState[] => +export const tenantStatesAllowingAccess = (access: ModuleEntrypointAccess): readonly TenantModuleState[] => Object.freeze( - TENANT_MODULE_STATES.flatMap((state) => - allowedAccessByState[state].has(access) ? [state] : [], - ).toSorted(), + TENANT_MODULE_STATES.flatMap((state) => (allowedAccessByState[state].has(access) ? [state] : [])).toSorted(), ); -const ModuleStateSnapshotInvariantError = Schema.TaggedError()( - 'ModuleStateSnapshotInvariantError', - { reason: Schema.String }, -); +const ModuleStateSnapshotInvariantError = Schema.TaggedError()('ModuleStateSnapshotInvariantError', { + reason: Schema.String, +}); const entrypointFingerprint = (entrypoint: ModuleEntrypointDescriptor): string => - [ - entrypoint.scope, - entrypoint.moduleKey, - entrypoint.entrypointKey, - entrypoint.role, - entrypoint.access, - ].join('\u0000'); + [entrypoint.scope, entrypoint.moduleKey, entrypoint.entrypointKey, entrypoint.role, entrypoint.access].join('\u0000'); const unavailable = (cause?: unknown) => { const error = new ModuleStateCheckUnavailableError({ @@ -91,16 +72,17 @@ export const makeModuleStateSnapshot = ( ...[tenantId, entrypoints, records]: [ tenantId: string, entrypoints: readonly ModuleEntrypointDescriptor[], - records: readonly { readonly moduleKey: string; readonly state: TenantModuleState }[], + records: readonly { + readonly moduleKey: string; + readonly state: TenantModuleState; + }[], ] ): ModuleStateSnapshot => { const entrypointKeys = Object.freeze( [...new Set(entrypoints.map((entrypoint) => entrypoint.entrypointKey))].toSorted(), ); const declaredEntrypoints = new Set(entrypoints.map(entrypointFingerprint)); - const moduleKeys = entrypoints.flatMap((entrypoint) => - entrypoint.scope === 'tenant' ? [entrypoint.moduleKey] : [], - ); + const moduleKeys = entrypoints.flatMap((entrypoint) => (entrypoint.scope === 'tenant' ? [entrypoint.moduleKey] : [])); const declaredKeys = Object.freeze([...new Set(moduleKeys)].toSorted()); const declaredSet = new Set(declaredKeys); const states = new Map(); @@ -239,10 +221,7 @@ export const checkModuleEntrypoint = ( }), ); } - const outcome = decideModuleStateAccess( - data.states.get(entrypoint.moduleKey) ?? null, - entrypoint.access, - ); + const outcome = decideModuleStateAccess(data.states.get(entrypoint.moduleKey) ?? null, entrypoint.access); return (outcome === 'allow' ? Effect.void : Effect.fail(denied())).pipe( Effect.withSpan('ModuleStateGate.evaluate', { attributes: { @@ -273,12 +252,9 @@ export interface ModuleStateGateService { const isModuleStateDenied = Schema.is(ModuleStateDeniedError); -export const makeModuleStateGate = ( - stateService: TenantModuleStateServiceContract, -): ModuleStateGateService => ({ +export const makeModuleStateGate = (stateService: TenantModuleStateServiceContract): ModuleStateGateService => ({ check: checkModuleEntrypoint, - prepareSnapshot: (tenantId, entrypoints) => - prepareModuleStateSnapshot(stateService, tenantId, entrypoints), + prepareSnapshot: (tenantId, entrypoints) => prepareModuleStateSnapshot(stateService, tenantId, entrypoints), recheckWrite: (transaction, tenantId, entrypoint) => { const recheck = Effect.gen(function* recheckWriteEffect() { const tenantRows = yield* transaction @@ -293,12 +269,7 @@ export const makeModuleStateGate = ( const rows = yield* transaction .select({ state: tenantModuleStates.state }) .from(tenantModuleStates) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, entrypoint.moduleKey), - ), - ) + .where(and(eq(tenantModuleStates.tenantId, tenantId), eq(tenantModuleStates.moduleKey, entrypoint.moduleKey))) .pipe(Effect.mapError(unavailable)); const state = rows[0] === undefined diff --git a/app/packages/core-runtime/src/modules/runtime-registration.ts b/app/packages/core-runtime/src/modules/runtime-registration.ts index 179ea710c..74aad4ff9 100644 --- a/app/packages/core-runtime/src/modules/runtime-registration.ts +++ b/app/packages/core-runtime/src/modules/runtime-registration.ts @@ -1,5 +1,6 @@ import { Predicate, Result, Schema } from 'effect'; import type { Effect } from 'effect'; + import type { AnyOutboxWorkerRegistration } from '../outbox/definition.ts'; import { validateOutboxWorkerRegistrations } from '../outbox/definition.ts'; import type { @@ -12,9 +13,7 @@ import type { import { OntosActionContractSchema } from './manifest.ts'; import type { OntosShellContributions } from './shell-contribution.ts'; -const runtimeRegistrationBrand: unique symbol = Symbol( - '@app/core-runtime/modules/runtime-registration', -); +const runtimeRegistrationBrand: unique symbol = Symbol('@app/core-runtime/modules/runtime-registration'); interface PrivateVerticalRuntime { readonly actions: readonly OntosManifestActionValue[]; @@ -28,9 +27,7 @@ export interface VerticalRuntimeRegistration { readonly [runtimeRegistrationBrand]: true; } -class VerticalRuntimeRegistrationValue< - ModuleId extends string, -> implements VerticalRuntimeRegistration { +class VerticalRuntimeRegistrationValue implements VerticalRuntimeRegistration { readonly #runtime: PrivateVerticalRuntime; readonly [runtimeRegistrationBrand] = true as const; readonly moduleId: ModuleId; @@ -55,9 +52,7 @@ const failRuntimeRegistration = (message: string): never => { throw new VerticalRuntimeRegistrationInvariantError({ message }); }; -export interface VerticalRuntimeRegistrationInput< - Manifest extends OntosModuleManifest = OntosModuleManifest, -> { +export interface VerticalRuntimeRegistrationInput { readonly actions: readonly OntosManifestActionValue[]; readonly entrypoints?: VerticalRuntimeEntrypointBindings; readonly manifest: Manifest; @@ -155,9 +150,7 @@ export const defineVerticalRuntimeRegistration = { +const requirePrivateRuntime = (registration: VerticalRuntimeRegistration): PrivateVerticalRuntime => { const value = VerticalRuntimeRegistrationValue.runtimeOf(registration); if (value === undefined || !registration[runtimeRegistrationBrand]) { return failRuntimeRegistration('invalid Vertical Runtime Registration'); @@ -198,7 +191,7 @@ export const extractVerticalRuntimeSafeDescriptors = ( .map(({ descriptor }) => Object.freeze( Result.getOrThrow( - Schema.decodeUnknownResult(OntosActionContractSchema)({ + Schema.decodeResult(OntosActionContractSchema)({ actionKey: descriptor.actionKey, auditProfile: descriptor.auditProfile, entrypoint: descriptor.entrypoint, @@ -220,7 +213,9 @@ export const extractVerticalRuntimeSafeDescriptors = ( consumerModuleKey: descriptor.consumerModuleKey, entrypoint: Object.freeze({ ...descriptor.entrypoint, - authorization: Object.freeze({ kind: 'owner_local_background' as const }), + authorization: Object.freeze({ + kind: 'owner_local_background' as const, + }), }), producerModuleKey: descriptor.producerModuleKey, topic: descriptor.topic, diff --git a/app/packages/core-runtime/src/modules/shell-contribution.ts b/app/packages/core-runtime/src/modules/shell-contribution.ts index 5da350cf0..fe4498035 100644 --- a/app/packages/core-runtime/src/modules/shell-contribution.ts +++ b/app/packages/core-runtime/src/modules/shell-contribution.ts @@ -1,4 +1,5 @@ import { Result, Schema } from 'effect'; + import { ModuleEntrypointSchema } from './module-entrypoint.ts'; const stableKey = Schema.String.check( @@ -14,10 +15,7 @@ const groupKey = stableKey.pipe(Schema.brand('GroupKey')); const pageKey = stableKey.pipe(Schema.brand('PageKey')); const reportKey = stableKey.pipe(Schema.brand('ReportKey')); const searchKey = stableKey.pipe(Schema.brand('SearchKey')); -const order = Schema.Finite.check( - Schema.isInt(), - Schema.isBetween({ maximum: 10_000, minimum: 0 }), -); +const order = Schema.Finite.check(Schema.isInt(), Schema.isBetween({ maximum: 10_000, minimum: 0 })); const routeParameterPattern = /^:(?[a-z][A-Za-z0-9]*)$/u; const routeLocalePrefixPattern = /^[a-z]{2}(?:-[a-z]{2})?$/u; const routePath = Schema.String.check( @@ -58,9 +56,7 @@ const pageEntrypoint = ModuleEntrypointSchema.pipe( const componentEntrypoint = ModuleEntrypointSchema.pipe( Schema.check( Schema.makeFilter((entrypoint) => - entrypoint.scope === 'tenant' && - entrypoint.role === 'public_component' && - allowsRead(entrypoint.access) + entrypoint.scope === 'tenant' && entrypoint.role === 'public_component' && allowsRead(entrypoint.access) ? undefined : 'component contribution entrypoint must be a readable tenant public component', ), @@ -78,9 +74,7 @@ const searchEntrypoint = ModuleEntrypointSchema.pipe( const reportEntrypoint = ModuleEntrypointSchema.pipe( Schema.check( Schema.makeFilter((entrypoint) => - entrypoint.scope === 'tenant' && - entrypoint.role === 'report' && - entrypoint.access !== 'background' + entrypoint.scope === 'tenant' && entrypoint.role === 'report' && entrypoint.access !== 'background' ? undefined : 'report contribution entrypoint must be a tenant report with compatible access', ), @@ -183,11 +177,7 @@ export interface ShellContributionReferenceSets { readonly searchKeys: ReadonlySet; } -const referenceIssue = ( - set: ReadonlySet, - key: string, - label: string, -): string | undefined => +const referenceIssue = (set: ReadonlySet, key: string, label: string): string | undefined => set.has(key) ? undefined : `${label} references undeclared manifest key ${key}`; const validatePageReferences = ( @@ -202,11 +192,7 @@ const validatePageReferences = ( } } for (const contribution of [...contributions.pages, ...contributions.publicComponents]) { - const issue = referenceIssue( - references.componentKeys, - contribution.componentKey, - 'component contribution', - ); + const issue = referenceIssue(references.componentKeys, contribution.componentKey, 'component contribution'); if (issue !== undefined) { return issue; } @@ -220,21 +206,13 @@ const validateDiscoveryReferences = ( references: ShellContributionReferenceSets, ): string | undefined => { for (const contribution of contributions.search) { - const issue = referenceIssue( - references.searchKeys, - contribution.searchKey, - 'search contribution', - ); + const issue = referenceIssue(references.searchKeys, contribution.searchKey, 'search contribution'); if (issue !== undefined) { return issue; } } for (const contribution of contributions.reports) { - const issue = referenceIssue( - references.reportKeys, - contribution.reportKey, - 'report contribution', - ); + const issue = referenceIssue(references.reportKeys, contribution.reportKey, 'report contribution'); if (issue !== undefined) { return issue; } @@ -248,11 +226,7 @@ const validateResourceReferences = ( references: ShellContributionReferenceSets, ): string | undefined => { for (const contribution of [...contributions.resourceDetails, ...contributions.timelines]) { - const apiIssue = referenceIssue( - references.apiKeys, - contribution.apiKey, - 'resource contribution', - ); + const apiIssue = referenceIssue(references.apiKeys, contribution.apiKey, 'resource contribution'); if (apiIssue !== undefined) { return apiIssue; } @@ -274,11 +248,7 @@ const validateMediaReferences = ( references: ShellContributionReferenceSets, ): string | undefined => { for (const contribution of contributions.mediaAttachments) { - const actionIssue = referenceIssue( - references.actionKeys, - contribution.actionKey, - 'media contribution', - ); + const actionIssue = referenceIssue(references.actionKeys, contribution.actionKey, 'media contribution'); if (actionIssue !== undefined) { return actionIssue; } @@ -286,11 +256,7 @@ const validateMediaReferences = ( if (apiIssue !== undefined) { return apiIssue; } - const resourceIssue = referenceIssue( - references.resourceTypeKeys, - contribution.resourceType, - 'media contribution', - ); + const resourceIssue = referenceIssue(references.resourceTypeKeys, contribution.resourceType, 'media contribution'); if (resourceIssue !== undefined) { return resourceIssue; } @@ -337,11 +303,7 @@ export const validateShellContributions = ( references: ShellContributionReferenceSets, ): OntosShellContributions => { const schema = OntosShellContributionsSchema.pipe( - Schema.check( - Schema.makeFilter((contributions) => validateReferences(contributions, references)), - ), - ); - return Result.getOrThrow( - Schema.decodeUnknownResult(schema, { onExcessProperty: 'error' })(input), + Schema.check(Schema.makeFilter((contributions) => validateReferences(contributions, references))), ); + return Result.getOrThrow(Schema.decodeUnknownResult(schema, { onExcessProperty: 'error' })(input)); }; diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-concurrent-change-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-concurrent-change-error.ts index a6bd8aaa7..b46c0900b 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-concurrent-change-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-concurrent-change-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateConcurrentChangeError extends Schema.TaggedError()( 'TenantModuleStateConcurrentChangeError', - { code: Schema.Literal('tenant_module_state_changed_concurrently'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_changed_concurrently'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-persistence-unavailable-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-persistence-unavailable-error.ts index 9b78ce1c8..bf86ba15f 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-persistence-unavailable-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-persistence-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStatePersistenceUnavailableError extends Schema.TaggedError()( 'TenantModuleStatePersistenceUnavailableError', - { code: Schema.Literal('tenant_module_state_persistence_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_persistence_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-read-unavailable-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-read-unavailable-error.ts index dc3aff5f6..0ba231a34 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-read-unavailable-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-read-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateReadUnavailableError extends Schema.TaggedError()( 'TenantModuleStateReadUnavailableError', - { code: Schema.Literal('tenant_module_state_read_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_read_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-service.ts b/app/packages/core-runtime/src/modules/tenant-module-state-service.ts index fd7ea39f4..537bc3df6 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-service.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-service.ts @@ -1,5 +1,6 @@ import { and, asc, eq, inArray } from 'drizzle-orm'; import { Clock, Context, DateTime, Effect, Layer, Match, Schema } from 'effect'; + import { CoreDatabase } from '../db/client.ts'; import type { ActionAuthMethod } from '../db/schema.ts'; import { tenantModuleStateChanges, tenantModuleStates, tenants } from '../db/schema.ts'; @@ -20,12 +21,7 @@ import { TenantModuleStateUnsupportedStateError, } from './tenant-module-state-errors.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -65,10 +61,7 @@ export const validateTenantModuleStateTransition = ( catalog: InstalledModuleCatalog, moduleKey: OntosModuleId, newState: TenantModuleState, -): Effect.Effect< - void, - TenantModuleStateUnknownModuleError | TenantModuleStateUnsupportedStateError -> => { +): Effect.Effect => { if (newState === 'inactive') { return Effect.void; } @@ -168,7 +161,10 @@ export const makeTenantModuleStateService = (database: { const listTenantModuleStates = (tenantId: string) => database.executor - .select({ moduleKey: tenantModuleStates.moduleKey, state: tenantModuleStates.state }) + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + }) .from(tenantModuleStates) .where(eq(tenantModuleStates.tenantId, tenantId)) .orderBy(asc(tenantModuleStates.moduleKey)) @@ -181,14 +177,12 @@ export const makeTenantModuleStateService = (database: { return Effect.succeed(Object.freeze([])); } return database.executor - .select({ moduleKey: tenantModuleStates.moduleKey, state: tenantModuleStates.state }) + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + }) .from(tenantModuleStates) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - inArray(tenantModuleStates.moduleKey, distinctKeys), - ), - ) + .where(and(eq(tenantModuleStates.tenantId, tenantId), inArray(tenantModuleStates.moduleKey, distinctKeys))) .orderBy(asc(tenantModuleStates.moduleKey)) .pipe(Effect.mapError(tenantModuleStateReadUnavailable), Effect.flatMap(decodeRows)); }, @@ -237,132 +231,131 @@ const persistenceUnavailable = (cause?: unknown) => { return error; }; -export const persistTenantModuleStateChange = Effect.fn( - 'TenantModuleStateService.persistTenantModuleStateChange', -)(function* persistTenantModuleStateChangeEffect( - transaction: ScopedTransactionExecutor, - input: PersistTenantModuleStateChangeInput, -): Effect.fn.Return { - const { changeSource, tenantRows } = yield* resolveTenantModuleStateChangeSource( - input.authMethod, - ).pipe( - Effect.flatMap((resolvedChangeSource) => - transaction - .select({ tenantId: tenants.tenantId }) - .from(tenants) - .where(eq(tenants.tenantId, input.tenantId)) - .for('update') - .pipe( - Effect.mapError(persistenceUnavailable), - Effect.map((lockedTenantRows) => ({ - changeSource: resolvedChangeSource, - tenantRows: lockedTenantRows, - })), - ), - ), - ); - const [tenant] = tenantRows; - if (tenant === undefined) { - return yield* new TenantModuleStateTenantMissingError({ - code: 'tenant_module_state_tenant_missing', - reason: 'The tenant required for this state change does not exist', - }); - } - - const currentRows = yield* transaction - .select({ - state: tenantModuleStates.state, - tenantModuleStateId: tenantModuleStates.tenantModuleStateId, - }) - .from(tenantModuleStates) - .where( - and( - eq(tenantModuleStates.tenantId, input.tenantId), - eq(tenantModuleStates.moduleKey, input.moduleKey), - ), - ) - .pipe(Effect.mapError(persistenceUnavailable)); - const [current] = currentRows; - const previousState = - current === undefined - ? null - : yield* Schema.decodeUnknownEffect(TenantModuleStateSchema)(current.state).pipe( - Effect.mapError(persistenceUnavailable), - ); - const effectivePreviousState = previousState ?? 'inactive'; - if (input.expectedState !== undefined && input.expectedState !== effectivePreviousState) { - return yield* new TenantModuleStateConcurrentChangeError({ - code: 'tenant_module_state_changed_concurrently', - reason: 'The tenant module state changed after it was read', - }); - } - yield* rejectUnchangedTenantModuleState(previousState, input.newState); - const currentTimeMillis = yield* Clock.currentTimeMillis; - const changedAt = DateTime.toDateUtc(DateTime.makeUnsafe(currentTimeMillis)); - - const historyRows = yield* transaction - .insert(tenantModuleStateChanges) - .values( - withOptionalProperty( - { - actionInvocationId: input.actionInvocationId, - changedByPrincipalId: input.principalId, - changeSource, - moduleKey: input.moduleKey, - newState: input.newState, - occurredAt: changedAt, - previousState, - }, - input.reason !== undefined, - 'reason', - input.reason, - { - tenantId: input.tenantId, - }, +export const persistTenantModuleStateChange = Effect.fn('TenantModuleStateService.persistTenantModuleStateChange')( + function* persistTenantModuleStateChangeEffect( + transaction: ScopedTransactionExecutor, + input: PersistTenantModuleStateChangeInput, + ): Effect.fn.Return { + const { changeSource, tenantRows } = yield* resolveTenantModuleStateChangeSource(input.authMethod).pipe( + Effect.flatMap((resolvedChangeSource) => + transaction + .select({ tenantId: tenants.tenantId }) + .from(tenants) + .where(eq(tenants.tenantId, input.tenantId)) + .for('update') + .pipe( + Effect.mapError(persistenceUnavailable), + Effect.map((lockedTenantRows) => ({ + changeSource: resolvedChangeSource, + tenantRows: lockedTenantRows, + })), + ), ), - ) - .returning({ moduleStateChangeId: tenantModuleStateChanges.moduleStateChangeId }) - .pipe(Effect.mapError(persistenceUnavailable)); - const [history] = historyRows; - if (history === undefined) { - return yield* persistenceUnavailable(); - } + ); + const [tenant] = tenantRows; + if (tenant === undefined) { + return yield* new TenantModuleStateTenantMissingError({ + code: 'tenant_module_state_tenant_missing', + reason: 'The tenant required for this state change does not exist', + }); + } - if (current === undefined) { - const inserted = yield* transaction - .insert(tenantModuleStates) - .values({ - lastChangeId: history.moduleStateChangeId, - moduleKey: input.moduleKey, - state: input.newState, - tenantId: input.tenantId, - updatedAt: changedAt, + const currentRows = yield* transaction + .select({ + state: tenantModuleStates.state, + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, }) - .returning({ tenantModuleStateId: tenantModuleStates.tenantModuleStateId }) + .from(tenantModuleStates) + .where(and(eq(tenantModuleStates.tenantId, input.tenantId), eq(tenantModuleStates.moduleKey, input.moduleKey))) .pipe(Effect.mapError(persistenceUnavailable)); - const [insertedState] = inserted; - if (insertedState === undefined) { - return yield* persistenceUnavailable(); + const [current] = currentRows; + const previousState = + current === undefined + ? null + : yield* Schema.decodeUnknownEffect(TenantModuleStateSchema)(current.state).pipe( + Effect.mapError(persistenceUnavailable), + ); + const effectivePreviousState = previousState ?? 'inactive'; + if (input.expectedState !== undefined && input.expectedState !== effectivePreviousState) { + return yield* new TenantModuleStateConcurrentChangeError({ + code: 'tenant_module_state_changed_concurrently', + reason: 'The tenant module state changed after it was read', + }); } - } else { - const updated = yield* transaction - .update(tenantModuleStates) - .set({ - lastChangeId: history.moduleStateChangeId, - state: input.newState, - updatedAt: changedAt, + yield* rejectUnchangedTenantModuleState(previousState, input.newState); + const currentTimeMillis = yield* Clock.currentTimeMillis; + const changedAt = DateTime.toDateUtc(DateTime.makeUnsafe(currentTimeMillis)); + + const historyRows = yield* transaction + .insert(tenantModuleStateChanges) + .values( + withOptionalProperty( + { + actionInvocationId: input.actionInvocationId, + changedByPrincipalId: input.principalId, + changeSource, + moduleKey: input.moduleKey, + newState: input.newState, + occurredAt: changedAt, + previousState, + }, + input.reason !== undefined, + 'reason', + input.reason, + { + tenantId: input.tenantId, + }, + ), + ) + .returning({ + moduleStateChangeId: tenantModuleStateChanges.moduleStateChangeId, }) - .where(eq(tenantModuleStates.tenantModuleStateId, current.tenantModuleStateId)) - .returning({ tenantModuleStateId: tenantModuleStates.tenantModuleStateId }) .pipe(Effect.mapError(persistenceUnavailable)); - if (updated[0] === undefined) { + const [history] = historyRows; + if (history === undefined) { return yield* persistenceUnavailable(); } - } - return { - moduleKey: input.moduleKey, - newState: input.newState, - previousState, - }; -}); + if (current === undefined) { + const inserted = yield* transaction + .insert(tenantModuleStates) + .values({ + lastChangeId: history.moduleStateChangeId, + moduleKey: input.moduleKey, + state: input.newState, + tenantId: input.tenantId, + updatedAt: changedAt, + }) + .returning({ + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, + }) + .pipe(Effect.mapError(persistenceUnavailable)); + const [insertedState] = inserted; + if (insertedState === undefined) { + return yield* persistenceUnavailable(); + } + } else { + const updated = yield* transaction + .update(tenantModuleStates) + .set({ + lastChangeId: history.moduleStateChangeId, + state: input.newState, + updatedAt: changedAt, + }) + .where(eq(tenantModuleStates.tenantModuleStateId, current.tenantModuleStateId)) + .returning({ + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, + }) + .pipe(Effect.mapError(persistenceUnavailable)); + if (updated[0] === undefined) { + return yield* persistenceUnavailable(); + } + } + + return { + moduleKey: input.moduleKey, + newState: input.newState, + previousState, + }; + }, +); diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-tenant-missing-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-tenant-missing-error.ts index 26b673087..bc6794984 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-tenant-missing-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-tenant-missing-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateTenantMissingError extends Schema.TaggedError()( 'TenantModuleStateTenantMissingError', - { code: Schema.Literal('tenant_module_state_tenant_missing'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_tenant_missing'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-unchanged-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-unchanged-error.ts index f744db24d..0d6e32624 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-unchanged-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-unchanged-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateUnchangedError extends Schema.TaggedError()( 'TenantModuleStateUnchangedError', - { code: Schema.Literal('tenant_module_state_unchanged'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_unchanged'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-unknown-module-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-unknown-module-error.ts index ca1417c50..ac3e8891e 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-unknown-module-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-unknown-module-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateUnknownModuleError extends Schema.TaggedError()( 'TenantModuleStateUnknownModuleError', - { code: Schema.Literal('tenant_module_state_module_unknown'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_module_unknown'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-change-source-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-change-source-error.ts index 9672a505c..05c768588 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-change-source-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-change-source-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateUnsupportedChangeSourceError extends Schema.TaggedError()( 'TenantModuleStateUnsupportedChangeSourceError', - { code: Schema.Literal('tenant_module_state_change_source_unsupported'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_change_source_unsupported'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-state-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-state-error.ts index f66158c13..3313d0faa 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-state-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-unsupported-state-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateUnsupportedStateError extends Schema.TaggedError()( 'TenantModuleStateUnsupportedStateError', - { code: Schema.Literal('tenant_module_state_unsupported'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_unsupported'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-validation-unavailable-error.ts b/app/packages/core-runtime/src/modules/tenant-module-state-validation-unavailable-error.ts index f57c992fe..8a7d22147 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-validation-unavailable-error.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-validation-unavailable-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class TenantModuleStateValidationUnavailableError extends Schema.TaggedError()( 'TenantModuleStateValidationUnavailableError', - { code: Schema.Literal('tenant_module_state_validation_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('tenant_module_state_validation_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/operations/context.ts b/app/packages/core-runtime/src/operations/context.ts index 8e381e7de..79df6defc 100644 --- a/app/packages/core-runtime/src/operations/context.ts +++ b/app/packages/core-runtime/src/operations/context.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { Context, Effect, Layer } from 'effect'; + import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; import { isTrustedSupportRecoveryPrincipalContext, @@ -24,12 +25,7 @@ import { OperationalScopeRepositoryContext } from './repository-context.ts'; export type { OperationalScopeRepository } from './repository-context.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -45,8 +41,7 @@ export interface OperationalScopeRequest { readonly traceId?: string; } -export interface OperationalScope - extends Readonly, Readonly {} +export interface OperationalScope extends Readonly, Readonly {} export type LegalEntityScopeAccess = Pick & Partial>; @@ -57,9 +52,7 @@ export interface ResolveOperationalScopeInput extends Readonly Effect.Effect; + readonly resolve: (input: ResolveOperationalScopeInput) => Effect.Effect; } export class OperationalScopeResolver extends Context.Service< @@ -73,7 +66,10 @@ const operationContextUnavailable = (cause?: unknown) => { reason: 'The operation context could not be revalidated', }); if (cause !== undefined) { - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); } return failure; }; @@ -101,10 +97,7 @@ export const makeOperationalScopeRepository = (database: { .from(tenants) .innerJoin( principals, - and( - eq(principals.tenantId, tenants.tenantId), - eq(principals.principalId, principal.principalId), - ), + and(eq(principals.tenantId, tenants.tenantId), eq(principals.principalId, principal.principalId)), ) .leftJoin( impersonators, @@ -118,10 +111,7 @@ export const makeOperationalScopeRepository = (database: { .leftJoin( principalAuthBindings, principal.authBindingId === undefined - ? eq( - principalAuthBindings.principalAuthBindingId, - '00000000-0000-0000-0000-000000000000', - ) + ? eq(principalAuthBindings.principalAuthBindingId, '00000000-0000-0000-0000-000000000000') : and( eq(principalAuthBindings.tenantId, principal.tenantId), eq(principalAuthBindings.principalAuthBindingId, principal.authBindingId), @@ -204,8 +194,7 @@ const hasInvalidPersistedBinding = ( ): boolean => persisted.bindingTenantId !== principal.tenantId || persisted.bindingPrincipalId !== principal.principalId || - (!supportRecovery && - (persisted.bindingStatus !== 'active' || persisted.bindingRevokedAt !== null)); + (!supportRecovery && (persisted.bindingStatus !== 'active' || persisted.bindingRevokedAt !== null)); const validatePersistedPrincipal = ( principal: TrustedPrincipalContext, @@ -215,18 +204,14 @@ const validatePersistedPrincipal = ( if ( persisted.principalTenantId !== principal.tenantId || persisted.tenantStatus === null || - (!supportRecovery && - (persisted.tenantStatus !== 'active' || persisted.principalStatus !== 'active')) + (!supportRecovery && (persisted.tenantStatus !== 'active' || persisted.principalStatus !== 'active')) ) { return new OperationContextDenied({ code: 'operation_context_denied', reason: 'The tenant or principal is not active in this operation scope', }); } - if ( - principal.authBindingId !== undefined && - hasInvalidPersistedBinding(principal, persisted, supportRecovery) - ) { + if (principal.authBindingId !== undefined && hasInvalidPersistedBinding(principal, persisted, supportRecovery)) { return new OperationAuthenticationRequired({ code: 'operation_authentication_required', reason: 'The authenticated principal binding is no longer valid', @@ -235,52 +220,52 @@ const validatePersistedPrincipal = ( return undefined; }; -const validateSupportImpersonation = Effect.fn( - 'OperationalScopeResolver.validateSupportImpersonation', -)(function* validateSupportImpersonationEffect( - contextAccess: LegalEntityScopeAccess, - principal: TrustedPrincipalContext, - persisted: PersistedScopeRecord, -) { - if (principal.authMethod !== 'support_impersonation') { - return yield* Effect.void; - } - if ( - principal.impersonatedByPrincipalId === undefined || - persisted.impersonatorStatus !== 'active' || - persisted.impersonatorTenantId !== principal.tenantId +const validateSupportImpersonation = Effect.fn('OperationalScopeResolver.validateSupportImpersonation')( + function* validateSupportImpersonationEffect( + contextAccess: LegalEntityScopeAccess, + principal: TrustedPrincipalContext, + persisted: PersistedScopeRecord, ) { - return yield* new OperationContextDenied({ - code: 'operation_context_denied', - reason: 'The support administrator is no longer active in this tenant', - }); - } + if (principal.authMethod !== 'support_impersonation') { + return yield* Effect.void; + } + if ( + principal.impersonatedByPrincipalId === undefined || + persisted.impersonatorStatus !== 'active' || + persisted.impersonatorTenantId !== principal.tenantId + ) { + return yield* new OperationContextDenied({ + code: 'operation_context_denied', + reason: 'The support administrator is no longer active in this tenant', + }); + } - if (contextAccess.tenants === undefined) { - return yield* new OperationContextUnavailable({ - code: 'operation_context_unavailable', - reason: 'Support authorization is temporarily unavailable', - }); - } - const [supportDecision] = yield* contextAccess.tenants({ - permission: 'impersonate', - principalId: principal.impersonatedByPrincipalId, - tenantIds: [principal.tenantId], - }); - if (supportDecision?.decision === 'denied') { - return yield* new OperationContextDenied({ - code: 'operation_context_denied', - reason: 'Support impersonation permission was revoked', - }); - } - if (supportDecision?.decision !== 'allowed') { - return yield* new OperationContextUnavailable({ - code: 'operation_context_unavailable', - reason: 'Support authorization is temporarily unavailable', + if (contextAccess.tenants === undefined) { + return yield* new OperationContextUnavailable({ + code: 'operation_context_unavailable', + reason: 'Support authorization is temporarily unavailable', + }); + } + const [supportDecision] = yield* contextAccess.tenants({ + permission: 'impersonate', + principalId: principal.impersonatedByPrincipalId, + tenantIds: [principal.tenantId], }); - } - return yield* Effect.void; -}); + if (supportDecision?.decision === 'denied') { + return yield* new OperationContextDenied({ + code: 'operation_context_denied', + reason: 'Support impersonation permission was revoked', + }); + } + if (supportDecision?.decision !== 'allowed') { + return yield* new OperationContextUnavailable({ + code: 'operation_context_unavailable', + reason: 'Support authorization is temporarily unavailable', + }); + } + return yield* Effect.void; + }, +); const validateLegalEntity = Effect.fn('OperationalScopeResolver.validateLegalEntity')( function* validateLegalEntityEffect( @@ -291,10 +276,7 @@ const validateLegalEntity = Effect.fn('OperationalScopeResolver.validateLegalEnt if (principal.legalEntityId === undefined) { return yield* Effect.void; } - if ( - persisted.legalEntityStatus !== 'active' || - persisted.legalEntityTenantId !== principal.tenantId - ) { + if (persisted.legalEntityStatus !== 'active' || persisted.legalEntityTenantId !== principal.tenantId) { return yield* new OperationContextDenied({ code: 'operation_context_denied', reason: 'The selected legal entity is unavailable in this tenant', @@ -326,40 +308,40 @@ export const makeOperationalScopeResolver = ( repository: Pick, contextAccess: LegalEntityScopeAccess, ): OperationalScopeResolverService => { - const resolveOperationalScope = Effect.fn('OperationalScopeResolver.resolve')( - function* resolveOperationalScopeEffect(input: ResolveOperationalScopeInput) { - const { principal } = input; - const requestFailure = validateRequestedScope(input, input.legalEntityScope, principal); - if (requestFailure !== undefined) { - return yield* requestFailure; - } + const resolveOperationalScope = Effect.fn('OperationalScopeResolver.resolve')(function* resolveOperationalScopeEffect( + input: ResolveOperationalScopeInput, + ) { + const { principal } = input; + const requestFailure = validateRequestedScope(input, input.legalEntityScope, principal); + if (requestFailure !== undefined) { + return yield* requestFailure; + } - const persisted = yield* repository.load(principal); - const supportRecovery = isTrustedSupportRecoveryPrincipalContext(principal); - const persistedFailure = validatePersistedPrincipal(principal, persisted, supportRecovery); - if (persistedFailure !== undefined) { - return yield* persistedFailure; - } + const persisted = yield* repository.load(principal); + const supportRecovery = isTrustedSupportRecoveryPrincipalContext(principal); + const persistedFailure = validatePersistedPrincipal(principal, persisted, supportRecovery); + if (persistedFailure !== undefined) { + return yield* persistedFailure; + } - yield* validateSupportImpersonation(contextAccess, principal, persisted); - yield* validateLegalEntity(contextAccess, principal, persisted); - return preserveSystemPrincipalContextTrust( - principal, - Object.freeze( - withOptionalProperty( - { - ...principal, - correlationId: input.correlationId, - }, - input.traceId !== undefined, - 'traceId', - input.traceId, - {}, - ), + yield* validateSupportImpersonation(contextAccess, principal, persisted); + yield* validateLegalEntity(contextAccess, principal, persisted); + return preserveSystemPrincipalContextTrust( + principal, + Object.freeze( + withOptionalProperty( + { + ...principal, + correlationId: input.correlationId, + }, + input.traceId !== undefined, + 'traceId', + input.traceId, + {}, ), - ); - }, - ); + ), + ); + }); return { resolve: resolveOperationalScope }; }; diff --git a/app/packages/core-runtime/src/operations/operation-authentication-required.ts b/app/packages/core-runtime/src/operations/operation-authentication-required.ts index b431c87de..63bfb39fe 100644 --- a/app/packages/core-runtime/src/operations/operation-authentication-required.ts +++ b/app/packages/core-runtime/src/operations/operation-authentication-required.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class OperationAuthenticationRequired extends Schema.TaggedError()( 'OperationAuthenticationRequired', - { code: Schema.Literal('operation_authentication_required'), reason: Schema.String }, + { + code: Schema.Literal('operation_authentication_required'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/operations/operation-context-denied.ts b/app/packages/core-runtime/src/operations/operation-context-denied.ts index 1534564c3..090646340 100644 --- a/app/packages/core-runtime/src/operations/operation-context-denied.ts +++ b/app/packages/core-runtime/src/operations/operation-context-denied.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class OperationContextDenied extends Schema.TaggedError()( - 'OperationContextDenied', - { code: Schema.Literal('operation_context_denied'), reason: Schema.String }, -) {} +export class OperationContextDenied extends Schema.TaggedError()('OperationContextDenied', { + code: Schema.Literal('operation_context_denied'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/operations/operation-context-invalid.ts b/app/packages/core-runtime/src/operations/operation-context-invalid.ts index 713459945..c2055e26a 100644 --- a/app/packages/core-runtime/src/operations/operation-context-invalid.ts +++ b/app/packages/core-runtime/src/operations/operation-context-invalid.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class OperationContextInvalid extends Schema.TaggedError()( - 'OperationContextInvalid', - { code: Schema.Literal('operation_context_invalid'), reason: Schema.String }, -) {} +export class OperationContextInvalid extends Schema.TaggedError()('OperationContextInvalid', { + code: Schema.Literal('operation_context_invalid'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/operations/operation-context-unavailable.ts b/app/packages/core-runtime/src/operations/operation-context-unavailable.ts index 53ae1ee3f..84178aad0 100644 --- a/app/packages/core-runtime/src/operations/operation-context-unavailable.ts +++ b/app/packages/core-runtime/src/operations/operation-context-unavailable.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class OperationContextUnavailable extends Schema.TaggedError()( 'OperationContextUnavailable', - { code: Schema.Literal('operation_context_unavailable'), reason: Schema.String }, + { + code: Schema.Literal('operation_context_unavailable'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/operations/repository-context.ts b/app/packages/core-runtime/src/operations/repository-context.ts index c4ec2039d..cc2ce41d5 100644 --- a/app/packages/core-runtime/src/operations/repository-context.ts +++ b/app/packages/core-runtime/src/operations/repository-context.ts @@ -1,5 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; + import type { TrustedPrincipalContext } from '../actions/principal-context.ts'; import type { OperationContextUnavailable } from './errors.ts'; diff --git a/app/packages/core-runtime/src/outbox/definition.ts b/app/packages/core-runtime/src/outbox/definition.ts index e8dfe82e3..81d4cdbed 100644 --- a/app/packages/core-runtime/src/outbox/definition.ts +++ b/app/packages/core-runtime/src/outbox/definition.ts @@ -1,11 +1,10 @@ import { Predicate, Schema } from 'effect'; import type { Effect } from 'effect'; -import { OutboxWorkerDescriptorError } from './errors.ts'; + import type { TenantModuleEntrypoint } from '../modules/module-entrypoint.ts'; +import { OutboxWorkerDescriptorError } from './errors.ts'; -const outboxWorkerRegistration: unique symbol = Symbol( - '@app/core-runtime/outbox/worker-registration', -); +const outboxWorkerRegistration: unique symbol = Symbol('@app/core-runtime/outbox/worker-registration'); const verifiedOutboxWorkerHandlerContext = '__verifiedOutboxWorkerHandlerContext' as const; export interface OutboxWorkerRetryPolicy { @@ -15,9 +14,7 @@ export interface OutboxWorkerRetryPolicy { readonly multiplier: number; } -export interface OutboxWorkerHandlerContext extends Readonly< - Partial> -> { +export interface OutboxWorkerHandlerContext extends Readonly>> { readonly attemptNumber: number; readonly claimId: string; readonly deliveryId: string; @@ -35,9 +32,7 @@ const VerifiedOutboxWorkerHandlerContextSchema = Schema.Struct({ }); /** Core-private construction seam: caller-created context objects are not trusted worker claims. */ -export const attestOutboxWorkerHandlerContext = ( - context: OutboxWorkerHandlerContext, -): OutboxWorkerHandlerContext => { +export const attestOutboxWorkerHandlerContext = (context: OutboxWorkerHandlerContext): OutboxWorkerHandlerContext => { const verified = { ...context }; Object.defineProperty(verified, verifiedOutboxWorkerHandlerContext, { enumerable: false, @@ -46,9 +41,8 @@ export const attestOutboxWorkerHandlerContext = ( return Object.freeze(verified); }; -export const isVerifiedOutboxWorkerHandlerContext = ( - context: OutboxWorkerHandlerContext, -): boolean => Schema.is(VerifiedOutboxWorkerHandlerContextSchema)(context); +export const isVerifiedOutboxWorkerHandlerContext = (context: OutboxWorkerHandlerContext): boolean => + Schema.is(VerifiedOutboxWorkerHandlerContextSchema)(context); export interface OutboxWorkerDescriptor< PayloadSchema extends Schema.ConstraintDecoder, @@ -77,11 +71,7 @@ export type OutboxWorkerHandler = ( context: OutboxWorkerHandlerContext, ) => Effect.Effect; -type BivariantOutboxWorkerHandler = OutboxWorkerHandler< - Payload, - Error, - Requirements ->; +type BivariantOutboxWorkerHandler = OutboxWorkerHandler; export interface OutboxWorkerRegistration< PayloadSchema extends Schema.ConstraintDecoder, @@ -102,19 +92,9 @@ class OutboxWorkerRegistrationValue< Producer extends string, HandlerError, HandlerRequirements, -> implements OutboxWorkerRegistration< - PayloadSchema, - Consumer, - Producer, - HandlerError, - HandlerRequirements -> { +> implements OutboxWorkerRegistration { readonly [outboxWorkerRegistration] = true; - readonly #handler: BivariantOutboxWorkerHandler< - PayloadSchema['Type'], - HandlerError, - HandlerRequirements - >; + readonly #handler: BivariantOutboxWorkerHandler; readonly descriptor: Readonly>; constructor( @@ -125,11 +105,7 @@ class OutboxWorkerRegistrationValue< this.#handler = handler; } - resolveHandler(): BivariantOutboxWorkerHandler< - PayloadSchema['Type'], - HandlerError, - HandlerRequirements - > { + resolveHandler(): BivariantOutboxWorkerHandler { return this.#handler; } } @@ -189,14 +165,12 @@ const workerSlugPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const topicPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; const descriptorError = (reason: string): OutboxWorkerDescriptorError => - new OutboxWorkerDescriptorError({ code: 'outbox_worker_descriptor_invalid', reason }); + new OutboxWorkerDescriptorError({ + code: 'outbox_worker_descriptor_invalid', + reason, + }); -const assertFiniteInteger = ( - value: number, - minimum: number, - maximum: number, - label: string, -): void => { +const assertFiniteInteger = (value: number, minimum: number, maximum: number, label: string): void => { if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { throw descriptorError(`${label} must be an integer from ${minimum} through ${maximum}`); } @@ -215,10 +189,7 @@ const assertWorkerEntrypoint = (descriptor: OutboxWorkerSubscription, reason: st } }; -const assertWorkerSubscription = ( - descriptor: OutboxWorkerSubscription, - entrypointError: string, -): void => { +const assertWorkerSubscription = (descriptor: OutboxWorkerSubscription, entrypointError: string): void => { if (!moduleKeyPattern.test(descriptor.consumerModuleKey)) { throw descriptorError('consumerModuleKey must be a stable module key'); } @@ -231,13 +202,8 @@ const assertWorkerSubscription = ( } const expectedWorkerPrefix = `${descriptor.consumerModuleKey}.`; const workerSlug = descriptor.workerKey.slice(expectedWorkerPrefix.length); - if ( - !descriptor.workerKey.startsWith(expectedWorkerPrefix) || - !workerSlugPattern.test(workerSlug) - ) { - throw descriptorError( - 'workerKey must be owned by consumerModuleKey and end in lower-kebab-case', - ); + if (!descriptor.workerKey.startsWith(expectedWorkerPrefix) || !workerSlugPattern.test(workerSlug)) { + throw descriptorError('workerKey must be owned by consumerModuleKey and end in lower-kebab-case'); } }; @@ -250,25 +216,14 @@ export const defineOutboxWorker = < >( descriptor: OutboxWorkerDescriptor, handler: OutboxWorkerHandler, -): OutboxWorkerRegistration< - PayloadSchema, - Consumer, - Producer, - HandlerError, - HandlerRequirements -> => { +): OutboxWorkerRegistration => { assertWorkerSubscription( descriptor, 'Worker entrypoint must be an immutable tenant worker/background descriptor owned by consumerModuleKey', ); assertFiniteInteger(descriptor.leaseDurationMs, 1000, 3_600_000, 'leaseDurationMs'); assertFiniteInteger(descriptor.retryPolicy.maxAttempts, 1, 100, 'retryPolicy.maxAttempts'); - assertFiniteInteger( - descriptor.retryPolicy.initialBackoffMs, - 0, - 86_400_000, - 'retryPolicy.initialBackoffMs', - ); + assertFiniteInteger(descriptor.retryPolicy.initialBackoffMs, 0, 86_400_000, 'retryPolicy.initialBackoffMs'); assertFiniteInteger( descriptor.retryPolicy.maxBackoffMs, descriptor.retryPolicy.initialBackoffMs, @@ -323,10 +278,7 @@ export const validateOutboxWorkerSubscriptions = ( ): readonly OutboxWorkerSubscription[] => { const workerKeys = new Set(); for (const subscription of subscriptions) { - assertWorkerSubscription( - subscription, - 'installed Worker entrypoint is inconsistent with its subscription owner', - ); + assertWorkerSubscription(subscription, 'installed Worker entrypoint is inconsistent with its subscription owner'); if (workerKeys.has(subscription.workerKey)) { throw descriptorError(`duplicate Outbox Worker key ${subscription.workerKey}`); } @@ -350,10 +302,7 @@ export function getOutboxWorkerHandler(registration: AnyOutboxWorkerRegistration return registration.resolveHandler(); } -export const retryBackoffMs = ( - policy: OutboxWorkerRetryPolicy, - completedAttempts: number, -): number => +export const retryBackoffMs = (policy: OutboxWorkerRetryPolicy, completedAttempts: number): number => Math.min( policy.maxBackoffMs, Math.round(policy.initialBackoffMs * policy.multiplier ** Math.max(0, completedAttempts - 1)), diff --git a/app/packages/core-runtime/src/outbox/errors.ts b/app/packages/core-runtime/src/outbox/errors.ts index e06e25da1..f615095ca 100644 --- a/app/packages/core-runtime/src/outbox/errors.ts +++ b/app/packages/core-runtime/src/outbox/errors.ts @@ -6,16 +6,14 @@ export { OutboxPayloadDecodeError } from './outbox-payload-decode-error.ts'; export { OutboxPollerConfigError } from './outbox-poller-config-error.ts'; export { OutboxWorkerDescriptorError } from './outbox-worker-descriptor-error.ts'; -export class OutboxPersistenceError extends Schema.TaggedError()( - 'OutboxPersistenceError', - { code: Schema.Literal('outbox_persistence_failed'), reason: Schema.String }, -) {} +export class OutboxPersistenceError extends Schema.TaggedError()('OutboxPersistenceError', { + code: Schema.Literal('outbox_persistence_failed'), + reason: Schema.String, +}) {} const PERSISTENCE_CAUSE_PROPERTY = 'ontosOutboxPersistenceCause'; -export const outboxPersistenceError = ( - cause: FailureCause, -): OutboxPersistenceError => { +export const outboxPersistenceError = (cause: FailureCause): OutboxPersistenceError => { const failure = new OutboxPersistenceError({ code: 'outbox_persistence_failed', reason: 'The Outbox Worker persistence operation failed', diff --git a/app/packages/core-runtime/src/outbox/health.ts b/app/packages/core-runtime/src/outbox/health.ts index f5721a62c..335d0109b 100644 --- a/app/packages/core-runtime/src/outbox/health.ts +++ b/app/packages/core-runtime/src/outbox/health.ts @@ -1,8 +1,8 @@ import { NodeHttpServer } from '@effect/platform-node'; import { Clock, Effect, Match, Option, Ref } from 'effect'; import type { Scope } from 'effect'; -import type { ServeError } from 'effect/unstable/http/HttpServerError'; import { HttpServerRequest, HttpServerResponse } from 'effect/unstable/http'; +import type { ServeError } from 'effect/unstable/http/HttpServerError'; interface HealthState { readonly lastSuccessfulCycleAt: Option.Option; @@ -21,46 +21,46 @@ export interface CreateOutboxWorkerHealthOptions { readonly staleAfterMs: number; } -const makeOutboxWorkerHealth = Effect.fn('OutboxWorkerHealth.make')( - function* makeOutboxWorkerHealthEffect(staleAfterMs: number, now: Effect.Effect) { - const state = yield* Ref.make({ +const makeOutboxWorkerHealth = Effect.fn('OutboxWorkerHealth.make')(function* makeOutboxWorkerHealthEffect( + staleAfterMs: number, + now: Effect.Effect, +) { + const state = yield* Ref.make({ + lastSuccessfulCycleAt: Option.none(), + running: true, + }); + return { + cycleFailed: Ref.update(state, (current) => ({ + ...current, lastSuccessfulCycleAt: Option.none(), - running: true, - }); - return { - cycleFailed: Ref.update(state, (current) => ({ - ...current, - lastSuccessfulCycleAt: Option.none(), - })), - cycleSucceeded: now.pipe( - Effect.flatMap((lastSuccessfulCycleAt) => - Ref.update(state, (current) => ({ - ...current, - lastSuccessfulCycleAt: Option.some(lastSuccessfulCycleAt), - })), - ), + })), + cycleSucceeded: now.pipe( + Effect.flatMap((lastSuccessfulCycleAt) => + Ref.update(state, (current) => ({ + ...current, + lastSuccessfulCycleAt: Option.some(lastSuccessfulCycleAt), + })), ), - isReady: Effect.all([Ref.get(state), now], { concurrency: 1 }).pipe( - Effect.map( - ([current, currentTime]) => - current.running && - Option.isSome(current.lastSuccessfulCycleAt) && - currentTime - current.lastSuccessfulCycleAt.value <= staleAfterMs, - ), + ), + isReady: Effect.all([Ref.get(state), now], { concurrency: 1 }).pipe( + Effect.map( + ([current, currentTime]) => + current.running && + Option.isSome(current.lastSuccessfulCycleAt) && + currentTime - current.lastSuccessfulCycleAt.value <= staleAfterMs, ), - shuttingDown: Ref.set(state, { - lastSuccessfulCycleAt: Option.none(), - running: false, - }), - }; - }, -); + ), + shuttingDown: Ref.set(state, { + lastSuccessfulCycleAt: Option.none(), + running: false, + }), + }; +}); export const createOutboxWorkerHealth = ({ now = Clock.currentTimeMillis, staleAfterMs, -}: CreateOutboxWorkerHealthOptions): Effect.Effect => - makeOutboxWorkerHealth(staleAfterMs, now); +}: CreateOutboxWorkerHealthOptions): Effect.Effect => makeOutboxWorkerHealth(staleAfterMs, now); export interface OutboxWorkerHealthServer { readonly hostname: string; @@ -73,29 +73,27 @@ const createNodeHealthServer = () => process.getBuiltinModule('http').createServ export const serveOutboxWorkerHealth: ( health: OutboxWorkerHealth, options: { readonly port: number }, -) => Effect.Effect = Effect.fn( - 'OutboxWorkerHealth.serve', -)(function* serveOutboxWorkerHealthEffect(health, options) { - const server = yield* NodeHttpServer.make(createNodeHealthServer, { - host: '0.0.0.0', - port: options.port, - }); - const healthApplication = HttpServerRequest.HttpServerRequest.use((request) => { - if (request.url !== '/ready') { - return Effect.succeed(HttpServerResponse.empty({ status: 404 })); - } - return health.isReady.pipe( - Effect.map((ready) => - HttpServerResponse.jsonUnsafe({ ready }, { status: ready ? 200 : 503 }), - ), - ); - }); - yield* server.serve(healthApplication); +) => Effect.Effect = Effect.fn('OutboxWorkerHealth.serve')( + function* serveOutboxWorkerHealthEffect(health, options) { + const server = yield* NodeHttpServer.make(createNodeHealthServer, { + host: '0.0.0.0', + port: options.port, + }); + const healthApplication = HttpServerRequest.HttpServerRequest.use((request) => { + if (request.url !== '/ready') { + return Effect.succeed(HttpServerResponse.empty({ status: 404 })); + } + return health.isReady.pipe( + Effect.map((ready) => HttpServerResponse.jsonUnsafe({ ready }, { status: ready ? 200 : 503 })), + ); + }); + yield* server.serve(healthApplication); - const address = yield* Match.value(server.address).pipe( - Match.tag('TcpAddress', (tcpAddress) => Effect.succeed(tcpAddress)), - Match.orElse(() => Effect.die('Outbox health server did not bind to TCP')), - ); - yield* Effect.addFinalizer(() => health.shuttingDown); - return { hostname: address.hostname, port: address.port }; -}); + const address = yield* Match.value(server.address).pipe( + Match.tag('TcpAddress', (tcpAddress) => Effect.succeed(tcpAddress)), + Match.orElse(() => Effect.die('Outbox health server did not bind to TCP')), + ); + yield* Effect.addFinalizer(() => health.shuttingDown); + return { hostname: address.hostname, port: address.port }; + }, +); diff --git a/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts b/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts index 76cabca35..afd5dc9eb 100644 --- a/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts +++ b/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class OutboxClaimLostError extends Schema.TaggedError()( - 'OutboxClaimLostError', - { code: Schema.Literal('outbox_claim_lost'), reason: Schema.String }, -) {} +export class OutboxClaimLostError extends Schema.TaggedError()('OutboxClaimLostError', { + code: Schema.Literal('outbox_claim_lost'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts b/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts index 4fa5e7c56..1c420309c 100644 --- a/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts +++ b/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class OutboxHandlerExecutionError extends Schema.TaggedError()( 'OutboxHandlerExecutionError', - { code: Schema.Literal('outbox_handler_execution_failed'), reason: Schema.String }, + { + code: Schema.Literal('outbox_handler_execution_failed'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts b/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts index 612689660..5bfffe0f1 100644 --- a/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts +++ b/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class OutboxPollerConfigError extends Schema.TaggedError()( - 'OutboxPollerConfigError', - { code: Schema.Literal('outbox_poller_config_invalid'), reason: Schema.String }, -) {} +export class OutboxPollerConfigError extends Schema.TaggedError()('OutboxPollerConfigError', { + code: Schema.Literal('outbox_poller_config_invalid'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts b/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts index 44b5d6f57..22d7df701 100644 --- a/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts +++ b/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class OutboxWorkerDescriptorError extends Schema.TaggedError()( 'OutboxWorkerDescriptorError', - { code: Schema.Literal('outbox_worker_descriptor_invalid'), reason: Schema.String }, + { + code: Schema.Literal('outbox_worker_descriptor_invalid'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/outbox/poller.ts b/app/packages/core-runtime/src/outbox/poller.ts index ecb387bba..250f5ab12 100644 --- a/app/packages/core-runtime/src/outbox/poller.ts +++ b/app/packages/core-runtime/src/outbox/poller.ts @@ -1,18 +1,10 @@ import { Config, ConfigProvider, Duration, Effect, Schedule, Schema } from 'effect'; -import type { - AnyOutboxWorkerRegistration, - OutboxWorkerRequirements, - OutboxWorkerSubscription, -} from './definition.ts'; + +import type { AnyOutboxWorkerRegistration, OutboxWorkerRequirements, OutboxWorkerSubscription } from './definition.ts'; import { OutboxPollerConfigError } from './errors.ts'; import type { OutboxWorkerHealth } from './health.ts'; import { runOutboxCycle } from './runtime.ts'; -import type { - OutboxCycleError, - OutboxCycleResult, - OutboxRuntime, - RunOutboxCycleInput, -} from './runtime.ts'; +import type { OutboxCycleError, OutboxCycleResult, OutboxRuntime, RunOutboxCycleInput } from './runtime.ts'; const DEFAULT_MAX_DELIVERIES = 100; const DEFAULT_POLL_INTERVAL_MS = 1000; @@ -48,11 +40,7 @@ export type OutboxCycleRunner< RunnerRequirements = OutboxRuntime, > = ( input: RunOutboxCycleInput, -) => Effect.Effect< - OutboxCycleResult, - OutboxCycleError, - RunnerRequirements | OutboxWorkerRequirements ->; +) => Effect.Effect>; const configError = (reason: string): OutboxPollerConfigError => new OutboxPollerConfigError({ code: 'outbox_poller_config_invalid', reason }); @@ -61,12 +49,7 @@ const EmptyConfigValue = Schema.Trim.pipe(Schema.decodeTo(Schema.Literal(''))); const ClaimOwnerOverride = Schema.Trim.check(Schema.isMaxLength(200)); const ClaimOwner = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)); -const boundedIntegerConfig = ( - key: string, - fallback: number, - minimum: number, - maximum: number, -): Config.Config => +const boundedIntegerConfig = (key: string, fallback: number, minimum: number, maximum: number): Config.Config => Config.schema( Schema.Union([ EmptyConfigValue, @@ -88,18 +71,8 @@ const pollingConfig = (defaultClaimOwner: string) => Config.withDefault(defaultClaimOwner), Config.map((value) => (value === '' ? defaultClaimOwner : value)), ), - maxDeliveries: boundedIntegerConfig( - 'OUTBOX_WORKER_MAX_DELIVERIES', - DEFAULT_MAX_DELIVERIES, - 1, - 1000, - ), - pollIntervalMs: boundedIntegerConfig( - 'OUTBOX_WORKER_POLL_INTERVAL_MS', - DEFAULT_POLL_INTERVAL_MS, - 10, - 3_600_000, - ), + maxDeliveries: boundedIntegerConfig('OUTBOX_WORKER_MAX_DELIVERIES', DEFAULT_MAX_DELIVERIES, 1, 1000), + pollIntervalMs: boundedIntegerConfig('OUTBOX_WORKER_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, 10, 3_600_000), }); const pollingConfigFailure = ({ message }: { readonly message: string }) => configError(message); @@ -109,12 +82,11 @@ export const parseOutboxPollingConfig = ({ environment, }: ParseOutboxPollingConfigInput): Effect.Effect => { const config = pollingConfig(defaultClaimOwner); - const decoded = - environment === undefined ? config : config.parse(ConfigProvider.fromUnknown(environment)); + const decoded = environment === undefined ? config : config.parse(ConfigProvider.fromUnknown(environment)); return decoded.pipe( Effect.flatMap((value) => - Schema.decodeUnknownEffect(ClaimOwner)(value.claimOwner).pipe( + Schema.decodeEffect(ClaimOwner)(value.claimOwner).pipe( Effect.map((claimOwner) => Object.freeze({ ...value, claimOwner })), ), ), @@ -128,24 +100,14 @@ const hasActivity = (result: OutboxCycleResult): boolean => export function runOutboxPollingLoop( input: RunOutboxPollingLoopInput, ): Effect.Effect>; -export function runOutboxPollingLoop< - Registration extends AnyOutboxWorkerRegistration, - RunnerRequirements, ->( +export function runOutboxPollingLoop( input: RunOutboxPollingLoopInput, runCycle: OutboxCycleRunner, ): Effect.Effect>; -export function runOutboxPollingLoop< - Registration extends AnyOutboxWorkerRegistration, - RunnerRequirements, ->( +export function runOutboxPollingLoop( input: RunOutboxPollingLoopInput, runCycle?: OutboxCycleRunner, -): Effect.Effect< - void, - never, - OutboxRuntime | RunnerRequirements | OutboxWorkerRequirements -> { +): Effect.Effect> { const cycleInput = { claimOwner: input.config.claimOwner, maxDeliveries: input.config.maxDeliveries, @@ -187,8 +149,5 @@ export function runOutboxPollingLoop< }), ); - return tick.pipe( - Effect.repeat(Schedule.spaced(Duration.millis(input.config.pollIntervalMs))), - Effect.asVoid, - ); + return tick.pipe(Effect.repeat(Schedule.spaced(Duration.millis(input.config.pollIntervalMs))), Effect.asVoid); } diff --git a/app/packages/core-runtime/src/outbox/process.ts b/app/packages/core-runtime/src/outbox/process.ts index 253ddc6d8..e974c749d 100644 --- a/app/packages/core-runtime/src/outbox/process.ts +++ b/app/packages/core-runtime/src/outbox/process.ts @@ -12,11 +12,8 @@ import { Tracer, } from 'effect'; import type { Layer } from 'effect'; -import type { - AnyOutboxWorkerRegistration, - OutboxWorkerRequirements, - OutboxWorkerSubscription, -} from './definition.ts'; + +import type { AnyOutboxWorkerRegistration, OutboxWorkerRequirements, OutboxWorkerSubscription } from './definition.ts'; import type { createOutboxWorkerHealth, serveOutboxWorkerHealth } from './health.ts'; import { parseOutboxPollingConfig, runOutboxPollingLoop } from './poller.ts'; import type { RunOutboxPollingLoopInput } from './poller.ts'; @@ -91,7 +88,9 @@ export const runOutboxWorkerProcess = ('SIGTERM')), - ), + Effect.raceFirst(runOutboxPollingLoop(pollingInput).pipe(Effect.as('SIGTERM'))), ); yield* Effect.logInfo(`Outbox Worker process received ${signal}; shutting down`); }), ); -export const startOutboxWorkerProcess = < - Registration extends AnyOutboxWorkerRegistration, - LayerError, ->( +export const startOutboxWorkerProcess = ( input: StartOutboxWorkerProcessInput, ): void => { let processInput: RunOutboxWorkerProcessInput = { diff --git a/app/packages/core-runtime/src/outbox/repository.ts b/app/packages/core-runtime/src/outbox/repository.ts index 930d64eec..6ebbd92ea 100644 --- a/app/packages/core-runtime/src/outbox/repository.ts +++ b/app/packages/core-runtime/src/outbox/repository.ts @@ -1,9 +1,10 @@ -import type { CoreTransaction, CoreDatabaseExecutor } from '../db/types.ts'; +import { randomUUID } from 'node:crypto'; + import { and, asc, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'; import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; import { Context, DateTime, Effect, Layer, Option, Schema } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; -import { randomUUID } from 'node:crypto'; + import { CoreDatabase } from '../db/client.ts'; import { actionInvocations, @@ -15,27 +16,14 @@ import { tenants, workerCheckpoints, } from '../db/schema.ts'; - +import type { CoreTransaction, CoreDatabaseExecutor } from '../db/types.ts'; import { tenantStatesAllowingAccess } from '../modules/module-state-gate.ts'; -import type { - AnyOutboxWorkerRegistration, - OutboxWorkerRetryPolicy, - OutboxWorkerSubscription, -} from './definition.ts'; +import type { AnyOutboxWorkerRegistration, OutboxWorkerRetryPolicy, OutboxWorkerSubscription } from './definition.ts'; import { retryBackoffMs } from './definition.ts'; import type { OutboxPersistenceError } from './errors.ts'; -import { - OutboxClaimLostError, - outboxPersistenceError, - sanitizeOutboxErrorMessage, -} from './errors.ts'; +import { OutboxClaimLostError, outboxPersistenceError, sanitizeOutboxErrorMessage } from './errors.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -91,17 +79,15 @@ export class OutboxRepository extends Context.Service(error: Failure) => Schema.is(OutboxClaimLostError)(error) ? error : outboxPersistenceError(error); -const OutboxRepositoryInvariantError = Schema.TaggedError()( - 'OutboxRepositoryInvariantError', - { reason: Schema.String }, -); +const OutboxRepositoryInvariantError = Schema.TaggedError()('OutboxRepositoryInvariantError', { + reason: Schema.String, +}); const claimLost = (): OutboxClaimLostError => new OutboxClaimLostError({ code: 'outbox_claim_lost', reason: 'The Outbox delivery claim is no longer owned by this runtime', }); -const streamKeyFor = (producerModuleKey: string, topic: string): string => - `${producerModuleKey}:${topic}`; +const streamKeyFor = (producerModuleKey: string, topic: string): string => `${producerModuleKey}:${topic}`; const addMilliseconds = (date: Date, milliseconds: number): Date => DateTime.toDateUtc(DateTime.addDuration(DateTime.makeUnsafe(date), milliseconds)); const loadClaimCorrelationId = Effect.fnUntraced(function* loadClaimCorrelationId( @@ -121,11 +107,9 @@ const loadClaimCorrelationId = Effect.fnUntraced(function* loadClaimCorrelationI export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepositoryService => ({ claimNext: (registrations, claimOwner, now) => { if (registrations.length === 0) { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } - const byWorkerKey = new Map( - registrations.map((registration) => [registration.descriptor.workerKey, registration]), - ); + const byWorkerKey = new Map(registrations.map((registration) => [registration.descriptor.workerKey, registration])); return executor .transaction( Effect.fn('claimNextEffect')(function* claimNextEffect(transaction: CoreTransaction) { @@ -146,10 +130,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo workerKey: outboxDeliveries.workerKey, }) .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) + .innerJoin(outboxMessages, eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId)) .innerJoin(domainEvents, eq(domainEvents.domainEventId, outboxMessages.domainEventId)) .innerJoin( tenantModuleStates, @@ -163,14 +144,8 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo and( inArray(outboxDeliveries.workerKey, [...byWorkerKey.keys()]), or( - and( - eq(outboxDeliveries.status, 'pending'), - lte(outboxDeliveries.availableAt, now), - ), - and( - eq(outboxDeliveries.status, 'processing'), - lte(outboxDeliveries.claimExpiresAt, now), - ), + and(eq(outboxDeliveries.status, 'pending'), lte(outboxDeliveries.availableAt, now)), + and(eq(outboxDeliveries.status, 'processing'), lte(outboxDeliveries.claimExpiresAt, now)), ), ), ) @@ -196,12 +171,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo errorMessage: 'Outbox Worker lease expired before completion', finishedAt: now, }) - .where( - and( - eq(outboxAttempts.outboxDeliveryId, candidate.deliveryId), - isNull(outboxAttempts.finishedAt), - ), - ); + .where(and(eq(outboxAttempts.outboxDeliveryId, candidate.deliveryId), isNull(outboxAttempts.finishedAt))); } if (candidate.attemptsCount >= registration.descriptor.retryPolicy.maxAttempts) { yield* transaction @@ -244,10 +214,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo reason: 'Attempt insert returned no row', }); } - const correlationId = yield* loadClaimCorrelationId( - transaction, - candidate.actionInvocationId, - ); + const correlationId = yield* loadClaimCorrelationId(transaction, candidate.actionInvocationId); return Option.some( withOptionalProperty( { @@ -276,9 +243,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.mapError(outboxPersistenceError), ); }, @@ -308,12 +273,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo const finishedAttempts = yield* transaction .update(outboxAttempts) .set({ finishedAt: now }) - .where( - and( - eq(outboxAttempts.outboxAttemptId, claim.attemptId), - isNull(outboxAttempts.finishedAt), - ), - ) + .where(and(eq(outboxAttempts.outboxAttemptId, claim.attemptId), isNull(outboxAttempts.finishedAt))) .returning({ attemptId: outboxAttempts.outboxAttemptId }); if (finishedAttempts.length !== 1) { return yield* claimLost(); @@ -340,7 +300,9 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo } const streamKey = streamKeyFor(claim.producerModuleKey, claim.topic); const [checkpoint] = yield* transaction - .select({ lastTenantSequenceNo: workerCheckpoints.lastTenantSequenceNo }) + .select({ + lastTenantSequenceNo: workerCheckpoints.lastTenantSequenceNo, + }) .from(workerCheckpoints) .where( and( @@ -357,10 +319,7 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo tenantSequenceNo: domainEvents.tenantSequenceNo, }) .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) + .innerJoin(outboxMessages, eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId)) .innerJoin(domainEvents, eq(domainEvents.domainEventId, outboxMessages.domainEventId)) .where( and( @@ -396,20 +355,14 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo lastTenantSequenceNo: nextCheckpoint, updatedAt: now, }, - target: [ - workerCheckpoints.tenantId, - workerCheckpoints.consumerName, - workerCheckpoints.streamKey, - ], + target: [workerCheckpoints.tenantId, workerCheckpoints.consumerName, workerCheckpoints.streamKey], }); } return yield* Effect.void; }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.mapError(claimLostOrPersistenceError), ), fail: (claim, safeErrorMessage, now) => @@ -436,22 +389,14 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo errorMessage: sanitizeOutboxErrorMessage(safeErrorMessage), finishedAt: now, }) - .where( - and( - eq(outboxAttempts.outboxAttemptId, claim.attemptId), - isNull(outboxAttempts.finishedAt), - ), - ) + .where(and(eq(outboxAttempts.outboxAttemptId, claim.attemptId), isNull(outboxAttempts.finishedAt))) .returning({ attemptId: outboxAttempts.outboxAttemptId }); if (finishedAttempts.length !== 1) { return yield* claimLost(); } - const status: OutboxFailureStatus = - claim.attemptNumber >= claim.retryPolicy.maxAttempts ? 'dead' : 'pending'; + const status: OutboxFailureStatus = claim.attemptNumber >= claim.retryPolicy.maxAttempts ? 'dead' : 'pending'; const availableAt = - status === 'dead' - ? now - : addMilliseconds(now, retryBackoffMs(claim.retryPolicy, claim.attemptNumber)); + status === 'dead' ? now : addMilliseconds(now, retryBackoffMs(claim.retryPolicy, claim.attemptNumber)); const updated = yield* transaction .update(outboxDeliveries) .set({ @@ -477,17 +422,13 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.mapError(claimLostOrPersistenceError), ), matchUnmatched: (subscriptions, now) => executor .transaction( - Effect.fn('matchUnmatchedEffect')(function* matchUnmatchedEffect( - transaction: CoreTransaction, - ) { + Effect.fn('matchUnmatchedEffect')(function* matchUnmatchedEffect(transaction: CoreTransaction) { const messages = yield* transaction .select({ messageId: outboxMessages.outboxMessageId, @@ -499,55 +440,45 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo .orderBy(asc(outboxMessages.createdAt), asc(outboxMessages.outboxMessageId)) .limit(100) .for('update', { skipLocked: true }); - const matchMessage = Effect.fn('OutboxRepository.matchMessage')( - function* matchNextMessage( - messageIndex: number, - deliveriesCreated: number, - ): Effect.fn.Return { - const message = messages[messageIndex]; - if (message === undefined) { - return deliveriesCreated; - } - const matches = subscriptions.filter( - (subscription) => - subscription.producerModuleKey === message.producerModuleKey && - subscription.topic === message.topic, - ); - let nextDeliveriesCreated = deliveriesCreated; - if (matches.length > 0) { - const inserted = yield* transaction - .insert(outboxDeliveries) - .values( - matches.map((subscription) => ({ - consumerModuleKey: subscription.consumerModuleKey, - outboxMessageId: message.messageId, - workerKey: subscription.workerKey, - })), - ) - .onConflictDoNothing() - .returning({ deliveryId: outboxDeliveries.outboxDeliveryId }); - nextDeliveriesCreated += inserted.length; - } - yield* transaction - .update(outboxMessages) - .set({ matchedAt: now }) - .where( - and( - eq(outboxMessages.outboxMessageId, message.messageId), - isNull(outboxMessages.matchedAt), - ), - ); - return yield* matchMessage(messageIndex + 1, nextDeliveriesCreated); - }, - ); + const matchMessage = Effect.fn('OutboxRepository.matchMessage')(function* matchNextMessage( + messageIndex: number, + deliveriesCreated: number, + ): Effect.fn.Return { + const message = messages[messageIndex]; + if (message === undefined) { + return deliveriesCreated; + } + const matches = subscriptions.filter( + (subscription) => + subscription.producerModuleKey === message.producerModuleKey && subscription.topic === message.topic, + ); + let nextDeliveriesCreated = deliveriesCreated; + if (matches.length > 0) { + const inserted = yield* transaction + .insert(outboxDeliveries) + .values( + matches.map((subscription) => ({ + consumerModuleKey: subscription.consumerModuleKey, + outboxMessageId: message.messageId, + workerKey: subscription.workerKey, + })), + ) + .onConflictDoNothing() + .returning({ deliveryId: outboxDeliveries.outboxDeliveryId }); + nextDeliveriesCreated += inserted.length; + } + yield* transaction + .update(outboxMessages) + .set({ matchedAt: now }) + .where(and(eq(outboxMessages.outboxMessageId, message.messageId), isNull(outboxMessages.matchedAt))); + return yield* matchMessage(messageIndex + 1, nextDeliveriesCreated); + }); const deliveriesCreated = yield* matchMessage(0, 0); return { deliveriesCreated, messagesMatched: messages.length }; }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.mapError(outboxPersistenceError), ), }); diff --git a/app/packages/core-runtime/src/outbox/runtime.ts b/app/packages/core-runtime/src/outbox/runtime.ts index 969e8c6b2..51d458b54 100644 --- a/app/packages/core-runtime/src/outbox/runtime.ts +++ b/app/packages/core-runtime/src/outbox/runtime.ts @@ -2,6 +2,7 @@ /* eslint-disable unicorn/no-array-method-this-argument -- Effect's dual flatMap API is intentional. expires: 2026-12-31. */ // @effect-diagnostics effectFnOpportunity:off globalDateInEffect:off instanceOfSchema:off -- Existing compatibility boundary; expires: 2026-12-31. import { Context, DateTime, Effect, Exit, Layer, Option, Schema } from 'effect'; + import type { AnyOutboxWorkerRegistration, OutboxWorkerHandlerContext, @@ -14,21 +15,12 @@ import { validateOutboxWorkerRegistrations, validateOutboxWorkerSubscriptions, } from './definition.ts'; -import { - OutboxHandlerExecutionError, - OutboxPayloadDecodeError, - OutboxWorkerDescriptorError, -} from './errors.ts'; +import { OutboxHandlerExecutionError, OutboxPayloadDecodeError, OutboxWorkerDescriptorError } from './errors.ts'; import type { OutboxClaimLostError, OutboxPersistenceError } from './errors.ts'; import { OutboxRepository } from './repository.ts'; import type { OutboxClaim, OutboxRepositoryService as OutboxRepositoryPort } from './repository.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -36,9 +28,7 @@ const withOptionalProperty = < trailing: Trailing, ) => (condition ? { ...base, [key]: value, ...trailing } : { ...base, ...trailing }); -export interface RunOutboxCycleInput< - Registration extends AnyOutboxWorkerRegistration = AnyOutboxWorkerRegistration, -> { +export interface RunOutboxCycleInput { readonly claimOwner: string; readonly maxDeliveries?: number; readonly now?: Date; @@ -66,10 +56,7 @@ export interface OutboxCycleResult { readonly succeeded: number; } -export type OutboxCycleError = - | OutboxClaimLostError - | OutboxPersistenceError - | OutboxWorkerDescriptorError; +export type OutboxCycleError = OutboxClaimLostError | OutboxPersistenceError | OutboxWorkerDescriptorError; export interface OutboxRuntimeService { readonly matchMessages: ( @@ -81,38 +68,39 @@ export interface OutboxRuntimeService { } const descriptorFailure = (reason: string): OutboxWorkerDescriptorError => - new OutboxWorkerDescriptorError({ code: 'outbox_worker_descriptor_invalid', reason }); + new OutboxWorkerDescriptorError({ + code: 'outbox_worker_descriptor_invalid', + reason, + }); -const validateCycleInput = Effect.fn('OutboxRuntime.validateCycleInput')( - function* validateCycleInputEffect( - input: RunOutboxCycleInput, - ) { - if (input.claimOwner.trim().length === 0 || input.claimOwner.length > 200) { - return yield* descriptorFailure('claimOwner must be a non-empty stable runtime identity'); - } - const maxDeliveries = input.maxDeliveries ?? 100; - if (!Number.isSafeInteger(maxDeliveries) || maxDeliveries < 1 || maxDeliveries > 1000) { - return yield* descriptorFailure('maxDeliveries must be an integer from 1 through 1000'); - } - const now = input.now ?? (yield* DateTime.nowAsDate); - if (Number.isNaN(now.getTime())) { - return yield* descriptorFailure('now must be a valid timestamp'); - } - const registrations = yield* Effect.try({ - catch: (error) => - Schema.is(OutboxWorkerDescriptorError)(error) - ? error - : descriptorFailure('The Outbox Worker descriptor set is invalid'), - try: () => validateOutboxWorkerRegistrations(input.registrations), - }); - return { - claimOwner: input.claimOwner, - maxDeliveries, - now, - registrations, - }; - }, -); +const validateCycleInput = Effect.fn('OutboxRuntime.validateCycleInput')(function* validateCycleInputEffect< + Registration extends AnyOutboxWorkerRegistration, +>(input: RunOutboxCycleInput) { + if (input.claimOwner.trim().length === 0 || input.claimOwner.length > 200) { + return yield* descriptorFailure('claimOwner must be a non-empty stable runtime identity'); + } + const maxDeliveries = input.maxDeliveries ?? 100; + if (!Number.isSafeInteger(maxDeliveries) || maxDeliveries < 1 || maxDeliveries > 1000) { + return yield* descriptorFailure('maxDeliveries must be an integer from 1 through 1000'); + } + const now = input.now ?? (yield* DateTime.nowAsDate); + if (Number.isNaN(now.getTime())) { + return yield* descriptorFailure('now must be a valid timestamp'); + } + const registrations = yield* Effect.try({ + catch: (error) => + Schema.is(OutboxWorkerDescriptorError)(error) + ? error + : descriptorFailure('The Outbox Worker descriptor set is invalid'), + try: () => validateOutboxWorkerRegistrations(input.registrations), + }); + return { + claimOwner: input.claimOwner, + maxDeliveries, + now, + registrations, + }; +}); const claimAnnotations = (claim: OutboxClaim, outcome?: string) => withOptionalProperty( @@ -144,9 +132,7 @@ const claimAnnotations = (claim: OutboxClaim, outcome?: string) => const logUnexpectedPersistence = (claim?: OutboxClaim) => Effect.annotateLogs( Effect.logError('Unexpected Outbox persistence failure'), - claim === undefined - ? { outcome: 'persistence_failure' } - : claimAnnotations(claim, 'persistence_failure'), + claim === undefined ? { outcome: 'persistence_failure' } : claimAnnotations(claim, 'persistence_failure'), ); const withOutcomeSpan = ( @@ -197,33 +183,26 @@ const subscriptionMatchesRegistration = ( subscription.producerModuleKey === registration.descriptor.producerModuleKey && subscription.topic === registration.descriptor.topic; -const validateDeployedRegistrationSnapshot = Effect.fn( - 'OutboxRuntime.validateDeployedRegistrationSnapshot', -)(function* validateDeployedRegistrationSnapshotEffect( - registrations: readonly AnyOutboxWorkerRegistration[], - subscriptions: readonly OutboxWorkerSubscription[], -) { - const subscriptionsByKey = new Map( - subscriptions.map((subscription) => [subscription.workerKey, subscription]), - ); - for (const registration of registrations) { - if ( - !subscriptionMatchesRegistration( - subscriptionsByKey.get(registration.descriptor.workerKey), - registration, - ) - ) { +const validateDeployedRegistrationSnapshot = Effect.fn('OutboxRuntime.validateDeployedRegistrationSnapshot')( + function* validateDeployedRegistrationSnapshotEffect( + registrations: readonly AnyOutboxWorkerRegistration[], + subscriptions: readonly OutboxWorkerSubscription[], + ) { + const subscriptionsByKey = new Map(subscriptions.map((subscription) => [subscription.workerKey, subscription])); + for (const registration of registrations) { + if (!subscriptionMatchesRegistration(subscriptionsByKey.get(registration.descriptor.workerKey), registration)) { + return yield* descriptorFailure( + `worker ${registration.descriptor.workerKey} is absent from the installed subscription catalog`, + ); + } + } + if (subscriptions.length !== registrations.length) { return yield* descriptorFailure( - `worker ${registration.descriptor.workerKey} is absent from the installed subscription catalog`, + 'the owner-local worker registration set contradicts its deployed descriptor snapshot', ); } - } - if (subscriptions.length !== registrations.length) { - return yield* descriptorFailure( - 'the owner-local worker registration set contradicts its deployed descriptor snapshot', - ); - } -}); + }, +); interface OutboxCycleProgress { readonly claimed: number; @@ -251,10 +230,7 @@ interface OutboxCycleExecution } const matchMessagesWithRepository = Effect.fn('makeOutboxRuntime.matchMessages')( - function* matchMessagesWithRepositoryEffect( - repository: OutboxRepositoryPort, - input: MatchOutboxMessagesInput, - ) { + function* matchMessagesWithRepositoryEffect(repository: OutboxRepositoryPort, input: MatchOutboxMessagesInput) { const subscriptions = yield* Effect.try({ catch: (error) => { void error; @@ -266,33 +242,29 @@ const matchMessagesWithRepository = Effect.fn('makeOutboxRuntime.matchMessages') if (Number.isNaN(now.getTime())) { return yield* descriptorFailure('now must be a valid timestamp'); } - return yield* repository - .matchUnmatched(subscriptions, now) - .pipe(Effect.tapError(() => logUnexpectedPersistence())); + return yield* repository.matchUnmatched(subscriptions, now).pipe(Effect.tapError(() => logUnexpectedPersistence())); }, ); -const failOutboxDelivery = Effect.fn('OutboxRuntime.failDelivery')( - function* failOutboxDeliveryEffect( - repository: OutboxRepositoryPort, - claim: OutboxClaim, - now: Date, - state: OutboxCycleProgress, - reason: string, - outcome: string, - ) { - const status = yield* repository.fail(claim, reason, now).pipe( - Effect.tapErrorTag('OutboxPersistenceError', () => logUnexpectedPersistence(claim)), - (effect) => withOutcomeSpan(effect, claim, outcome), - ); - return { - ...state, - dead: state.dead + (status === 'dead' ? 1 : 0), - failed: state.failed + 1, - retried: state.retried + (status === 'pending' ? 1 : 0), - }; - }, -); +const failOutboxDelivery = Effect.fn('OutboxRuntime.failDelivery')(function* failOutboxDeliveryEffect( + repository: OutboxRepositoryPort, + claim: OutboxClaim, + now: Date, + state: OutboxCycleProgress, + reason: string, + outcome: string, +) { + const status = yield* repository.fail(claim, reason, now).pipe( + Effect.tapErrorTag('OutboxPersistenceError', () => logUnexpectedPersistence(claim)), + (effect) => withOutcomeSpan(effect, claim, outcome), + ); + return { + ...state, + dead: state.dead + (status === 'dead' ? 1 : 0), + failed: state.failed + 1, + retried: state.retried + (status === 'pending' ? 1 : 0), + }; +}); const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelivery')( function* processNextOutboxDeliveryEffect( @@ -310,9 +282,7 @@ const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelive const claimedState = { ...state, claimed: state.claimed + 1 }; const registration = execution.registrationsByKey.get(claim.workerKey); if (registration === undefined) { - return yield* descriptorFailure( - `claimed delivery references unknown worker ${claim.workerKey}`, - ); + return yield* descriptorFailure(`claimed delivery references unknown worker ${claim.workerKey}`); } const decoded = yield* Effect.exit( Schema.decodeUnknownEffect(registration.descriptor.payloadSchema)(claim.payloadJson), @@ -342,7 +312,9 @@ const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelive }, onSuccess: () => 'success' as const, }), - Effect.withSpan('OutboxWorker.handle', { attributes: claimAnnotations(claim) }), + Effect.withSpan('OutboxWorker.handle', { + attributes: claimAnnotations(claim), + }), ), ); if (Exit.isFailure(handlerExit)) { @@ -376,57 +348,47 @@ const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelive }, ); -const runCycleWithRepository = Effect.fn('makeOutboxRuntime.runCycle')( - function* runCycleWithRepositoryEffect( - repository: OutboxRepositoryPort, - input: RunOutboxCycleInput, - ) { - const validated = yield* validateCycleInput(input); - const deployedSubscriptions = yield* Effect.try({ - catch: (error) => { - void error; - return descriptorFailure('The deployed subscription snapshot is invalid'); - }, - try: () => validateOutboxWorkerSubscriptions(input.subscriptions), - }); - yield* validateDeployedRegistrationSnapshot(validated.registrations, deployedSubscriptions); - const registrationsByKey = new Map( - validated.registrations.map( - (registration) => [registration.descriptor.workerKey, registration] as const, - ), - ); - const execution: OutboxCycleExecution = { - claimOwner: validated.claimOwner, - now: validated.now, - registrations: validated.registrations, - registrationsByKey, - }; - const progress = yield* Effect.reduce( - Array.from({ length: validated.maxDeliveries }), - initialCycleProgress, - (state) => - state.stopped - ? Effect.succeed(state) - : processNextOutboxDelivery(repository, execution, state), - ); +const runCycleWithRepository = Effect.fn('makeOutboxRuntime.runCycle')(function* runCycleWithRepositoryEffect< + Registration extends AnyOutboxWorkerRegistration, +>(repository: OutboxRepositoryPort, input: RunOutboxCycleInput) { + const validated = yield* validateCycleInput(input); + const deployedSubscriptions = yield* Effect.try({ + catch: (error) => { + void error; + return descriptorFailure('The deployed subscription snapshot is invalid'); + }, + try: () => validateOutboxWorkerSubscriptions(input.subscriptions), + }); + yield* validateDeployedRegistrationSnapshot(validated.registrations, deployedSubscriptions); + const registrationsByKey = new Map( + validated.registrations.map((registration) => [registration.descriptor.workerKey, registration] as const), + ); + const execution: OutboxCycleExecution = { + claimOwner: validated.claimOwner, + now: validated.now, + registrations: validated.registrations, + registrationsByKey, + }; + const progress = yield* Effect.reduce( + Array.from({ length: validated.maxDeliveries }), + initialCycleProgress, + (state) => (state.stopped ? Effect.succeed(state) : processNextOutboxDelivery(repository, execution, state)), + ); - return Object.freeze({ - claimed: progress.claimed, - dead: progress.dead, - deliveriesCreated: 0, - failed: progress.failed, - messagesMatched: 0, - retried: progress.retried, - succeeded: progress.succeeded, - }); - }, -); + return Object.freeze({ + claimed: progress.claimed, + dead: progress.dead, + deliveriesCreated: 0, + failed: progress.failed, + messagesMatched: 0, + retried: progress.retried, + succeeded: progress.succeeded, + }); +}); export const makeOutboxRuntime = (repository: OutboxRepositoryPort): OutboxRuntimeService => { const matchMessages: OutboxRuntimeService['matchMessages'] = (input) => - matchMessagesWithRepository(repository, input).pipe( - Effect.withSpan('OutboxMatcher.matchMessages'), - ); + matchMessagesWithRepository(repository, input).pipe(Effect.withSpan('OutboxMatcher.matchMessages')); const runCycle: OutboxRuntimeService['runCycle'] = (input) => runCycleWithRepository(repository, input).pipe( Effect.withSpan('OutboxWorker.runCycle', { @@ -451,16 +413,10 @@ export const OutboxRuntimeLive = Layer.effect( export const runOutboxCycle = ( input: RunOutboxCycleInput, -): Effect.Effect< - OutboxCycleResult, - OutboxCycleError, - OutboxRuntime | OutboxWorkerRequirements -> => Effect.flatMap(OutboxRuntime, (runtime) => runtime.runCycle(input)); +): Effect.Effect> => + Effect.flatMap(OutboxRuntime, (runtime) => runtime.runCycle(input)); export const matchOutboxMessages = ( input: MatchOutboxMessagesInput, -): Effect.Effect< - OutboxMatchResult, - OutboxPersistenceError | OutboxWorkerDescriptorError, - OutboxRuntime -> => Effect.flatMap(OutboxRuntime, (runtime) => runtime.matchMessages(input)); +): Effect.Effect => + Effect.flatMap(OutboxRuntime, (runtime) => runtime.matchMessages(input)); diff --git a/app/packages/core-runtime/src/outbox/worker-entrypoint.ts b/app/packages/core-runtime/src/outbox/worker-entrypoint.ts index bb5d8661d..5deebc19e 100644 --- a/app/packages/core-runtime/src/outbox/worker-entrypoint.ts +++ b/app/packages/core-runtime/src/outbox/worker-entrypoint.ts @@ -10,10 +10,7 @@ export { CoreSearchProjectionMutationSchema, CoreSearchProjectionStore, } from '../search/projection.ts'; -export { - CoreSearchWorkerSnapshot, - CoreSearchWorkerSnapshotLive, -} from '../search/worker-snapshot.ts'; +export { CoreSearchWorkerSnapshot, CoreSearchWorkerSnapshotLive } from '../search/worker-snapshot.ts'; export { defineOutboxWorker, extractOutboxWorkerSubscriptions } from './definition.ts'; export { OutboxWorkerInfrastructureLive, startOutboxWorkerProcess } from './process.ts'; export { OutboxRepositoryLive } from './repository.ts'; diff --git a/app/packages/core-runtime/src/permissions/client.ts b/app/packages/core-runtime/src/permissions/client.ts index 62994256d..c88dc2d12 100644 --- a/app/packages/core-runtime/src/permissions/client.ts +++ b/app/packages/core-runtime/src/permissions/client.ts @@ -1,9 +1,10 @@ import { deadlineInterceptor, v1 } from '@authzed/authzed-node'; import { Cause, Duration, Effect, Schema } from 'effect'; import type { Scope } from 'effect'; + +import { SpiceDbConfigError } from './config-error.ts'; import { allowsInsecureSpiceDbTransport } from './config.ts'; import type { SpiceDbConfigValue } from './config.ts'; -import { SpiceDbConfigError } from './config-error.ts'; export const SPICEDB_CHECK_TIMEOUT_MS = 2000; @@ -45,10 +46,7 @@ export interface SpiceDbPermissionClient extends CloseableSpiceDbClient { const permissionTimeout = Effect.timeoutOrElse({ duration: Duration.millis(SPICEDB_CHECK_TIMEOUT_MS), - orElse: () => - Effect.fail( - spiceDbPermissionClientError(new Cause.TimeoutError('SpiceDB client operation timed out')), - ), + orElse: () => Effect.fail(spiceDbPermissionClientError(new Cause.TimeoutError('SpiceDB client operation timed out'))), }); export const spiceDbClientSecurity = ( @@ -59,9 +57,7 @@ export const spiceDbClientSecurity = ( reason: 'Insecure SpiceDB client credentials are not allowed for this endpoint', }); } - return configuration.insecureLocal - ? v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS - : v1.ClientSecurity.SECURE; + return configuration.insecureLocal ? v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS : v1.ClientSecurity.SECURE; }; export const createSpiceDbPermissionClient = ( @@ -96,6 +92,4 @@ export const acquireSpiceDbClientResource = Client, onFailure: (cause: unknown) => Error, ): Effect.Effect => - Effect.acquireRelease(Effect.try({ catch: onFailure, try: acquire }), (client) => - Effect.sync(() => client.close()), - ); + Effect.acquireRelease(Effect.try({ catch: onFailure, try: acquire }), (client) => Effect.sync(() => client.close())); diff --git a/app/packages/core-runtime/src/permissions/config-error.ts b/app/packages/core-runtime/src/permissions/config-error.ts index cd204845d..869018a26 100644 --- a/app/packages/core-runtime/src/permissions/config-error.ts +++ b/app/packages/core-runtime/src/permissions/config-error.ts @@ -1,8 +1,5 @@ import { Schema } from 'effect'; -export class SpiceDbConfigError extends Schema.TaggedError()( - 'SpiceDbConfigError', - { - reason: Schema.String, - }, -) {} +export class SpiceDbConfigError extends Schema.TaggedError()('SpiceDbConfigError', { + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/permissions/config.ts b/app/packages/core-runtime/src/permissions/config.ts index d6787cac8..916139838 100644 --- a/app/packages/core-runtime/src/permissions/config.ts +++ b/app/packages/core-runtime/src/permissions/config.ts @@ -1,4 +1,5 @@ import { Config, ConfigProvider, Effect, Option, Redacted, Schema } from 'effect'; + import { loadDotEnvProvider } from '../environment/dotenv-provider.ts'; import { APP_ENV_PATH } from '../environment/workspace-environment.ts'; import { SpiceDbConfigError } from './config-error.ts'; @@ -20,7 +21,11 @@ const makeSpiceDbConfigValue = (settings: { }; return settings.deploymentEnvironment === undefined ? Object.freeze(base) - : Object.freeze(Object.assign(base, { deploymentEnvironment: settings.deploymentEnvironment })); + : Object.freeze( + Object.assign(base, { + deploymentEnvironment: settings.deploymentEnvironment, + }), + ); }; export type SpiceDbConfigValue = ReturnType & @@ -29,10 +34,7 @@ export type SpiceDbConfigValue = ReturnType & export type SpiceDbEnvironment = Readonly< Partial< Record< - | 'SPICEDB_ENDPOINT' - | 'SPICEDB_INSECURE' - | 'SPICEDB_PRESHARED_KEY' - | 'ULTRAMODERN_DEPLOYMENT_ENVIRONMENT', + 'SPICEDB_ENDPOINT' | 'SPICEDB_INSECURE' | 'SPICEDB_PRESHARED_KEY' | 'ULTRAMODERN_DEPLOYMENT_ENVIRONMENT', string > > @@ -108,9 +110,7 @@ const parseSpiceDbConfigWith = Effect.fn('Config.parseSpiceDbConfigWith')(functi ), endpoint: Config.schema(Schema.Trim, 'SPICEDB_ENDPOINT') .parse(provider) - .pipe( - Effect.mapError((error) => configFailureWithCause('SPICEDB_ENDPOINT is required', error)), - ), + .pipe(Effect.mapError((error) => configFailureWithCause('SPICEDB_ENDPOINT is required', error))), insecureFlag: Config.schema(Schema.Trim, 'SPICEDB_INSECURE') .pipe(Config.map((value) => value.toLowerCase())) .parse(provider) @@ -122,11 +122,7 @@ const parseSpiceDbConfigWith = Effect.fn('Config.parseSpiceDbConfigWith')(functi preSharedKey: Config.redacted('SPICEDB_PRESHARED_KEY') .pipe(Config.map((value) => Redacted.make(Redacted.value(value).trim()))) .parse(provider) - .pipe( - Effect.mapError((error) => - configFailureWithCause('SPICEDB_PRESHARED_KEY is required', error), - ), - ), + .pipe(Effect.mapError((error) => configFailureWithCause('SPICEDB_PRESHARED_KEY is required', error))), }, { concurrency: 4 }, ); @@ -169,13 +165,13 @@ export const loadSpiceDbConfig = ( const environmentProvider = options.environment === undefined ? ConfigProvider.fromEnv({ preserveEmptyStrings: true }) - : ConfigProvider.fromUnknown(options.environment, { preserveEmptyStrings: true }); + : ConfigProvider.fromUnknown(options.environment, { + preserveEmptyStrings: true, + }); const envPath = options.envPath ?? SPICEDB_ROOT_ENV_PATH; return loadDotEnvProvider(envPath, configFailureWithCause).pipe( Effect.withSpan('Config.loadFileConfigProvider'), - Effect.flatMap((fileProvider) => - parseSpiceDbConfigWith(ConfigProvider.orElse(environmentProvider, fileProvider)), - ), + Effect.flatMap((fileProvider) => parseSpiceDbConfigWith(ConfigProvider.orElse(environmentProvider, fileProvider))), ); }; diff --git a/app/packages/core-runtime/src/permissions/context-access.ts b/app/packages/core-runtime/src/permissions/context-access.ts index 27efef050..794e217cc 100644 --- a/app/packages/core-runtime/src/permissions/context-access.ts +++ b/app/packages/core-runtime/src/permissions/context-access.ts @@ -1,6 +1,7 @@ import { v1 } from '@authzed/authzed-node'; import { Context, Effect, Layer, Result, Schema } from 'effect'; import type { Scope } from 'effect'; + import { SPICEDB_CHECK_TIMEOUT_MS, acquireSpiceDbClientResource, @@ -9,9 +10,9 @@ import { spiceDbPermissionClientError, } from './client.ts'; import type { SpiceDbPermissionClient } from './client.ts'; +import type { SpiceDbConfigError } from './config-error.ts'; import { loadSpiceDbConfig } from './config.ts'; import type { SpiceDbConfigValue } from './config.ts'; -import type { SpiceDbConfigError } from './config-error.ts'; const ContextAccessDecisionSchema = Schema.Literals(['allowed', 'denied', 'unavailable']); export type ContextAccessDecision = typeof ContextAccessDecisionSchema.Type; @@ -27,11 +28,7 @@ export const TENANT_PERMISSION_KEYS = [ 'review_party_identity', ] as const; export type TenantPermissionKey = (typeof TENANT_PERMISSION_KEYS)[number]; -export const LEGAL_ENTITY_PERMISSION_KEYS = [ - 'access', - 'manage_counterparty', - 'read_counterparty', -] as const; +export const LEGAL_ENTITY_PERMISSION_KEYS = ['access', 'manage_counterparty', 'read_counterparty'] as const; export type LegalEntityPermissionKey = (typeof LEGAL_ENTITY_PERMISSION_KEYS)[number]; export interface ContextAccessResult { @@ -93,7 +90,10 @@ const encodeContextAccessObjectIdParts = Schema.encodeResult(ContextAccessObject const principalReference = (principalId: string) => v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: principalId, objectType: 'principal' }), + object: v1.ObjectReference.create({ + objectId: principalId, + objectType: 'principal', + }), }); const encodeObjectId = (parts: readonly string[]): string | undefined => { @@ -105,29 +105,18 @@ const encodeObjectId = (parts: readonly string[]): string | undefined => { return encoded.length <= 1024 ? encoded : undefined; }; -export const toLegalEntityAccessObjectId = ( - tenantId: string, - legalEntityId: string, -): string | undefined => encodeObjectId([tenantId, legalEntityId]); +export const toLegalEntityAccessObjectId = (tenantId: string, legalEntityId: string): string | undefined => + encodeObjectId([tenantId, legalEntityId]); -export const toModuleAccessObjectId = ( - tenantId: string, - legalEntityId: string, - moduleId: string, -): string | undefined => encodeObjectId([tenantId, legalEntityId, moduleId]); +export const toModuleAccessObjectId = (tenantId: string, legalEntityId: string, moduleId: string): string | undefined => + encodeObjectId([tenantId, legalEntityId, moduleId]); export const toResourceAccessObjectId = ( tenantId: string, legalEntityId: string, resource: ResourceAccessTarget, ): string | undefined => - encodeObjectId([ - tenantId, - legalEntityId, - resource.moduleId, - resource.resourceType, - resource.resourceId, - ]); + encodeObjectId([tenantId, legalEntityId, resource.moduleId, resource.resourceType, resource.resourceId]); const unavailable = (keys: readonly string[]): readonly ContextAccessResult[] => keys.map((key) => ({ decision: 'unavailable' as const, key })); @@ -159,8 +148,7 @@ const makeRequestItem = (item: BatchItem, principalId: string) => const sameObjectReference = ( expected: v1.ObjectReference | undefined, actual: v1.ObjectReference | undefined, -): boolean => - actual?.objectId === expected?.objectId && actual?.objectType === expected?.objectType; +): boolean => actual?.objectId === expected?.objectId && actual?.objectType === expected?.objectType; const sameRequest = ( expected: v1.CheckBulkPermissionsRequestItem, @@ -204,12 +192,7 @@ export const makeContextAccess = (client: SpiceDbPermissionClient): ContextAcces const decisions = response.pairs.map((pair, index) => { const expected = requests[index]; const key = keys[index]; - if ( - expected === undefined || - key === undefined || - !sameRequest(expected, pair.request) || - seen.has(key) - ) { + if (expected === undefined || key === undefined || !sameRequest(expected, pair.request) || seen.has(key)) { return null; } seen.add(key); @@ -275,9 +258,7 @@ const unavailableContextAccess = (): ContextAccessService => { resources: ({ resources }) => Effect.succeed( unavailable( - resources.map( - ({ moduleId, resourceId, resourceType }) => `${moduleId}:${resourceType}:${resourceId}`, - ), + resources.map(({ moduleId, resourceId, resourceType }) => `${moduleId}:${resourceType}:${resourceId}`), ), ), tenants: ({ tenantIds }) => Effect.succeed(unavailable(tenantIds)), @@ -287,10 +268,7 @@ const unavailableContextAccess = (): ContextAccessService => { export const makeContextAccessLive = ( clientFactory: ContextAccessClientFactory = createSpiceDbPermissionClient, - loadConfiguration: () => Effect.Effect< - SpiceDbConfigValue, - SpiceDbConfigError - > = loadSpiceDbConfig, + loadConfiguration: () => Effect.Effect = loadSpiceDbConfig, ): Effect.Effect => loadConfiguration().pipe( Effect.flatMap((configuration) => @@ -299,9 +277,7 @@ export const makeContextAccessLive = ( spiceDbPermissionClientError, ).pipe( Effect.map(makeContextAccess), - Effect.catchTag('SpiceDbPermissionClientError', () => - Effect.succeed(unavailableContextAccess()), - ), + Effect.catchTag('SpiceDbPermissionClientError', () => Effect.succeed(unavailableContextAccess())), ), ), Effect.catchTag('SpiceDbConfigError', () => Effect.succeed(unavailableContextAccess())), diff --git a/app/packages/core-runtime/src/permissions/service.ts b/app/packages/core-runtime/src/permissions/service.ts index ecc5a1e06..8cbef1ad7 100644 --- a/app/packages/core-runtime/src/permissions/service.ts +++ b/app/packages/core-runtime/src/permissions/service.ts @@ -1,6 +1,7 @@ import { v1 } from '@authzed/authzed-node'; import { Context, Effect, Layer, Predicate, Schema } from 'effect'; import type { Scope } from 'effect'; + import type { ActionTransportMetadata } from '../actions/context.ts'; import { ActionPermissionCheckError } from '../actions/errors.ts'; import { decideAuthorizationRollout } from '../authorization/rollout-decision.ts'; @@ -8,9 +9,6 @@ import type { AuthorizationRolloutDecisionOptions, AuthorizationWouldDenyEvent, } from '../authorization/rollout-decision.ts'; -import { loadSpiceDbConfig } from './config.ts'; -import type { SpiceDbConfigValue } from './config.ts'; -import type { SpiceDbConfigError } from './config-error.ts'; import { SPICEDB_CHECK_TIMEOUT_MS, acquireSpiceDbClientResource, @@ -18,6 +16,9 @@ import { fullyConsistent, } from './client.ts'; import type { SpiceDbPermissionClient } from './client.ts'; +import type { SpiceDbConfigError } from './config-error.ts'; +import { loadSpiceDbConfig } from './config.ts'; +import type { SpiceDbConfigValue } from './config.ts'; export { SPICEDB_CHECK_TIMEOUT_MS } from './client.ts'; @@ -67,14 +68,8 @@ const checkFailure = (cause?: unknown): ActionPermissionCheckError => cause, ); -export const createPermissionCheckClient: PermissionClientFactory = ( - configuration, - timeoutMilliseconds, -) => - createSpiceDbPermissionClient( - configuration, - timeoutMilliseconds, - ) satisfies SpiceDbPermissionClient; +export const createPermissionCheckClient: PermissionClientFactory = (configuration, timeoutMilliseconds) => + createSpiceDbPermissionClient(configuration, timeoutMilliseconds) satisfies SpiceDbPermissionClient; export const acquirePermissionClientResource = ( acquire: () => PermissionCheckClient, @@ -114,11 +109,7 @@ const restrictionRequest = (actionKey: string, principalId: string) => const classifyPermissionship = ( response: Response, ): Effect.Effect<'has' | 'none', ActionPermissionCheckError> => { - if ( - !Predicate.isObjectKeyword(response) || - response === null || - !('permissionship' in response) - ) { + if (!Predicate.isObjectKeyword(response) || response === null || !('permissionship' in response)) { return Effect.fail(checkFailure()); } @@ -136,9 +127,7 @@ const runCheck = ( client: PermissionCheckClient, request: v1.CheckPermissionRequest, ): Effect.Effect<'has' | 'none', ActionPermissionCheckError> => - client - .checkPermission(request) - .pipe(Effect.mapError(checkFailure), Effect.flatMap(classifyPermissionship)); + client.checkPermission(request).pipe(Effect.mapError(checkFailure), Effect.flatMap(classifyPermissionship)); interface ActionPermissionRolloutOptions { readonly emit: (event: AuthorizationWouldDenyEvent) => void; @@ -153,43 +142,37 @@ const actionPermissionService = ( rolloutOptions?: PermissionRollout, ): ActionPermissionService => Object.freeze({ - checkActionPermission: Effect.fn('ActionPermission.checkActionPermission')( - function* checkActionPermissionEffect(input: CheckActionPermissionInput) { - const execution = yield* runCheck( - client, - executionRequest(input.actionKey, input.principalId), - ); - if (execution === 'has') { - return 'allowed' as const; - } - if (rolloutOptions === undefined) { - return 'denied' as const; - } - const restricted = yield* runCheck( - client, - restrictionRequest(input.actionKey, input.principalId), - ); - if (restricted === 'has') { - return 'denied' as const; - } - return yield* Effect.try({ - catch: (cause) => checkFailure(cause), - try: () => - decideAuthorizationRollout( - { - candidate: 'denied', - current: 'allowed', - denialReason: 'missing_policy', - entrypointKey: input.actionKey, - nowEpochMs: rolloutOptions.nowEpochMs(), - policyClass: 'action_execution', - surface: 'action', - }, - { contract: rolloutOptions.rollout, emit: rolloutOptions.emit }, - ), - }); - }, - ), + checkActionPermission: Effect.fn('ActionPermission.checkActionPermission')(function* checkActionPermissionEffect( + input: CheckActionPermissionInput, + ) { + const execution = yield* runCheck(client, executionRequest(input.actionKey, input.principalId)); + if (execution === 'has') { + return 'allowed' as const; + } + if (rolloutOptions === undefined) { + return 'denied' as const; + } + const restricted = yield* runCheck(client, restrictionRequest(input.actionKey, input.principalId)); + if (restricted === 'has') { + return 'denied' as const; + } + return yield* Effect.try({ + catch: (cause) => checkFailure(cause), + try: () => + decideAuthorizationRollout( + { + candidate: 'denied', + current: 'allowed', + denialReason: 'missing_policy', + entrypointKey: input.actionKey, + nowEpochMs: rolloutOptions.nowEpochMs(), + policyClass: 'action_execution', + surface: 'action', + }, + { contract: rolloutOptions.rollout, emit: rolloutOptions.emit }, + ), + }); + }), }); export const makeActionPermissionService = actionPermissionService; @@ -209,21 +192,14 @@ export class ActionPermission extends Context.Service Effect.Effect< - SpiceDbConfigValue, - SpiceDbConfigError - > = loadSpiceDbConfig, + loadConfiguration: () => Effect.Effect = loadSpiceDbConfig, ): Effect.Effect => Effect.matchEffect(loadConfiguration(), { onFailure: (cause) => Effect.succeed(unavailablePermissionService(cause)), onSuccess: (configuration) => - acquirePermissionClientResource(() => - clientFactory(configuration, SPICEDB_CHECK_TIMEOUT_MS), - ).pipe( + acquirePermissionClientResource(() => clientFactory(configuration, SPICEDB_CHECK_TIMEOUT_MS)).pipe( Effect.map(makeActionPermissionService), - Effect.catchTag('ActionPermissionCheckError', (cause) => - Effect.succeed(unavailablePermissionService(cause)), - ), + Effect.catchTag('ActionPermissionCheckError', (cause) => Effect.succeed(unavailablePermissionService(cause))), ), }); diff --git a/app/packages/core-runtime/src/reads/context.ts b/app/packages/core-runtime/src/reads/context.ts index d33d1de1b..0cb84250d 100644 --- a/app/packages/core-runtime/src/reads/context.ts +++ b/app/packages/core-runtime/src/reads/context.ts @@ -1,14 +1,10 @@ import { Effect, Predicate, Schema } from 'effect'; + import type { OperationalScope } from '../operations/context.ts'; import type { ReadEvidenceCaptureMode } from './definition.ts'; import { ReadEvidenceValidationError } from './errors.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -35,12 +31,7 @@ export interface ReadHandlerResult { } const sha256 = /^[\da-f]{64}$/u; -const evidenceKeys = new Set([ - 'queryHash', - 'resultCount', - 'resultFingerprintHash', - 'resultFingerprintSchema', -]); +const evidenceKeys = new Set(['queryHash', 'resultCount', 'resultFingerprintHash', 'resultFingerprintSchema']); const invalidEvidence = (cause?: unknown): ReadEvidenceValidationError => { const failure = new ReadEvidenceValidationError({ code: 'read_evidence_invalid', @@ -68,13 +59,10 @@ const isValidResultCount = Schema.is( Schema.Finite.check(Schema.isInt(), Schema.isBetween({ maximum: 2_147_483_647, minimum: 0 })), ); -const hasInvalidFingerprintHash = ( - value: ReadEvidenceCandidate['resultFingerprintHash'], -): boolean => value !== undefined && (!Predicate.isString(value) || !sha256.test(value)); +const hasInvalidFingerprintHash = (value: ReadEvidenceCandidate['resultFingerprintHash']): boolean => + value !== undefined && (!Predicate.isString(value) || !sha256.test(value)); -const hasInvalidFingerprintSchema = ( - value: ReadEvidenceCandidate['resultFingerprintSchema'], -): boolean => +const hasInvalidFingerprintSchema = (value: ReadEvidenceCandidate['resultFingerprintSchema']): boolean => value !== undefined && (!Predicate.isString(value) || value.length === 0 || value.length > 300); const hasInvalidHashEvidence = (record: ReadEvidenceCandidate): boolean => @@ -87,9 +75,9 @@ export const validateReadEvidenceMetadata = ( captureMode: ReadEvidenceCaptureMode, value: Value, ): Effect.Effect, ReadEvidenceValidationError> => - Schema.decodeUnknownEffect(ReadEvidenceCandidateSchema, { onExcessProperty: 'error' })( - value, - ).pipe( + Schema.decodeUnknownEffect(ReadEvidenceCandidateSchema, { + onExcessProperty: 'error', + })(value).pipe( Effect.mapError(invalidEvidence), Effect.flatMap((record) => { const { @@ -98,17 +86,12 @@ export const validateReadEvidenceMetadata = ( resultFingerprintHash: fingerprintHash, resultFingerprintSchema: fingerprintSchema, } = record; - if ( - Object.keys(record).some((key) => !evidenceKeys.has(key)) || - !isValidResultCount(resultCount) - ) { + if (Object.keys(record).some((key) => !evidenceKeys.has(key)) || !isValidResultCount(resultCount)) { return Effect.fail(invalidEvidence()); } if ( captureMode === 'metadata_only' && - (queryHash !== undefined || - fingerprintHash !== undefined || - fingerprintSchema !== undefined) + (queryHash !== undefined || fingerprintHash !== undefined || fingerprintSchema !== undefined) ) { return Effect.fail(invalidEvidence()); } diff --git a/app/packages/core-runtime/src/reads/definition.ts b/app/packages/core-runtime/src/reads/definition.ts index 76a63fffa..7706f64ba 100644 --- a/app/packages/core-runtime/src/reads/definition.ts +++ b/app/packages/core-runtime/src/reads/definition.ts @@ -1,12 +1,10 @@ import { Predicate, Schema } from 'effect'; import type { Effect } from 'effect'; + import type { ActionPolicy } from '../actions/policy.ts'; import { isActionPolicy } from '../actions/policy.ts'; import type { ScopedTransactionExecutor } from '../db/scoped-transaction.ts'; -import type { - ModuleEntrypointDescriptor, - ModuleEntrypointRole, -} from '../modules/module-entrypoint.ts'; +import type { ModuleEntrypointDescriptor, ModuleEntrypointRole } from '../modules/module-entrypoint.ts'; import { LEGAL_ENTITY_SCOPES } from '../operations/context.ts'; import type { LegalEntityScope, OperationalScope } from '../operations/context.ts'; import type { OperationContextUnavailable } from '../operations/errors.ts'; @@ -42,14 +40,7 @@ class ReadPrivateStorage { } } -export const READ_ACCESS_KINDS = [ - 'detail', - 'download', - 'export', - 'list', - 'report', - 'search', -] as const; +export const READ_ACCESS_KINDS = ['detail', 'download', 'export', 'list', 'report', 'search'] as const; export type ReadAccessKind = (typeof READ_ACCESS_KINDS)[number]; export const READ_EVIDENCE_CAPTURE_MODES = ['hash_only', 'metadata_only'] as const; export type ReadEvidenceCaptureMode = (typeof READ_EVIDENCE_CAPTURE_MODES)[number]; @@ -60,24 +51,33 @@ export interface ReadPolicyDescriptor { readonly denialStatus: ReadPermissionDenialStatus; readonly policyKey: string; } -export type ReadAlternativeTenantPermission = Exclude< - TenantPermissionKey, - 'access' | 'impersonate' ->; +export type ReadAlternativeTenantPermission = Exclude; export type AtomicResolvedReadPermissionTarget = | Readonly<{ readonly kind: 'legal_entity'; readonly permission?: LegalEntityPermissionKey; }> | Readonly<{ readonly kind: 'module'; readonly moduleId: string }> - | Readonly<{ readonly kind: 'resource'; readonly resource: ResourceAccessTarget }> - | Readonly<{ readonly kind: 'tenant'; readonly permission: TenantPermissionKey }>; + | Readonly<{ + readonly kind: 'resource'; + readonly resource: ResourceAccessTarget; + }> + | Readonly<{ + readonly kind: 'tenant'; + readonly permission: TenantPermissionKey; + }>; export type AlternativeResolvedReadPermissionTarget = | Exclude< AtomicResolvedReadPermissionTarget, - Readonly<{ readonly kind: 'tenant'; readonly permission: TenantPermissionKey }> + Readonly<{ + readonly kind: 'tenant'; + readonly permission: TenantPermissionKey; + }> > - | Readonly<{ readonly kind: 'tenant'; readonly permission: ReadAlternativeTenantPermission }>; + | Readonly<{ + readonly kind: 'tenant'; + readonly permission: ReadAlternativeTenantPermission; + }>; export type ResolvedReadPermissionTarget = | AtomicResolvedReadPermissionTarget | Readonly<{ @@ -159,8 +159,7 @@ const failReadDefinition = (message: string): never => { export const validateReadDescriptorInput = (descriptor: ReadDescriptorValidationInput): void => { if ( descriptor.entrypoint.moduleKey !== descriptor.owningModuleKey || - descriptor.entrypoint.scope !== - (descriptor.owningModuleKey.startsWith('core.') ? 'system' : 'tenant') || + descriptor.entrypoint.scope !== (descriptor.owningModuleKey.startsWith('core.') ? 'system' : 'tenant') || !['read', 'historical_read'].includes(descriptor.entrypoint.access) || !Object.isFrozen(descriptor.entrypoint) ) { @@ -182,9 +181,7 @@ type ReadRegistrationPrivateValue< readonly handler: ReadHandler; readonly permissionTargetResolver: ReadPermissionTargetResolver; readonly policies: readonly ActionPolicy[]; - readonly resultPermissionTargetResolver?: ReadResultPermissionTargetResolver< - ResultSchema['Type'] - >; + readonly resultPermissionTargetResolver?: ReadResultPermissionTargetResolver; readonly serviceFactory: ReadServiceFactory; }>; @@ -206,11 +203,7 @@ export type ReadRegistration< }; const validateReadVocabulary = ( - descriptor: ReadDescriptor< - Schema.ConstraintDecoder, - Schema.ConstraintDecoder, - string - >, + descriptor: ReadDescriptor, Schema.ConstraintDecoder, string>, permissionTargetResolver: ReadPermissionTargetResolver, resultPermissionTargetResolver: ReadResultPermissionTargetResolver | undefined, ): void => { @@ -245,13 +238,8 @@ export const defineRead = < executablePolicies?: readonly ActionPolicy>[], ] ): ReadRegistration => { - const [ - handler, - serviceFactory, - permissionTargetResolver, - resultPermissionTargetResolver, - executablePolicies = [], - ] = definition; + const [handler, serviceFactory, permissionTargetResolver, resultPermissionTargetResolver, executablePolicies = []] = + definition; validateReadDescriptorInput(descriptor); validateReadVocabulary(descriptor, permissionTargetResolver, resultPermissionTargetResolver); if ( @@ -273,9 +261,7 @@ export const defineRead = < ...descriptor, entrypoint: descriptor.entrypoint, evidencePolicy: Object.freeze({ ...descriptor.evidencePolicy }), - policies: Object.freeze( - descriptor.policies.map((reference) => Object.freeze({ ...reference })), - ), + policies: Object.freeze(descriptor.policies.map((reference) => Object.freeze({ ...reference }))), }); const privateValue = { handler, @@ -290,10 +276,7 @@ export const defineRead = < if (resultPermissionTargetResolver === undefined) { return ReadPrivateStorage.create(Object.freeze(privateValue), publicFields); } - return ReadPrivateStorage.create( - Object.freeze({ ...privateValue, resultPermissionTargetResolver }), - publicFields, - ); + return ReadPrivateStorage.create(Object.freeze({ ...privateValue, resultPermissionTargetResolver }), publicFields); }; export const getReadPolicyImplementations = < @@ -305,8 +288,7 @@ export const getReadPolicyImplementations = < Requirements, >( registration: ReadRegistration, -): readonly ActionPolicy[] => - ReadPrivateStorage.getValue(registration).policies; +): readonly ActionPolicy[] => ReadPrivateStorage.getValue(registration).policies; export const getReadResultPermissionTargetResolver = < InputSchema extends Schema.ConstraintDecoder, @@ -353,5 +335,4 @@ export const getReadServiceFactory = < Requirements, >( registration: ReadRegistration, -): ReadServiceFactory => - ReadPrivateStorage.getValue(registration).serviceFactory; +): ReadServiceFactory => ReadPrivateStorage.getValue(registration).serviceFactory; diff --git a/app/packages/core-runtime/src/reads/errors.ts b/app/packages/core-runtime/src/reads/errors.ts index b73e5e286..127e0fdba 100644 --- a/app/packages/core-runtime/src/reads/errors.ts +++ b/app/packages/core-runtime/src/reads/errors.ts @@ -1,5 +1,5 @@ -import type { OperationContextError } from '../operations/errors.ts'; import type { ModuleStateGateError } from '../modules/module-state-gate-errors.ts'; +import type { OperationContextError } from '../operations/errors.ts'; import type { ReadEvidencePersistenceError } from './read-evidence-persistence-error.ts'; import type { ReadEvidenceValidationError } from './read-evidence-validation-error.ts'; import type { ReadHandlerExecutionError } from './read-handler-execution-error.ts'; diff --git a/app/packages/core-runtime/src/reads/read-evidence-persistence-error.ts b/app/packages/core-runtime/src/reads/read-evidence-persistence-error.ts index 44978f011..c143fc55d 100644 --- a/app/packages/core-runtime/src/reads/read-evidence-persistence-error.ts +++ b/app/packages/core-runtime/src/reads/read-evidence-persistence-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class ReadEvidencePersistenceError extends Schema.TaggedError()( 'ReadEvidencePersistenceError', - { code: Schema.Literal('read_evidence_persistence_failed'), reason: Schema.String }, + { + code: Schema.Literal('read_evidence_persistence_failed'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/reads/read-handler-execution-error.ts b/app/packages/core-runtime/src/reads/read-handler-execution-error.ts index ae375d22b..8eefcf14b 100644 --- a/app/packages/core-runtime/src/reads/read-handler-execution-error.ts +++ b/app/packages/core-runtime/src/reads/read-handler-execution-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class ReadHandlerExecutionError extends Schema.TaggedError()( 'ReadHandlerExecutionError', - { code: Schema.Literal('read_handler_execution_failed'), reason: Schema.String }, + { + code: Schema.Literal('read_handler_execution_failed'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/reads/read-handler-not-found.ts b/app/packages/core-runtime/src/reads/read-handler-not-found.ts index 9c12024ff..1c17d164b 100644 --- a/app/packages/core-runtime/src/reads/read-handler-not-found.ts +++ b/app/packages/core-runtime/src/reads/read-handler-not-found.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class ReadHandlerNotFound extends Schema.TaggedError()( - 'ReadHandlerNotFound', - { code: Schema.Literal('read_handler_not_found'), reason: Schema.String }, -) {} +export class ReadHandlerNotFound extends Schema.TaggedError()('ReadHandlerNotFound', { + code: Schema.Literal('read_handler_not_found'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/reads/read-handler-unavailable.ts b/app/packages/core-runtime/src/reads/read-handler-unavailable.ts index c1e95c630..320bf7809 100644 --- a/app/packages/core-runtime/src/reads/read-handler-unavailable.ts +++ b/app/packages/core-runtime/src/reads/read-handler-unavailable.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class ReadHandlerUnavailable extends Schema.TaggedError()( - 'ReadHandlerUnavailable', - { code: Schema.Literal('read_handler_unavailable'), reason: Schema.String }, -) {} +export class ReadHandlerUnavailable extends Schema.TaggedError()('ReadHandlerUnavailable', { + code: Schema.Literal('read_handler_unavailable'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/reads/read-permission-denied.ts b/app/packages/core-runtime/src/reads/read-permission-denied.ts index e01018f13..dfcad5ca0 100644 --- a/app/packages/core-runtime/src/reads/read-permission-denied.ts +++ b/app/packages/core-runtime/src/reads/read-permission-denied.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -export class ReadPermissionDenied extends Schema.TaggedError()( - 'ReadPermissionDenied', - { code: Schema.Literal('read_permission_denied'), reason: Schema.String }, -) {} +export class ReadPermissionDenied extends Schema.TaggedError()('ReadPermissionDenied', { + code: Schema.Literal('read_permission_denied'), + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/src/reads/read-policy-evaluation-error.ts b/app/packages/core-runtime/src/reads/read-policy-evaluation-error.ts index d96a59585..fd33f851e 100644 --- a/app/packages/core-runtime/src/reads/read-policy-evaluation-error.ts +++ b/app/packages/core-runtime/src/reads/read-policy-evaluation-error.ts @@ -2,5 +2,8 @@ import { Schema } from 'effect'; export class ReadPolicyEvaluationError extends Schema.TaggedError()( 'ReadPolicyEvaluationError', - { code: Schema.Literal('read_policy_evaluation_failed'), reason: Schema.String }, + { + code: Schema.Literal('read_policy_evaluation_failed'), + reason: Schema.String, + }, ) {} diff --git a/app/packages/core-runtime/src/reads/repository.ts b/app/packages/core-runtime/src/reads/repository.ts index 22222c4e1..2f001f036 100644 --- a/app/packages/core-runtime/src/reads/repository.ts +++ b/app/packages/core-runtime/src/reads/repository.ts @@ -1,4 +1,5 @@ import { Duration, Effect } from 'effect'; + import { dataAccessEvents } from '../db/schema.ts'; import type { CoreDbExecutor } from '../db/types.ts'; import type { OperationalScope } from '../operations/context.ts'; @@ -34,7 +35,10 @@ const readEvidencePersistenceFailure = (cause: unknown): ReadEvidencePersistence code: 'read_evidence_persistence_failed', reason: 'Required read evidence could not be persisted', }); - return Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + return Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); }; export const persistReadEvidence = ( @@ -70,8 +74,7 @@ export const persistReadEvidence = ( Effect.mapError(readEvidencePersistenceFailure), Effect.timeoutOrElse({ duration: READ_EVIDENCE_PERSISTENCE_TIMEOUT, - orElse: () => - Effect.fail(readEvidencePersistenceFailure('Read evidence persistence timed out')), + orElse: () => Effect.fail(readEvidencePersistenceFailure('Read evidence persistence timed out')), }), Effect.asVoid, ); diff --git a/app/packages/core-runtime/src/reads/runtime.ts b/app/packages/core-runtime/src/reads/runtime.ts index fdbc688e3..82305ce1e 100644 --- a/app/packages/core-runtime/src/reads/runtime.ts +++ b/app/packages/core-runtime/src/reads/runtime.ts @@ -1,7 +1,8 @@ -import { SqlError, isSqlError } from 'effect/unstable/sql/SqlError'; /* oxlint-disable sonarjs/no-duplicate-string -- Existing compatibility boundary; expires: 2026-12-31. */ // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import { Cause, Context, Effect, Exit, Layer, Schema } from 'effect'; +import { SqlError, isSqlError } from 'effect/unstable/sql/SqlError'; + import { computeCanonicalValueHash } from '../actions/repository.ts'; import { decodeTrustedPrincipalContext, @@ -55,15 +56,9 @@ const withOptionalProperty = < trailing: Trailing, ) => (condition ? { ...base, [key]: value, ...trailing } : { ...base, ...trailing }); -const CorrelationIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe( - Schema.brand('ReadCorrelationId'), -); -const TargetModuleKeySchema = Schema.String.check(Schema.isMinLength(1)).pipe( - Schema.brand('ReadTargetModuleKey'), -); -const TargetResourceIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe( - Schema.brand('ReadTargetResourceId'), -); +const CorrelationIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand('ReadCorrelationId')); +const TargetModuleKeySchema = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand('ReadTargetModuleKey')); +const TargetResourceIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand('ReadTargetResourceId')); const TargetResourceTypeSchema = Schema.String.check(Schema.isMinLength(1)).pipe( Schema.brand('ReadTargetResourceType'), ); @@ -117,15 +112,11 @@ const atomicTargetIsValid = (target: AtomicResolvedReadPermissionTarget): boolea ); }; -const usesForbiddenAlternativeTenantPermission = ( - target: AtomicResolvedReadPermissionTarget, -): boolean => - target.kind === 'tenant' && - (target.permission === 'access' || target.permission === 'impersonate'); +const usesForbiddenAlternativeTenantPermission = (target: AtomicResolvedReadPermissionTarget): boolean => + target.kind === 'tenant' && (target.permission === 'access' || target.permission === 'impersonate'); -const canonicalPermissionTarget = ( - target: ResolvedReadPermissionTarget, -): AtomicResolvedReadPermissionTarget => (target.kind === 'any_of' ? target.targets[0] : target); +const canonicalPermissionTarget = (target: ResolvedReadPermissionTarget): AtomicResolvedReadPermissionTarget => + target.kind === 'any_of' ? target.targets[0] : target; const targetIsValid = ( declared: 'legal_entity' | 'module' | 'resource' | 'tenant', @@ -142,8 +133,7 @@ const targetIsValid = ( target.targets.length >= 2 && target.targets.length <= 5 && target.targets.every( - (candidate) => - atomicTargetIsValid(candidate) && !usesForbiddenAlternativeTenantPermission(candidate), + (candidate) => atomicTargetIsValid(candidate) && !usesForbiddenAlternativeTenantPermission(candidate), ) ); }; @@ -164,13 +154,14 @@ const targetMetadata = (target: ResolvedReadPermissionTarget) => { }; const decisionFor = ( - decisions: readonly { readonly decision: PermissionDecision; readonly key: string }[], + decisions: readonly { + readonly decision: PermissionDecision; + readonly key: string; + }[], expectedKey: string, ): PermissionDecision => { const [decision, ...unexpected] = decisions; - return unexpected.length === 0 && decision?.key === expectedKey - ? decision.decision - : 'unavailable'; + return unexpected.length === 0 && decision?.key === expectedKey ? decision.decision : 'unavailable'; }; const checkAtomicPermissionTarget = ( @@ -298,90 +289,85 @@ const checkTenantResultPermission = Effect.fnUntraced(function* checkTenantResul return yield* Effect.void; }); -const checkResultPermissions = Effect.fn('ReadRuntime.checkResultPermissions')( - function* checkResultPermissionsEffect< - Result, - AccessValue extends (typeof ContextAccess)['Service'], - >( - contextAccess: AccessValue, +const checkResultPermissions = Effect.fn('ReadRuntime.checkResultPermissions')(function* checkResultPermissionsEffect< + Result, + AccessValue extends (typeof ContextAccess)['Service'], +>( + contextAccess: AccessValue, + result: Result, + scope: OperationalScope, + permissionTarget: ResolvedReadPermissionTarget, + resolver: ( result: Result, scope: OperationalScope, - permissionTarget: ResolvedReadPermissionTarget, - resolver: ( - result: Result, - scope: OperationalScope, - ) => readonly { - readonly moduleId: string; - readonly resourceId: string; - readonly resourceType: string; - }[], + ) => readonly { + readonly moduleId: string; + readonly resourceId: string; + readonly resourceType: string; + }[], +) { + const resultTargets = yield* Effect.try({ + catch: (resolverDefect) => + preserveFailureCause( + new ReadHandlerExecutionError({ + code: 'read_handler_execution_failed', + reason: 'The read result permission targets are invalid', + }), + resolverDefect, + ), + try: () => resolver(result, scope), + }); + if ( + resultTargets.some( + (target) => + !stableTargetKey(target.moduleId) || + !stableTargetKey(target.resourceId) || + !stableTargetKey(target.resourceType), + ) ) { - const resultTargets = yield* Effect.try({ - catch: (resolverDefect) => - preserveFailureCause( - new ReadHandlerExecutionError({ - code: 'read_handler_execution_failed', - reason: 'The read result permission targets are invalid', - }), - resolverDefect, - ), - try: () => resolver(result, scope), + return yield* new ReadHandlerExecutionError({ + code: 'read_handler_execution_failed', + reason: 'The read result permission targets are invalid', }); - if ( - resultTargets.some( - (target) => - !stableTargetKey(target.moduleId) || - !stableTargetKey(target.resourceId) || - !stableTargetKey(target.resourceType), - ) - ) { - return yield* new ReadHandlerExecutionError({ - code: 'read_handler_execution_failed', - reason: 'The read result permission targets are invalid', - }); - } - if (resultTargets.length === 0) { - return yield* Effect.void; - } - if (permissionTarget.kind === 'tenant') { - return yield* checkTenantResultPermission(contextAccess, scope, permissionTarget); - } - if (scope.legalEntityId === undefined) { - return yield* new ReadHandlerExecutionError({ - code: 'read_handler_execution_failed', - reason: 'The read result permission targets are invalid', - }); - } - const decisions = yield* contextAccess.resources({ - legalEntityId: scope.legalEntityId, - principalId: scope.principalId, - resources: resultTargets, - tenantId: scope.tenantId, - }); - const malformed = - decisions.length !== resultTargets.length || - decisions.some(({ key }, index) => { - const target = resultTargets[index]; - return ( - target === undefined || - key !== `${target.moduleId}:${target.resourceType}:${target.resourceId}` - ); - }); - if (malformed || decisions.some(({ decision }) => decision === 'unavailable')) { - return yield* new ReadPermissionUnavailable({ - code: 'read_permission_unavailable', - reason: 'Read result authorization is temporarily unavailable', - }); - } - if (decisions.some(({ decision }) => decision === 'denied')) { - return yield* new ReadPermissionDenied({ - code: 'read_permission_denied', - reason: 'The read result contains a forbidden resource', - }); - } + } + if (resultTargets.length === 0) { return yield* Effect.void; - }, -); + } + if (permissionTarget.kind === 'tenant') { + return yield* checkTenantResultPermission(contextAccess, scope, permissionTarget); + } + if (scope.legalEntityId === undefined) { + return yield* new ReadHandlerExecutionError({ + code: 'read_handler_execution_failed', + reason: 'The read result permission targets are invalid', + }); + } + const decisions = yield* contextAccess.resources({ + legalEntityId: scope.legalEntityId, + principalId: scope.principalId, + resources: resultTargets, + tenantId: scope.tenantId, + }); + const malformed = + decisions.length !== resultTargets.length || + decisions.some(({ key }, index) => { + const target = resultTargets[index]; + return target === undefined || key !== `${target.moduleId}:${target.resourceType}:${target.resourceId}`; + }); + if (malformed || decisions.some(({ decision }) => decision === 'unavailable')) { + return yield* new ReadPermissionUnavailable({ + code: 'read_permission_unavailable', + reason: 'Read result authorization is temporarily unavailable', + }); + } + if (decisions.some(({ decision }) => decision === 'denied')) { + return yield* new ReadPermissionDenied({ + code: 'read_permission_denied', + reason: 'The read result contains a forbidden resource', + }); + } + return yield* Effect.void; +}); const readRuntimeFromDependencies = < DatabaseValue extends (typeof CoreDatabase)['Service'], @@ -407,19 +393,10 @@ const readRuntimeFromDependencies = < >(input: { readonly input: unknown; readonly principal: unknown; - readonly registration: ReadRegistration< - InputSchema, - ResultSchema, - Owner, - Services, - HandlerError, - Requirements - >; + readonly registration: ReadRegistration; readonly transport: unknown; }) { - const decodedInput = yield* Schema.decodeUnknownEffect( - input.registration.descriptor.inputSchema, - )(input.input).pipe( + const decodedInput = yield* Schema.decodeUnknownEffect(input.registration.descriptor.inputSchema)(input.input).pipe( Effect.mapError((parseIssue) => preserveFailureCause( new ReadInputValidationError({ @@ -502,8 +479,7 @@ const readRuntimeFromDependencies = < }); if ( !targetIsValid(input.registration.descriptor.permissionTarget, permissionTarget) || - (getReadResultPermissionTargetResolver(input.registration) !== undefined && - permissionTarget.kind === 'any_of') + (getReadResultPermissionTargetResolver(input.registration) !== undefined && permissionTarget.kind === 'any_of') ) { return yield* new ReadHandlerExecutionError({ code: 'read_handler_execution_failed', @@ -511,9 +487,7 @@ const readRuntimeFromDependencies = < }); } const permissionTargetMetadata = targetMetadata(permissionTarget); - const snapshot = yield* gateway.prepareSnapshot(scope, [ - input.registration.descriptor.entrypoint, - ]); + const snapshot = yield* gateway.prepareSnapshot(scope, [input.registration.descriptor.entrypoint]); yield* gateway.check(snapshot, input.registration.descriptor.entrypoint); stage('module_state_checked'); @@ -636,9 +610,7 @@ const readRuntimeFromDependencies = < const transactionResult = database.executor .transaction( - Effect.fn('ReadRuntime.readTransactionBody')(function* readTransactionBody( - transaction: CoreTransaction, - ) { + Effect.fn('ReadRuntime.readTransactionBody')(function* readTransactionBody(transaction: CoreTransaction) { const scoped = yield* installOperationalScope(transaction, scope); stage('scope_installed'); const services = yield* getReadServiceFactory(input.registration)(scoped, scope); @@ -653,9 +625,9 @@ const readRuntimeFromDependencies = < ), ).pipe(Effect.mapError(sanitizeReadHandlerFailure)); stage('handler_executed'); - const result = yield* Schema.decodeUnknownEffect( - Schema.toType(input.registration.descriptor.resultSchema), - )(handlerResult.result).pipe( + const result = yield* Schema.decodeUnknownEffect(Schema.toType(input.registration.descriptor.resultSchema))( + handlerResult.result, + ).pipe( Effect.mapError((parseIssue) => preserveFailureCause( new ReadResultValidationError({ @@ -667,17 +639,9 @@ const readRuntimeFromDependencies = < ), ); stage('result_decoded'); - const resultPermissionResolver = getReadResultPermissionTargetResolver( - input.registration, - ); + const resultPermissionResolver = getReadResultPermissionTargetResolver(input.registration); if (resultPermissionResolver !== undefined) { - yield* checkResultPermissions( - contextAccess, - result, - scope, - permissionTarget, - resultPermissionResolver, - ); + yield* checkResultPermissions(contextAccess, result, scope, permissionTarget, resultPermissionResolver); } const evidence = yield* validateReadEvidenceMetadata( input.registration.descriptor.evidencePolicy.captureMode, @@ -724,9 +688,7 @@ const readRuntimeFromDependencies = < }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.tapError((failure) => Schema.is(SqlError)(failure) ? Effect.logError('Unexpected governed read transaction failure', failure) @@ -749,11 +711,7 @@ const readRuntimeFromDependencies = < return transactionExit.value; } const { cause } = transactionExit; - if ( - !cause.reasons.some( - (reason) => Cause.isFailReason(reason) && Schema.is(ReadPermissionDenied)(reason.error), - ) - ) { + if (!cause.reasons.some((reason) => Cause.isFailReason(reason) && Schema.is(ReadPermissionDenied)(reason.error))) { return yield* Effect.failCause(cause); } const evidenceExit = yield* Effect.exit( @@ -781,9 +739,7 @@ const readRuntimeFromDependencies = < ), ), ); - return yield* Effect.failCause( - Exit.isFailure(evidenceExit) ? Cause.combine(evidenceExit.cause, cause) : cause, - ); + return yield* Effect.failCause(Exit.isFailure(evidenceExit) ? Cause.combine(evidenceExit.cause, cause) : cause); }); return Object.freeze({ runRead }); diff --git a/app/packages/core-runtime/src/search/ingestion.ts b/app/packages/core-runtime/src/search/ingestion.ts index 2e6e73ff6..80aed5fd7 100644 --- a/app/packages/core-runtime/src/search/ingestion.ts +++ b/app/packages/core-runtime/src/search/ingestion.ts @@ -1,4 +1,5 @@ import { Context, Effect, Layer, Schema } from 'effect'; + import { CoreSearchProjectionInvalid, CoreSearchProjectionMutationSchema, @@ -47,8 +48,7 @@ export const CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS = [ 'party.registry.project-counterparty-role-ended-to-search', 'party.registry.rebuild-search', ] as const; -export type CoreSearchPartyProjectorWorkerKey = - (typeof CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS)[number]; +export type CoreSearchPartyProjectorWorkerKey = (typeof CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS)[number]; const topicSchema = Schema.Literals(CORE_SEARCH_PARTY_LIFECYCLE_TOPICS); const workerKeySchema = Schema.Literals(CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS); @@ -72,26 +72,28 @@ export interface CoreSearchIngestionRegistration { readonly workerKey: CoreSearchPartyProjectorWorkerKey; } -export const CORE_SEARCH_INGESTION_REGISTRATIONS: readonly CoreSearchIngestionRegistration[] = - Object.freeze( - CORE_SEARCH_PARTY_LIFECYCLE_TOPICS.flatMap((topic, index) => { - const workerKey = CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS[index]; - return workerKey === undefined - ? [] - : [ - Object.freeze({ - consumerModuleKey: PARTY_REGISTRY_MODULE_KEY, - producerModuleKey: PARTY_REGISTRY_MODULE_KEY, - topic, - workerKey, - }), - ]; - }), - ); +export const CORE_SEARCH_INGESTION_REGISTRATIONS: readonly CoreSearchIngestionRegistration[] = Object.freeze( + CORE_SEARCH_PARTY_LIFECYCLE_TOPICS.flatMap((topic, index) => { + const workerKey = CORE_SEARCH_PARTY_PROJECTOR_WORKER_KEYS[index]; + return workerKey === undefined + ? [] + : [ + Object.freeze({ + consumerModuleKey: PARTY_REGISTRY_MODULE_KEY, + producerModuleKey: PARTY_REGISTRY_MODULE_KEY, + topic, + workerKey, + }), + ]; + }), +); const invalid = (reason: string, cause?: unknown): CoreSearchProjectionInvalid => { if (cause === undefined) { - return new CoreSearchProjectionInvalid({ code: 'core_search_projection_invalid', reason }); + return new CoreSearchProjectionInvalid({ + code: 'core_search_projection_invalid', + reason, + }); } return new CoreSearchProjectionInvalid({ cause, @@ -107,14 +109,11 @@ export interface CoreSearchIngestionService { } /** Core-owned consumer seam for post-commit Party lifecycle observations. */ -export class CoreSearchIngestion extends Context.Service< - CoreSearchIngestion, - CoreSearchIngestionService ->()('@app/core-runtime/search/ingestion/CoreSearchIngestion') {} +export class CoreSearchIngestion extends Context.Service()( + '@app/core-runtime/search/ingestion/CoreSearchIngestion', +) {} -export const makeCoreSearchIngestion = ( - store: CoreSearchProjectionMutationSink, -): CoreSearchIngestionService => ({ +export const makeCoreSearchIngestion = (store: CoreSearchProjectionMutationSink): CoreSearchIngestionService => ({ ingest: (input) => Schema.decodeUnknownEffect(CoreSearchIngestionObservationSchema)(input).pipe( Effect.mapError((cause) => invalid('Core Search ingestion observation is invalid', cause)), @@ -144,9 +143,7 @@ export const makeCoreSearchIngestion = ( mutationModuleId !== observation.producerModuleKey || mutationVersion !== observation.projectionVersion ) { - return Effect.fail( - invalid('Core Search ingestion identity does not match its post-commit observation'), - ); + return Effect.fail(invalid('Core Search ingestion identity does not match its post-commit observation')); } return store.apply(observation.mutation); }), diff --git a/app/packages/core-runtime/src/search/persistence.ts b/app/packages/core-runtime/src/search/persistence.ts index 1e1f9a7b7..915a78178 100644 --- a/app/packages/core-runtime/src/search/persistence.ts +++ b/app/packages/core-runtime/src/search/persistence.ts @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { DateTime, Effect, Layer, Option, Result, Schema } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; + import { CoreDatabase } from '../db/client.ts'; import { searchIndexEntries, searchProjectionRebuilds } from '../db/schema.ts'; import type { CoreDatabaseExecutor, CoreTransaction } from '../db/types.ts'; @@ -44,15 +45,15 @@ const PersistedDocumentPayloadSchema = Schema.Struct({ Schema.Array(CoreSearchTemporalSearchableTextSchema).check(Schema.isMaxLength(100)), ), }); -const ProjectionUnitKeySchema = Schema.fromJsonString( - Schema.Tuple([Schema.String, Schema.String, Schema.String]), -); +const ProjectionUnitKeySchema = Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.String])); type PersistedDocumentPayload = typeof PersistedDocumentPayloadSchema.Type; type MutablePersistedDocumentPayload = { -readonly [Key in keyof PersistedDocumentPayload]: PersistedDocumentPayload[Key]; }; -type CoreSearchPersistenceDatabase = Readonly<{ executor: CoreDatabaseExecutor }>; +type CoreSearchPersistenceDatabase = Readonly<{ + executor: CoreDatabaseExecutor; +}>; type CoreSearchProjectionInput = Parameters[0]; type CoreSearchPersistenceCause = typeof Schema.Unknown.Type; type SearchIndexEntry = typeof searchIndexEntries.$inferSelect; @@ -78,13 +79,17 @@ interface PersistedDocumentInput { title: string; } -const invalid = ( - reason: string, - cause?: CoreSearchPersistenceCause, -): CoreSearchProjectionInvalid => +const invalid = (reason: string, cause?: CoreSearchPersistenceCause): CoreSearchProjectionInvalid => cause === undefined - ? new CoreSearchProjectionInvalid({ code: 'core_search_projection_invalid', reason }) - : new CoreSearchProjectionInvalid({ cause, code: 'core_search_projection_invalid', reason }); + ? new CoreSearchProjectionInvalid({ + code: 'core_search_projection_invalid', + reason, + }) + : new CoreSearchProjectionInvalid({ + cause, + code: 'core_search_projection_invalid', + reason, + }); const unavailable = (cause?: CoreSearchPersistenceCause) => cause === undefined ? new CoreSearchProjectionUnavailable({ @@ -158,54 +163,49 @@ const rowMatchesDocument = ( ); const makeTransactionOperations = () => { - const installTenantScope = Effect.fn('CoreSearchPersistence.installTenantScope')( - function* installTenantScopeEffect( - transaction: CoreTransaction, - tenantId: string, - legalEntityId?: string, - ) { - const result = yield* transaction - .execute( - sql` + const installTenantScope = Effect.fn('CoreSearchPersistence.installTenantScope')(function* installTenantScopeEffect( + transaction: CoreTransaction, + tenantId: string, + legalEntityId?: string, + ) { + const result = yield* transaction + .execute( + sql` select set_config('ontos.tenant_id', ${tenantId}, true) as tenant_id, set_config('ontos.legal_entity_id', ${legalEntityId ?? ''}, true) as legal_entity_id `, - 'objects', - ) - .pipe(Effect.mapError(unavailable)); - const verified = Schema.decodeUnknownOption( - Schema.Struct({ legal_entity_id: Schema.String, tenant_id: Schema.String }), - )(result[0]); - if ( - Option.isNone(verified) || - verified.value.tenant_id !== tenantId || - verified.value.legal_entity_id !== (legalEntityId ?? '') - ) { - return yield* unavailable(); - } - return yield* Effect.void; - }, - ); - - const lockProjectionUnit = Effect.fn('CoreSearchPersistence.lockProjectionUnit')( - function* lockProjectionUnitEffect( - transaction: CoreTransaction, - tenantId: string, - moduleId: string, - resourceType: string, + 'objects', + ) + .pipe(Effect.mapError(unavailable)); + const verified = Schema.decodeUnknownOption( + Schema.Struct({ + legal_entity_id: Schema.String, + tenant_id: Schema.String, + }), + )(result[0]); + if ( + Option.isNone(verified) || + verified.value.tenant_id !== tenantId || + verified.value.legal_entity_id !== (legalEntityId ?? '') ) { - yield* transaction - .execute( - sql`select pg_advisory_xact_lock(hashtextextended(${projectionUnitKey( - tenantId, - moduleId, - resourceType, - )}, 0))`, - ) - .pipe(Effect.mapError(unavailable)); - }, - ); + return yield* unavailable(); + } + return yield* Effect.void; + }); + + const lockProjectionUnit = Effect.fn('CoreSearchPersistence.lockProjectionUnit')(function* lockProjectionUnitEffect( + transaction: CoreTransaction, + tenantId: string, + moduleId: string, + resourceType: string, + ) { + yield* transaction + .execute( + sql`select pg_advisory_xact_lock(hashtextextended(${projectionUnitKey(tenantId, moduleId, resourceType)}, 0))`, + ) + .pipe(Effect.mapError(unavailable)); + }); const currentRow = Effect.fn('CoreSearchPersistence.currentRow')(function* currentRowEffect( transaction: CoreTransaction, @@ -223,122 +223,120 @@ const makeTransactionOperations = () => { return Option.fromNullishOr(row); }); - const currentRebuild = Effect.fn('CoreSearchPersistence.currentRebuild')( - function* currentRebuildEffect( - transaction: CoreTransaction, - unit: Readonly<{ moduleId: string; resourceType: string; tenantId: string }>, - ) { - const query = transaction.query.searchProjectionRebuilds.findFirst({ - where: { - sourceModuleKey: unit.moduleId, - sourceResourceType: unit.resourceType, - tenantId: unit.tenantId, - }, - }); - const rebuild = yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); - return Option.fromNullishOr(rebuild); - }, - ); - - const persistUpsert = Effect.fn('CoreSearchPersistence.persistUpsert')( - function* persistUpsertEffect( - transaction: CoreTransaction, - document: CoreSearchProjectionDocument, - updatedAt: Date, - ) { - const current = yield* currentRow(transaction, document.ref); - const version = BigInt(document.projectionVersion); - if (Option.isSome(current)) { - const existing = current.value; - if (existing.projectionVersion > version) { - return yield* Effect.void; - } - if (existing.projectionVersion === version) { - if (!Result.getOrThrow(rowMatchesDocument(existing, document))) { - return yield* invalid('Core Search mutation reuses a version for different content'); - } - return yield* Effect.void; - } - const query = transaction - .update(searchIndexEntries) - .set({ - bodyText: bodyText(document), - deleted: false, - facetsJson: payload(document), - legalEntityId: document.selectedLegalEntityId ?? null, - projectionVersion: version, - title: document.title, - updatedAt, - }) - .where(eq(searchIndexEntries.searchIndexEntryId, existing.searchIndexEntryId)); - yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); - return yield* Effect.void; - } - const query = transaction.insert(searchIndexEntries).values({ - bodyText: bodyText(document), - deleted: false, - facetsJson: payload(document), - legalEntityId: document.selectedLegalEntityId ?? null, - projectionVersion: version, - sourceModuleKey: document.ref.moduleId, - sourceResourceId: document.ref.resourceId, - sourceResourceType: document.ref.resourceType, - tenantId: document.ref.tenantId, - title: document.title, - }); - yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); - return yield* Effect.void; - }, - ); + const currentRebuild = Effect.fn('CoreSearchPersistence.currentRebuild')(function* currentRebuildEffect( + transaction: CoreTransaction, + unit: Readonly<{ + moduleId: string; + resourceType: string; + tenantId: string; + }>, + ) { + const query = transaction.query.searchProjectionRebuilds.findFirst({ + where: { + sourceModuleKey: unit.moduleId, + sourceResourceType: unit.resourceType, + tenantId: unit.tenantId, + }, + }); + const rebuild = yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); + return Option.fromNullishOr(rebuild); + }); - const persistDelete = Effect.fn('CoreSearchPersistence.persistDelete')( - function* persistDeleteEffect( - transaction: CoreTransaction, - mutation: Extract, - updatedAt: Date, - ) { - const current = yield* currentRow(transaction, mutation.ref); - const version = BigInt(mutation.projectionVersion); - if (Option.isSome(current) && current.value.projectionVersion > version) { + const persistUpsert = Effect.fn('CoreSearchPersistence.persistUpsert')(function* persistUpsertEffect( + transaction: CoreTransaction, + document: CoreSearchProjectionDocument, + updatedAt: Date, + ) { + const current = yield* currentRow(transaction, document.ref); + const version = BigInt(document.projectionVersion); + if (Option.isSome(current)) { + const existing = current.value; + if (existing.projectionVersion > version) { return yield* Effect.void; } - if (Option.isSome(current) && current.value.projectionVersion === version) { - if (!current.value.deleted) { + if (existing.projectionVersion === version) { + if (!Result.getOrThrow(rowMatchesDocument(existing, document))) { return yield* invalid('Core Search mutation reuses a version for different content'); } return yield* Effect.void; } - if (Option.isNone(current)) { - const query = transaction.insert(searchIndexEntries).values({ - bodyText: '', - deleted: true, - facetsJson: { schemaVersion: '1' }, - projectionVersion: version, - sourceModuleKey: mutation.ref.moduleId, - sourceResourceId: mutation.ref.resourceId, - sourceResourceType: mutation.ref.resourceType, - tenantId: mutation.ref.tenantId, - title: '', - }); - yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); - return yield* Effect.void; - } const query = transaction .update(searchIndexEntries) .set({ - bodyText: '', - deleted: true, - facetsJson: { schemaVersion: '1' }, - legalEntityId: null, + bodyText: bodyText(document), + deleted: false, + facetsJson: payload(document), + legalEntityId: document.selectedLegalEntityId ?? null, projectionVersion: version, - title: '', + title: document.title, updatedAt, }) - .where(eq(searchIndexEntries.searchIndexEntryId, current.value.searchIndexEntryId)); + .where(eq(searchIndexEntries.searchIndexEntryId, existing.searchIndexEntryId)); yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); return yield* Effect.void; - }, - ); + } + const query = transaction.insert(searchIndexEntries).values({ + bodyText: bodyText(document), + deleted: false, + facetsJson: payload(document), + legalEntityId: document.selectedLegalEntityId ?? null, + projectionVersion: version, + sourceModuleKey: document.ref.moduleId, + sourceResourceId: document.ref.resourceId, + sourceResourceType: document.ref.resourceType, + tenantId: document.ref.tenantId, + title: document.title, + }); + yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); + return yield* Effect.void; + }); + + const persistDelete = Effect.fn('CoreSearchPersistence.persistDelete')(function* persistDeleteEffect( + transaction: CoreTransaction, + mutation: Extract, + updatedAt: Date, + ) { + const current = yield* currentRow(transaction, mutation.ref); + const version = BigInt(mutation.projectionVersion); + if (Option.isSome(current) && current.value.projectionVersion > version) { + return yield* Effect.void; + } + if (Option.isSome(current) && current.value.projectionVersion === version) { + if (!current.value.deleted) { + return yield* invalid('Core Search mutation reuses a version for different content'); + } + return yield* Effect.void; + } + if (Option.isNone(current)) { + const query = transaction.insert(searchIndexEntries).values({ + bodyText: '', + deleted: true, + facetsJson: { schemaVersion: '1' }, + projectionVersion: version, + sourceModuleKey: mutation.ref.moduleId, + sourceResourceId: mutation.ref.resourceId, + sourceResourceType: mutation.ref.resourceType, + tenantId: mutation.ref.tenantId, + title: '', + }); + yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); + return yield* Effect.void; + } + const query = transaction + .update(searchIndexEntries) + .set({ + bodyText: '', + deleted: true, + facetsJson: { schemaVersion: '1' }, + legalEntityId: null, + projectionVersion: version, + title: '', + updatedAt, + }) + .where(eq(searchIndexEntries.searchIndexEntryId, current.value.searchIndexEntryId)); + yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); + return yield* Effect.void; + }); const persistedDeleteMutation = ( row: SearchIndexEntry, @@ -389,77 +387,60 @@ const makeTransactionOperations = () => { }, ); - const replacementRows = Effect.fn('CoreSearchPersistence.replacementRows')( - function* replacementRowsEffect( - transaction: CoreTransaction, - replacement: CoreSearchProjectionReplacement, - ) { - const current = yield* currentRebuild(transaction, replacement); - const version = BigInt(replacement.rebuildVersion); - const fingerprint = coreSearchReplacementFingerprint(replacement); - if (Option.isSome(current) && version < current.value.rebuildVersion) { - return Option.none(); - } - if (Option.isSome(current) && version === current.value.rebuildVersion) { - const prior = current.value; - if (fingerprint !== prior.fingerprint) { - return yield* invalid('Core Search rebuild reuses a version for different content'); - } - return Option.none(); + const replacementRows = Effect.fn('CoreSearchPersistence.replacementRows')(function* replacementRowsEffect( + transaction: CoreTransaction, + replacement: CoreSearchProjectionReplacement, + ) { + const current = yield* currentRebuild(transaction, replacement); + const version = BigInt(replacement.rebuildVersion); + const fingerprint = coreSearchReplacementFingerprint(replacement); + if (Option.isSome(current) && version < current.value.rebuildVersion) { + return Option.none(); + } + if (Option.isSome(current) && version === current.value.rebuildVersion) { + const prior = current.value; + if (fingerprint !== prior.fingerprint) { + return yield* invalid('Core Search rebuild reuses a version for different content'); } - const query = transaction.query.searchIndexEntries.findMany({ - where: { - sourceModuleKey: replacement.moduleId, - sourceResourceType: replacement.resourceType, - tenantId: replacement.tenantId, - }, - }); - const existing = yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); - return Option.some({ existing, fingerprint }); - }, - ); + return Option.none(); + } + const query = transaction.query.searchIndexEntries.findMany({ + where: { + sourceModuleKey: replacement.moduleId, + sourceResourceType: replacement.resourceType, + tenantId: replacement.tenantId, + }, + }); + const existing = yield* query.execute.bind(query)().pipe(Effect.mapError(unavailable)); + return Option.some({ existing, fingerprint }); + }); - const replaceProjection = Effect.fn('CoreSearchPersistence.replaceProjection')( - function* replaceProjectionEffect( - transaction: CoreTransaction, - replacement: CoreSearchProjectionReplacement, - updatedAt: Date, - ) { - yield* lockProjectionUnit( - transaction, - replacement.tenantId, - replacement.moduleId, - replacement.resourceType, - ); - const work = yield* replacementRows(transaction, replacement); - if (Option.isNone(work)) { - return; - } - const { existing, fingerprint } = work.value; - yield* Effect.forEach( - replacement.documents, - (document) => persistUpsert(transaction, document, updatedAt), - { concurrency: 1, discard: true }, - ); - const nextIds = new Set(replacement.documents.map(({ ref }) => ref.resourceId)); - const staleRows = existing.filter( - (row) => - !nextIds.has(row.sourceResourceId) && - row.projectionVersion < BigInt(replacement.rebuildVersion), - ); - yield* Effect.forEach( - staleRows, - (row) => - persistDelete( - transaction, - persistedDeleteMutation(row, replacement.rebuildVersion), - updatedAt, - ), - { concurrency: 1, discard: true }, - ); - yield* persistRebuildFloor(transaction, replacement, fingerprint, updatedAt); - }, - ); + const replaceProjection = Effect.fn('CoreSearchPersistence.replaceProjection')(function* replaceProjectionEffect( + transaction: CoreTransaction, + replacement: CoreSearchProjectionReplacement, + updatedAt: Date, + ) { + yield* lockProjectionUnit(transaction, replacement.tenantId, replacement.moduleId, replacement.resourceType); + const work = yield* replacementRows(transaction, replacement); + if (Option.isNone(work)) { + return; + } + const { existing, fingerprint } = work.value; + yield* Effect.forEach(replacement.documents, (document) => persistUpsert(transaction, document, updatedAt), { + concurrency: 1, + discard: true, + }); + const nextIds = new Set(replacement.documents.map(({ ref }) => ref.resourceId)); + const staleRows = existing.filter( + (row) => !nextIds.has(row.sourceResourceId) && row.projectionVersion < BigInt(replacement.rebuildVersion), + ); + yield* Effect.forEach( + staleRows, + (row) => persistDelete(transaction, persistedDeleteMutation(row, replacement.rebuildVersion), updatedAt), + { concurrency: 1, discard: true }, + ); + yield* persistRebuildFloor(transaction, replacement, fingerprint, updatedAt); + }); const decodeRow = (row: SearchIndexEntry): CoreSearchProjectionDocument => { const decoded = Result.getOrThrow( @@ -502,7 +483,10 @@ const makeTransactionOperations = () => { if (decoded.temporalSearchableText !== undefined) { document.temporalSearchableText = decoded.temporalSearchableText; } - const mutation = decodeCoreSearchProjectionMutation({ document, kind: 'upsert' }); + const mutation = decodeCoreSearchProjectionMutation({ + document, + kind: 'upsert', + }); if (mutation.kind !== 'upsert') { throw invalid('Core Search persisted document is invalid'); } @@ -538,9 +522,7 @@ const makeTransactionOperations = () => { yield* lockProjectionUnit(transaction, ref.tenantId, ref.moduleId, ref.resourceType); const rebuild = yield* currentRebuild(transaction, ref); const version = BigInt( - mutation.kind === 'upsert' - ? mutation.document.projectionVersion - : mutation.projectionVersion, + mutation.kind === 'upsert' ? mutation.document.projectionVersion : mutation.projectionVersion, ); if (Option.isSome(rebuild) && version <= rebuild.value.rebuildVersion) { return; @@ -554,10 +536,7 @@ const makeTransactionOperations = () => { ); const queryCandidatesTransaction = Effect.fn('CoreSearchPersistence.queryCandidatesTransaction')( - function* queryCandidatesTransactionEffect( - transaction: CoreTransaction, - input: CoreSearchQuery, - ) { + function* queryCandidatesTransactionEffect(transaction: CoreTransaction, input: CoreSearchQuery) { yield* installTenantScope(transaction, input.tenantId, input.selectedLegalEntityId); const query = transaction.query.searchIndexEntries.findMany({ // Match the bounded rebuild unit; never silently truncate before evidence filtering. @@ -579,16 +558,16 @@ const makeTransactionOperations = () => { }, ); - const replaceProjectionTransaction = Effect.fn( - 'CoreSearchPersistence.replaceProjectionTransaction', - )(function* replaceProjectionTransactionEffect( - transaction: CoreTransaction, - replacement: CoreSearchProjectionReplacement, - updatedAt: Date, - ) { - yield* installTenantScope(transaction, replacement.tenantId); - yield* replaceProjection(transaction, replacement, updatedAt); - }); + const replaceProjectionTransaction = Effect.fn('CoreSearchPersistence.replaceProjectionTransaction')( + function* replaceProjectionTransactionEffect( + transaction: CoreTransaction, + replacement: CoreSearchProjectionReplacement, + updatedAt: Date, + ) { + yield* installTenantScope(transaction, replacement.tenantId); + yield* replaceProjection(transaction, replacement, updatedAt); + }, + ); return Object.freeze({ applyMutationTransaction, @@ -604,43 +583,39 @@ const transactionOperations = makeTransactionOperations(); export const makePostgresCoreSearchProjectionStore = ( database: CoreSearchPersistenceDatabase, ): CoreSearchProjectionStoreService => { - const runTransaction = ( - body: (transaction: CoreTransaction) => Effect.Effect, - ) => + const runTransaction = (body: (transaction: CoreTransaction) => Effect.Effect) => database.executor.transaction(body).pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.catchTag('SqlError', (failure) => Effect.fail(unavailable(failure))), ); - const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn( - 'CoreSearchProjectionStore.applyPostgres', - )(function* applyCoreSearchProjection(input: CoreSearchProjectionInput) { - const mutation = yield* transactionOperations.decodeMutation(input); - const updatedAt = DateTime.toDateUtc(yield* DateTime.now); - const transactionBody = (transaction: CoreTransaction) => - transactionOperations.applyMutationTransaction(transaction, mutation, updatedAt); - yield* runTransaction(transactionBody); - }); + const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn('CoreSearchProjectionStore.applyPostgres')( + function* applyCoreSearchProjection(input: CoreSearchProjectionInput) { + const mutation = yield* transactionOperations.decodeMutation(input); + const updatedAt = DateTime.toDateUtc(yield* DateTime.now); + const transactionBody = (transaction: CoreTransaction) => + transactionOperations.applyMutationTransaction(transaction, mutation, updatedAt); + yield* runTransaction(transactionBody); + }, + ); const queryCandidates: CoreSearchProjectionStoreService['queryCandidates'] = Effect.fn( 'CoreSearchProjectionStore.queryCandidatesPostgres', )(function* queryCoreSearchCandidates(input: CoreSearchQuery) { const transactionBody = (transaction: CoreTransaction) => transactionOperations.queryCandidatesTransaction(transaction, input); const documents = yield* runTransaction(transactionBody); - return yield* Schema.decodeUnknownEffect(Schema.Array(CoreSearchProjectionDocumentSchema))( - documents, - ).pipe(Effect.mapError(unavailable)); - }); - const replace: CoreSearchProjectionStoreService['replace'] = Effect.fn( - 'CoreSearchProjectionStore.replacePostgres', - )(function* replaceCoreSearchProjection(input: CoreSearchProjectionInput) { - const replacement = yield* transactionOperations.decodeReplacement(input); - const updatedAt = DateTime.toDateUtc(yield* DateTime.now); - const transactionBody = (transaction: CoreTransaction) => - transactionOperations.replaceProjectionTransaction(transaction, replacement, updatedAt); - yield* runTransaction(transactionBody); + return yield* Schema.decodeEffect(Schema.Array(CoreSearchProjectionDocumentSchema))(documents).pipe( + Effect.mapError(unavailable), + ); }); + const replace: CoreSearchProjectionStoreService['replace'] = Effect.fn('CoreSearchProjectionStore.replacePostgres')( + function* replaceCoreSearchProjection(input: CoreSearchProjectionInput) { + const replacement = yield* transactionOperations.decodeReplacement(input); + const updatedAt = DateTime.toDateUtc(yield* DateTime.now); + const transactionBody = (transaction: CoreTransaction) => + transactionOperations.replaceProjectionTransaction(transaction, replacement, updatedAt); + yield* runTransaction(transactionBody); + }, + ); return Object.freeze({ apply, queryCandidates, replace }); }; @@ -652,12 +627,5 @@ export const CoreSearchProjectionStoreLive = Layer.effect( }), ); -/** Fully composed production query layer; owner adapters never import Core database capabilities. */ -export const CoreSearchQueryRuntimeLive = Layer.effect( - CoreSearchQueryRuntime, - Effect.gen(function* createCoreSearchQueryRuntimeLive() { - const database = yield* CoreDatabase; - const store = makePostgresCoreSearchProjectionStore(database); - return createCoreSearchQueryRuntime(store); - }), -); +/** Query layer exposes its store requirement for composition at the application boundary. */ +export const CoreSearchQueryRuntimeLive = Layer.effect(CoreSearchQueryRuntime, createCoreSearchQueryRuntime); diff --git a/app/packages/core-runtime/src/search/projection-store.ts b/app/packages/core-runtime/src/search/projection-store.ts new file mode 100644 index 000000000..7fc8e86a5 --- /dev/null +++ b/app/packages/core-runtime/src/search/projection-store.ts @@ -0,0 +1,40 @@ +import { Context } from 'effect'; +import type { Effect, Schema } from 'effect'; + +import type { + CoreSearchProjectionDocument, + CoreSearchProjectionInvalid, + CoreSearchProjectionUnavailable, + CoreSearchQuery, +} from './projection.ts'; + +type UnparsedCoreSearchInput = typeof Schema.Unknown.Type; +type CoreSearchProjectionUnavailableInstance = InstanceType; + +export interface CoreSearchProjectionStoreService { + /** Applies one idempotent versioned lifecycle observation. */ + readonly apply: ( + input: UnparsedCoreSearchInput, + ) => Effect.Effect; + /** Candidate access is Core-private: the query runtime strips searchable evidence before return. */ + readonly queryCandidates: ( + input: CoreSearchQuery, + ) => Effect.Effect; + /** + * Replaces one tenant/module/resource projection as one physical rebuild unit. Implementations + * must leave the prior unit intact when validation or persistence fails. + */ + readonly replace: ( + input: UnparsedCoreSearchInput, + ) => Effect.Effect; +} + +/** Production persistence implements this Core-owned port; business modules never own an index. */ +export class CoreSearchProjectionStore extends Context.Service< + CoreSearchProjectionStore, + CoreSearchProjectionStoreService +>()( + // Preserve the public Context identity after splitting the service into its owning module. + // @effect-diagnostics-next-line deterministicKeys:off + '@app/core-runtime/search/projection/CoreSearchProjectionStore', +) {} diff --git a/app/packages/core-runtime/src/search/projection.ts b/app/packages/core-runtime/src/search/projection.ts index e9a7872c8..7a734af9a 100644 --- a/app/packages/core-runtime/src/search/projection.ts +++ b/app/packages/core-runtime/src/search/projection.ts @@ -1,5 +1,15 @@ import { createHash } from 'node:crypto'; -import { Clock, Context, DateTime, Effect, Option, Predicate, Result, Schema } from 'effect'; + +import { Clock, DateTime, Effect, Option, Predicate, Result, Schema } from 'effect'; + +import { CoreSearchProjectionStore } from './projection-store.ts'; +import type { CoreSearchProjectionStoreService } from './projection-store.ts'; +import type { CoreSearchQueryRuntimeService } from './query-runtime.ts'; + +export { CoreSearchProjectionStore } from './projection-store.ts'; +export { CoreSearchQueryRuntime } from './query-runtime.ts'; +export type { CoreSearchProjectionStoreService } from './projection-store.ts'; +export type { CoreSearchQueryRuntimeService } from './query-runtime.ts'; const boundedText = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)); const stableKey = Schema.String.check( @@ -8,13 +18,9 @@ const stableKey = Schema.String.check( Schema.isPattern(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/u), ); const moduleId = stableKey.pipe(Schema.brand('ModuleId')); -const resourceId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)).pipe( - Schema.brand('ResourceId'), -); +const resourceId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)).pipe(Schema.brand('ResourceId')); const resourceType = stableKey.pipe(Schema.brand('ResourceType')); -const selectedLegalEntityId = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('SelectedLegalEntityId'), -); +const selectedLegalEntityId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('SelectedLegalEntityId')); const tenantId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TenantId')); const projectionVersion = Schema.String.check(Schema.isPattern(/^[1-9][0-9]*$/u)); type UnparsedCoreSearchInput = typeof Schema.Unknown.Type; @@ -34,8 +40,16 @@ export const CoreSearchFacetSchema = Schema.Struct({ export type CoreSearchFacet = typeof CoreSearchFacetSchema.Type; export const CoreSearchMetadataFieldSchema = Schema.Union([ - Schema.Struct({ key: stableKey, kind: Schema.Literal('boolean'), value: Schema.Boolean }), - Schema.Struct({ key: stableKey, kind: Schema.Literal('string'), value: boundedText }), + Schema.Struct({ + key: stableKey, + kind: Schema.Literal('boolean'), + value: Schema.Boolean, + }), + Schema.Struct({ + key: stableKey, + kind: Schema.Literal('string'), + value: boundedText, + }), Schema.Struct({ key: stableKey, kind: Schema.Literal('strings'), @@ -80,9 +94,7 @@ export const CoreSearchProjectionDocumentSchema = Schema.Struct({ searchableText: Schema.Array(boundedText).check(Schema.isMaxLength(100)), selectedLegalEntityId: Schema.optionalKey(selectedLegalEntityId), subjectRef: Schema.optionalKey(CoreSearchResourceRefSchema), - temporalFacets: Schema.optionalKey( - Schema.Array(CoreSearchTemporalFacetSchema).check(Schema.isMaxLength(100)), - ), + temporalFacets: Schema.optionalKey(Schema.Array(CoreSearchTemporalFacetSchema).check(Schema.isMaxLength(100))), temporalSearchableText, title: boundedText, }); @@ -97,9 +109,7 @@ export const CoreSearchProjectionHitSchema = Schema.Struct({ ref: CoreSearchResourceRefSchema, selectedLegalEntityId: Schema.optionalKey(selectedLegalEntityId), subjectRef: Schema.optionalKey(CoreSearchResourceRefSchema), - temporalFacets: Schema.optionalKey( - Schema.Array(CoreSearchTemporalFacetSchema).check(Schema.isMaxLength(100)), - ), + temporalFacets: Schema.optionalKey(Schema.Array(CoreSearchTemporalFacetSchema).check(Schema.isMaxLength(100))), title: boundedText, }); export type CoreSearchProjectionHit = typeof CoreSearchProjectionHitSchema.Type; @@ -126,7 +136,10 @@ export const CoreSearchProjectionReplacementSchema = Schema.Struct({ export type CoreSearchProjectionReplacement = typeof CoreSearchProjectionReplacementSchema.Type; export const CoreSearchProjectionMutationSchema = Schema.Union([ - Schema.Struct({ document: CoreSearchProjectionDocumentSchema, kind: Schema.Literal('upsert') }), + Schema.Struct({ + document: CoreSearchProjectionDocumentSchema, + kind: Schema.Literal('upsert'), + }), Schema.Struct({ kind: Schema.Literal('delete'), projectionVersion, @@ -153,62 +166,12 @@ const CoreSearchProjectionUnavailableSchema = Schema.TaggedStruct( 'CoreSearchProjectionUnavailable', projectionUnavailableFields, ); -export type CoreSearchProjectionUnavailableError = - typeof CoreSearchProjectionUnavailableSchema.Type; -export const CoreSearchProjectionUnavailable = - Schema.TaggedError()( - 'CoreSearchProjectionUnavailable', - projectionUnavailableFields, - ); -type CoreSearchProjectionUnavailableInstance = InstanceType; - -export interface CoreSearchProjectionStoreService { - /** Applies one idempotent versioned lifecycle observation. */ - readonly apply: ( - input: UnparsedCoreSearchInput, - ) => Effect.Effect; - /** Candidate access is Core-private: the query runtime strips searchable evidence before return. */ - readonly queryCandidates: ( - input: CoreSearchQuery, - ) => Effect.Effect< - readonly CoreSearchProjectionDocument[], - CoreSearchProjectionUnavailableInstance - >; - /** - * Replaces one tenant/module/resource projection as one physical rebuild unit. Implementations - * must leave the prior unit intact when validation or persistence fails. - */ - readonly replace: ( - input: UnparsedCoreSearchInput, - ) => Effect.Effect; -} - -/** Production persistence implements this Core-owned port; business modules never own an index. */ -const defineContextService = Context.Service; -export const CoreSearchProjectionStore = defineContextService( - '@app/core-runtime/search/projection/CoreSearchProjectionStore', -); -type CoreSearchProjectionStorePort = - typeof CoreSearchProjectionStore extends Context.Service - ? Store - : never; - -export interface CoreSearchQueryRuntimeService { - readonly search: ( - input: UnparsedCoreSearchInput, - ) => Effect.Effect< - readonly CoreSearchProjectionHit[], - CoreSearchProjectionInvalid | CoreSearchProjectionUnavailableInstance - >; -} - -export const CoreSearchQueryRuntime = defineContextService( - '@app/core-runtime/search/projection/CoreSearchQueryRuntime', -); - -const projectionUnitKeyCodec = Schema.fromJsonString( - Schema.Tuple([Schema.String, Schema.String, Schema.String]), +export type CoreSearchProjectionUnavailableError = typeof CoreSearchProjectionUnavailableSchema.Type; +export const CoreSearchProjectionUnavailable = Schema.TaggedError()( + 'CoreSearchProjectionUnavailable', + projectionUnavailableFields, ); +const projectionUnitKeyCodec = Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.String])); const encodeProjectionUnitKey = Schema.encodeUnknownResult(projectionUnitKeyCodec); const projectionUnitKey = (tenant: string, module: string, type: string): string => Result.getOrThrow(encodeProjectionUnitKey([tenant, module, type])); @@ -217,7 +180,10 @@ const normalize = (value: string): string => value.normalize('NFKC').toLocaleLow const invalid = (reason: string, cause?: unknown): CoreSearchProjectionInvalid => { if (cause === undefined) { - return new CoreSearchProjectionInvalid({ code: 'core_search_projection_invalid', reason }); + return new CoreSearchProjectionInvalid({ + code: 'core_search_projection_invalid', + reason, + }); } return new CoreSearchProjectionInvalid({ cause, @@ -232,19 +198,13 @@ const hasUniqueKeys = (values: readonly { readonly key: string }[]): boolean => const toEpochMillis = (value: string): number | undefined => DateTime.make(value).pipe(Option.map(DateTime.toEpochMillis), Option.getOrUndefined); -const invalidPeriod = ({ - validFrom, - validTo, -}: Readonly<{ validFrom: string; validTo?: string }>): boolean => { +const invalidPeriod = ({ validFrom, validTo }: Readonly<{ validFrom: string; validTo?: string }>): boolean => { const from = toEpochMillis(validFrom); const to = validTo === undefined ? undefined : toEpochMillis(validTo); return from === undefined || (validTo !== undefined && (to === undefined || to <= from)); }; -const hasForeignDocumentReference = ( - document: CoreSearchProjectionDocument, - tenant: string, -): boolean => +const hasForeignDocumentReference = (document: CoreSearchProjectionDocument, tenant: string): boolean => [document.matchedRef, document.subjectRef, document.matchedSubjectRef].some( (ref) => ref !== undefined && ref.tenantId !== tenant, ); @@ -252,26 +212,23 @@ const hasForeignDocumentReference = ( const hasInvalidDocumentFacets = (document: CoreSearchProjectionDocument): boolean => !hasUniqueKeys(document.facets) || !hasUniqueKeys(document.metadata) || - document.facets.some( - ({ values }) => values.length === 0 || new Set(values).size !== values.length, - ); + document.facets.some(({ values }) => values.length === 0 || new Set(values).size !== values.length); const hasInvalidDocumentPeriods = (document: CoreSearchProjectionDocument): boolean => - (document.temporalFacets ?? []).some(invalidPeriod) || - (document.temporalSearchableText ?? []).some(invalidPeriod); + (document.temporalFacets ?? []).some(invalidPeriod) || (document.temporalSearchableText ?? []).some(invalidPeriod); -const hasInvalidDocumentAliases = ( - document: CoreSearchProjectionDocument, - tenant: string, -): boolean => +const hasInvalidDocumentAliases = (document: CoreSearchProjectionDocument, tenant: string): boolean => (document.aliases ?? []).some( - (alias) => - alias.ref.tenantId !== tenant || (alias.temporalSearchableText ?? []).some(invalidPeriod), + (alias) => alias.ref.tenantId !== tenant || (alias.temporalSearchableText ?? []).some(invalidPeriod), ); const validateDocument = ( document: CoreSearchProjectionDocument, - expected: Readonly<{ moduleId: string; resourceType: string; tenantId: string }>, + expected: Readonly<{ + moduleId: string; + resourceType: string; + tenantId: string; + }>, ): Result.Result => { if ( document.ref.tenantId !== expected.tenantId || @@ -289,10 +246,7 @@ const validateDocument = ( const validateReplacement = ( input: typeof CoreSearchProjectionReplacementSchema.Type, -): Result.Result< - typeof CoreSearchProjectionReplacementSchema.Type, - CoreSearchProjectionInvalid -> => { +): Result.Result => { const seen = new Set(); const rebuildVersion = BigInt(input.rebuildVersion); for (const document of input.documents) { @@ -336,9 +290,7 @@ export const decodeCoreSearchProjectionReplacement = ( ), ); -export const decodeCoreSearchProjectionMutation = ( - input: UnparsedCoreSearchInput, -): CoreSearchProjectionMutation => +export const decodeCoreSearchProjectionMutation = (input: UnparsedCoreSearchInput): CoreSearchProjectionMutation => Result.getOrThrow( Result.flatMap( Schema.decodeUnknownResult(CoreSearchProjectionMutationSchema, { @@ -364,9 +316,7 @@ const stableJson = (value: UnparsedCoreSearchInput): string => { }; /** Private Core persistence identity, independent of transport object/document ordering. */ -export const coreSearchReplacementFingerprint = ( - replacement: CoreSearchProjectionReplacement, -): string => +export const coreSearchReplacementFingerprint = (replacement: CoreSearchProjectionReplacement): string => createHash('sha256') .update( stableJson({ @@ -384,9 +334,7 @@ const decodeMutationEffect = (input: UnparsedCoreSearchInput) => Schema.decodeUnknownEffect(CoreSearchProjectionMutationSchema, { onExcessProperty: 'error', })(input).pipe( - Effect.mapError((cause) => - invalid('Core Search mutation does not match its declared contract', cause), - ), + Effect.mapError((cause) => invalid('Core Search mutation does not match its declared contract', cause)), Effect.flatMap((mutation) => Effect.fromResult(validateMutation(mutation))), ); @@ -394,9 +342,7 @@ const decodeReplacementEffect = (input: UnparsedCoreSearchInput) => Schema.decodeUnknownEffect(CoreSearchProjectionReplacementSchema, { onExcessProperty: 'error', })(input).pipe( - Effect.mapError((cause) => - invalid('Core Search replacement does not match its declared contract', cause), - ), + Effect.mapError((cause) => invalid('Core Search replacement does not match its declared contract', cause)), Effect.flatMap((replacement) => Effect.fromResult(validateReplacement(replacement))), ); @@ -405,10 +351,7 @@ type Stored = Readonly<{ readonly projectionVersion: string; }>; -const sameStoredDocument = ( - current: Stored, - next: CoreSearchProjectionDocument | undefined, -): boolean => +const sameStoredDocument = (current: Stored, next: CoreSearchProjectionDocument | undefined): boolean => current.document === undefined ? next === undefined : next !== undefined && projectionDocumentEquivalence(current.document, next); @@ -454,34 +397,22 @@ const mergeReplacementDocuments = ( ): Result.Result => { for (const document of documents) { const existing = current.get(document.ref.resourceId); - if ( - existing === undefined || - BigInt(existing.projectionVersion) < BigInt(document.projectionVersion) - ) { + if (existing === undefined || BigInt(existing.projectionVersion) < BigInt(document.projectionVersion)) { current.set(document.ref.resourceId, { document, projectionVersion: document.projectionVersion, }); - } else if ( - existing.projectionVersion === document.projectionVersion && - !sameStoredDocument(existing, document) - ) { + } else if (existing.projectionVersion === document.projectionVersion && !sameStoredDocument(existing, document)) { return Result.fail(invalid('Core Search rebuild reuses a version for different content')); } } return Result.succeed(true); }; -const retireMissingDocuments = ( - current: Map, - replacement: CoreSearchProjectionReplacement, -): void => { +const retireMissingDocuments = (current: Map, replacement: CoreSearchProjectionReplacement): void => { const nextIds = new Set(replacement.documents.map(({ ref }) => ref.resourceId)); for (const [id, existing] of current) { - if ( - !nextIds.has(id) && - BigInt(existing.projectionVersion) < BigInt(replacement.rebuildVersion) - ) { + if (!nextIds.has(id) && BigInt(existing.projectionVersion) < BigInt(replacement.rebuildVersion)) { current.set(id, { projectionVersion: replacement.rebuildVersion }); } } @@ -490,82 +421,62 @@ const retireMissingDocuments = ( export const makeInMemoryCoreSearchProjectionStore = (): CoreSearchProjectionStoreService => { const units = new Map>(); const rebuilds = new Map(); - const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn( - 'CoreSearchProjectionStore.apply', - )(function* applyCoreSearchProjection(input: UnparsedCoreSearchInput) { - const mutation = yield* decodeMutationEffect(input); - const ref = mutation.kind === 'upsert' ? mutation.document.ref : mutation.ref; - const version = - mutation.kind === 'upsert' ? mutation.document.projectionVersion : mutation.projectionVersion; - const unitKey = projectionUnitKey(ref.tenantId, ref.moduleId, ref.resourceType); - const rebuild = rebuilds.get(unitKey); - if (rebuild !== undefined && BigInt(version) <= rebuild.version) { - return yield* Effect.void; - } - const unit = units.get(unitKey) ?? new Map(); - const next: Stored = - mutation.kind === 'upsert' - ? { document: mutation.document, projectionVersion: version } - : { projectionVersion: version }; - const shouldApply = yield* Effect.fromResult( - shouldApplyMutation(unit.get(ref.resourceId), next), - ); - if (!shouldApply) { + const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn('CoreSearchProjectionStore.apply')( + function* applyCoreSearchProjection(input: UnparsedCoreSearchInput) { + const mutation = yield* decodeMutationEffect(input); + const ref = mutation.kind === 'upsert' ? mutation.document.ref : mutation.ref; + const version = mutation.kind === 'upsert' ? mutation.document.projectionVersion : mutation.projectionVersion; + const unitKey = projectionUnitKey(ref.tenantId, ref.moduleId, ref.resourceType); + const rebuild = rebuilds.get(unitKey); + if (rebuild !== undefined && BigInt(version) <= rebuild.version) { + return yield* Effect.void; + } + const unit = units.get(unitKey) ?? new Map(); + const next: Stored = + mutation.kind === 'upsert' + ? { document: mutation.document, projectionVersion: version } + : { projectionVersion: version }; + const shouldApply = yield* Effect.fromResult(shouldApplyMutation(unit.get(ref.resourceId), next)); + if (!shouldApply) { + return yield* Effect.void; + } + unit.set(ref.resourceId, next); + units.set(unitKey, unit); return yield* Effect.void; - } - unit.set(ref.resourceId, next); - units.set(unitKey, unit); - return yield* Effect.void; - }); + }, + ); const queryCandidates: CoreSearchProjectionStoreService['queryCandidates'] = (input) => Effect.sync(() => - [ - ...(units - .get(projectionUnitKey(input.tenantId, input.moduleId, input.resourceType)) - ?.values() ?? []), - ].flatMap(({ document }) => (document === undefined ? [] : [document])), - ); - const replace: CoreSearchProjectionStoreService['replace'] = Effect.fn( - 'CoreSearchProjectionStore.replace', - )(function* replaceCoreSearchProjection(input: UnparsedCoreSearchInput) { - const replacement = yield* decodeReplacementEffect(input); - const unitKey = projectionUnitKey( - replacement.tenantId, - replacement.moduleId, - replacement.resourceType, - ); - const prior = rebuilds.get(unitKey); - const version = BigInt(replacement.rebuildVersion); - const fingerprint = coreSearchReplacementFingerprint(replacement); - const shouldReplace = yield* Effect.fromResult( - shouldReplaceProjection(prior, version, fingerprint), + [...(units.get(projectionUnitKey(input.tenantId, input.moduleId, input.resourceType))?.values() ?? [])].flatMap( + ({ document }) => (document === undefined ? [] : [document]), + ), ); - if (!shouldReplace) { + const replace: CoreSearchProjectionStoreService['replace'] = Effect.fn('CoreSearchProjectionStore.replace')( + function* replaceCoreSearchProjection(input: UnparsedCoreSearchInput) { + const replacement = yield* decodeReplacementEffect(input); + const unitKey = projectionUnitKey(replacement.tenantId, replacement.moduleId, replacement.resourceType); + const prior = rebuilds.get(unitKey); + const version = BigInt(replacement.rebuildVersion); + const fingerprint = coreSearchReplacementFingerprint(replacement); + const shouldReplace = yield* Effect.fromResult(shouldReplaceProjection(prior, version, fingerprint)); + if (!shouldReplace) { + return yield* Effect.void; + } + const current = new Map(units.get(unitKey)); + yield* Effect.fromResult(mergeReplacementDocuments(current, replacement.documents)); + retireMissingDocuments(current, replacement); + units.set(unitKey, current); + rebuilds.set(unitKey, { fingerprint, version }); return yield* Effect.void; - } - const current = new Map(units.get(unitKey)); - yield* Effect.fromResult(mergeReplacementDocuments(current, replacement.documents)); - retireMissingDocuments(current, replacement); - units.set(unitKey, current); - rebuilds.set(unitKey, { fingerprint, version }); - return yield* Effect.void; - }); + }, + ); return Object.freeze({ apply, queryCandidates, replace }); }; -const isEffectiveTemporalFacet = ( - temporal: CoreSearchTemporalFacet, - key: string, - effectiveAt: number, -): boolean => { +const isEffectiveTemporalFacet = (temporal: CoreSearchTemporalFacet, key: string, effectiveAt: number): boolean => { const from = toEpochMillis(temporal.validFrom); const to = temporal.validTo === undefined ? undefined : toEpochMillis(temporal.validTo); - return ( - temporal.key === key && - from !== undefined && - from <= effectiveAt && - (to === undefined || effectiveAt < to) - ); + return temporal.key === key && from !== undefined && from <= effectiveAt && (to === undefined || effectiveAt < to); }; const matchesFacets = ( @@ -621,8 +532,7 @@ const matchDocument = ( effectiveAt: number, ): CoreSearchProjectionHit | undefined => { const hit = toHit(document); - const matches = (values: readonly string[]) => - values.some((value) => normalize(value).includes(needle)); + const matches = (values: readonly string[]) => values.some((value) => normalize(value).includes(needle)); const activeValues = ( values: readonly (typeof CoreSearchTemporalSearchableTextSchema.Type)[] = [], ): readonly string[] => { @@ -636,13 +546,7 @@ const matchDocument = ( } return active; }; - if ( - matches([ - document.title, - ...document.searchableText, - ...activeValues(document.temporalSearchableText), - ]) - ) { + if (matches([document.title, ...document.searchableText, ...activeValues(document.temporalSearchableText)])) { return hit; } const alias = document.aliases?.find((candidate) => @@ -651,47 +555,43 @@ const matchDocument = ( if (alias === undefined) { return undefined; } - return alias.kind === 'resource' - ? { ...hit, matchedRef: alias.ref } - : { ...hit, matchedSubjectRef: alias.ref }; + return alias.kind === 'resource' ? { ...hit, matchedRef: alias.ref } : { ...hit, matchedSubjectRef: alias.ref }; }; -export const createCoreSearchQueryRuntime = ( - store: CoreSearchProjectionStorePort, -): CoreSearchQueryRuntimeService => { - const search: CoreSearchQueryRuntimeService['search'] = Effect.fn( - 'CoreSearchQueryRuntime.search', - )(function* searchCoreSearchProjection(input: UnparsedCoreSearchInput) { - const query = yield* Schema.decodeUnknownEffect(CoreSearchQuerySchema)(input).pipe( - Effect.mapError((cause) => - invalid('Core Search query does not match its declared contract', cause), - ), - ); - const documents = yield* store.queryCandidates(query); - const needle = normalize(query.query); - const requestedFacets = query.facets ?? []; - const effectiveAt = - query.effectiveAt === undefined - ? yield* Clock.currentTimeMillis - : DateTime.toEpochMillis(query.effectiveAt); - const hits: CoreSearchProjectionHit[] = []; - for (const document of documents) { - if ( - (query.includeArchived || !document.archived) && - document.selectedLegalEntityId === query.selectedLegalEntityId && - matchesFacets(document, requestedFacets, effectiveAt) - ) { - const hit = matchDocument(document, needle, effectiveAt); - if (hit !== undefined) { - hits.push(hit); +export const createCoreSearchQueryRuntime: Effect.Effect< + CoreSearchQueryRuntimeService, + never, + CoreSearchProjectionStore +> = Effect.gen(function* createCoreSearchQueryRuntimeService() { + const store = yield* CoreSearchProjectionStore; + const search: CoreSearchQueryRuntimeService['search'] = Effect.fn('CoreSearchQueryRuntime.search')( + function* searchCoreSearchProjection(input: UnparsedCoreSearchInput) { + const query = yield* Schema.decodeUnknownEffect(CoreSearchQuerySchema)(input).pipe( + Effect.mapError((cause) => invalid('Core Search query does not match its declared contract', cause)), + ); + const documents = yield* store.queryCandidates(query); + const needle = normalize(query.query); + const requestedFacets = query.facets ?? []; + const effectiveAt = + query.effectiveAt === undefined ? yield* Clock.currentTimeMillis : DateTime.toEpochMillis(query.effectiveAt); + const hits: CoreSearchProjectionHit[] = []; + for (const document of documents) { + if ( + (query.includeArchived || !document.archived) && + document.selectedLegalEntityId === query.selectedLegalEntityId && + matchesFacets(document, requestedFacets, effectiveAt) + ) { + const hit = matchDocument(document, needle, effectiveAt); + if (hit !== undefined) { + hits.push(hit); + } } } - } - return hits.toSorted( - (left, right) => - left.title.localeCompare(right.title) || - left.ref.resourceId.localeCompare(right.ref.resourceId), - ); - }); + return hits.toSorted( + (left, right) => + left.title.localeCompare(right.title) || left.ref.resourceId.localeCompare(right.ref.resourceId), + ); + }, + ); return Object.freeze({ search }); -}; +}); diff --git a/app/packages/core-runtime/src/search/query-runtime.ts b/app/packages/core-runtime/src/search/query-runtime.ts new file mode 100644 index 000000000..5330f4447 --- /dev/null +++ b/app/packages/core-runtime/src/search/query-runtime.ts @@ -0,0 +1,26 @@ +import { Context } from 'effect'; +import type { Effect, Schema } from 'effect'; + +import type { + CoreSearchProjectionHit, + CoreSearchProjectionInvalid, + CoreSearchProjectionUnavailable, +} from './projection.ts'; + +type UnparsedCoreSearchInput = typeof Schema.Unknown.Type; + +export interface CoreSearchQueryRuntimeService { + readonly search: ( + input: UnparsedCoreSearchInput, + ) => Effect.Effect< + readonly CoreSearchProjectionHit[], + CoreSearchProjectionInvalid | InstanceType + >; +} + +/** Core-owned query port returns hits without private searchable evidence. */ +export class CoreSearchQueryRuntime extends Context.Service()( + // Preserve the public Context identity after splitting the service into its owning module. + // @effect-diagnostics-next-line deterministicKeys:off + '@app/core-runtime/search/projection/CoreSearchQueryRuntime', +) {} diff --git a/app/packages/core-runtime/src/search/worker-snapshot.ts b/app/packages/core-runtime/src/search/worker-snapshot.ts index 1b996dda2..94f8ebedb 100644 --- a/app/packages/core-runtime/src/search/worker-snapshot.ts +++ b/app/packages/core-runtime/src/search/worker-snapshot.ts @@ -1,12 +1,10 @@ -import { SqlError, isSqlError } from 'effect/unstable/sql/SqlError'; /* oxlint-disable sonarjs/no-built-in-override -- Existing compatibility boundary; expires: 2026-12-31. */ import { and, eq, sql } from 'drizzle-orm'; import { Context, Effect, Exit, Layer, Option, Schema } from 'effect'; +import { SqlError, isSqlError } from 'effect/unstable/sql/SqlError'; + import type { DatabaseDriverFailure } from '../database/driver-failure.ts'; -import { - DatabaseTransactionFailure, - decodeDatabaseDriverFailure, -} from '../database/driver-failure.ts'; +import { DatabaseTransactionFailure, decodeDatabaseDriverFailure } from '../database/driver-failure.ts'; import { CoreDatabase } from '../db/client.ts'; import { domainEvents, legalEntities, searchProjectionGenerations } from '../db/schema.ts'; import type { CoreDatabaseExecutor, CoreTransaction } from '../db/types.ts'; @@ -84,9 +82,7 @@ export interface CoreSearchSnapshotBackend { use: ( scope: SnapshotScope, executor: CoreSearchSnapshotReadExecutor, - install: ( - legalEntityId?: string, - ) => Effect.Effect, + install: (legalEntityId?: string) => Effect.Effect, ) => Effect.Effect, ) => Effect.Effect; } @@ -103,22 +99,14 @@ const viewForSnapshot = ( read: (executor: CoreSearchSnapshotReadExecutor) => Effect.Effect, ): Effect.Effect => Effect.suspend((): Effect.Effect => { - if ( - !active || - inUse || - (legalEntityId !== undefined && !scope.legalEntityIds.includes(legalEntityId)) - ) { + if (!active || inUse || (legalEntityId !== undefined && !scope.legalEntityIds.includes(legalEntityId))) { return Effect.fail(invalid()); } inUse = true; return Effect.gen(function* readOwnedScope() { - const exit = yield* Effect.exit( - install(legalEntityId).pipe(Effect.andThen(read(executor))), - ); + const exit = yield* Effect.exit(install(legalEntityId).pipe(Effect.andThen(read(executor)))); yield* install(); - return yield* Exit.isSuccess(exit) - ? Effect.succeed(exit.value) - : Effect.failCause(exit.cause); + return yield* Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -133,9 +121,8 @@ const viewForSnapshot = ( legalEntityId: string, read: (executor: CoreSearchSnapshotReadExecutor) => Effect.Effect, ) => scoped(legalEntityId, read), - tenant: ( - read: (executor: CoreSearchSnapshotReadExecutor) => Effect.Effect, - ) => scoped(undefined, read), + tenant: (read: (executor: CoreSearchSnapshotReadExecutor) => Effect.Effect) => + scoped(undefined, read), }); return { close: () => { @@ -145,9 +132,7 @@ const viewForSnapshot = ( }; }; -export const makeCoreSearchWorkerSnapshot = ( - backend: CoreSearchSnapshotBackend, -): CoreSearchWorkerSnapshotService => ({ +export const makeCoreSearchWorkerSnapshot = (backend: CoreSearchSnapshotBackend): CoreSearchWorkerSnapshotService => ({ read: ( context: OutboxWorkerHandlerContext, read: (snapshot: CoreSearchWorkerSnapshotView) => Effect.Effect, @@ -181,9 +166,7 @@ const serializationFailure = (cause: unknown): boolean => Option.exists( decodeDatabaseDriverFailure(cause), (failure) => - Schema.is(DatabaseTransactionFailure)(failure) && - failure.kind === 'sqlstate' && - failure.code.slice(2) === '001', + Schema.is(DatabaseTransactionFailure)(failure) && failure.kind === 'sqlstate' && failure.code.slice(2) === '001', ); /** Bounded retry is restricted to PostgreSQL snapshot serialization failures. */ @@ -208,9 +191,7 @@ export const makePostgresCoreSearchSnapshotBackend = ( readSnapshot: ( scope: SnapshotScope, executor: CoreSearchSnapshotReadExecutor, - install: ( - legalEntityId?: string, - ) => Effect.Effect, + install: (legalEntityId?: string) => Effect.Effect, ) => Effect.Effect, ) { const transactionProgram = Effect.fn('CoreSearchSnapshotBackend.transaction')( @@ -231,17 +212,13 @@ export const makePostgresCoreSearchSnapshotBackend = ( ) .pipe(Effect.mapError(snapshotDriverError)); const [setting] = result; - if ( - setting?.tenant_id !== context.tenantId || - setting.legal_entity_id !== (legalEntityId ?? '') - ) { + if (setting?.tenant_id !== context.tenantId || setting.legal_entity_id !== (legalEntityId ?? '')) { return yield* unavailable(); } return yield* Effect.void; }, ); - const install = (legalEntityId?: string) => - installScope(legalEntityId).pipe(Effect.mapError(unavailable)); + const install = (legalEntityId?: string) => installScope(legalEntityId).pipe(Effect.mapError(unavailable)); yield* installScope(); // RR rejects a waiter whose snapshot predates the preceding generation commit. @@ -259,10 +236,7 @@ export const makePostgresCoreSearchSnapshotBackend = ( generation: sql`${searchProjectionGenerations.generation} + 1`, updatedAt: sql`now()`, }, - target: [ - searchProjectionGenerations.tenantId, - searchProjectionGenerations.sourceModuleKey, - ], + target: [searchProjectionGenerations.tenantId, searchProjectionGenerations.sourceModuleKey], }) .returning({ version: searchProjectionGenerations.generation }) .pipe(Effect.mapError(snapshotDriverError)); @@ -270,7 +244,9 @@ export const makePostgresCoreSearchSnapshotBackend = ( return yield* unavailable(); } const [watermark] = yield* transaction - .select({ version: sql`max(${domainEvents.tenantSequenceNo})::text` }) + .select({ + version: sql`max(${domainEvents.tenantSequenceNo})::text`, + }) .from(domainEvents) .where(eq(domainEvents.tenantId, context.tenantId)) .pipe(Effect.mapError(snapshotDriverError)); @@ -315,9 +291,7 @@ export const makePostgresCoreSearchSnapshotBackend = ( const snapshotExit = yield* retryCoreSearchSnapshot( database.executor .transaction( - Effect.fn('snapshotTransactionEffect')(function* snapshotTransactionEffect( - transaction: CoreTransaction, - ) { + Effect.fn('snapshotTransactionEffect')(function* snapshotTransactionEffect(transaction: CoreTransaction) { yield* transaction.setTransaction({ isolationLevel: 'repeatable read', }); @@ -325,12 +299,8 @@ export const makePostgresCoreSearchSnapshotBackend = ( }), ) .pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), - Effect.mapError((failure) => - Schema.is(SqlError)(failure) ? snapshotDriverError(failure) : failure, - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), + Effect.mapError((failure) => (Schema.is(SqlError)(failure) ? snapshotDriverError(failure) : failure)), ), ).pipe(Effect.mapError(unavailable)); return yield* Exit.isSuccess(snapshotExit) diff --git a/app/packages/core-runtime/src/testing/actions.ts b/app/packages/core-runtime/src/testing/actions.ts index 037f33d2b..41fa9e4da 100644 --- a/app/packages/core-runtime/src/testing/actions.ts +++ b/app/packages/core-runtime/src/testing/actions.ts @@ -1,15 +1,13 @@ +import { randomUUID } from 'node:crypto'; + import { PgClient } from '@effect/sql-pg'; import { makeWithDefaults } from 'drizzle-orm/effect-postgres'; import { DateTime, Deferred, Effect, Layer, Schema, Stream } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; import type { Connection } from 'effect/unstable/sql/SqlConnection'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; -import { randomUUID } from 'node:crypto'; -import type { - ActionRegistration, - ActionServiceFactory, - AnyActionRegistration, -} from '../actions/definition.ts'; + +import type { ActionRegistration, ActionServiceFactory, AnyActionRegistration } from '../actions/definition.ts'; import { getActionServiceFactory } from '../actions/definition.ts'; import { ActionInvocationNotFound, @@ -40,12 +38,7 @@ import type { ContextAccessDecision, ContextAccessService } from '../permissions const actionTestServiceBinding: unique symbol = Symbol('test-action-service-binding'); const querySchema = Schema.Union([Schema.String, Schema.Struct({ text: Schema.String })]); const scopeValuesSchema = Schema.Tuple([Schema.String, Schema.String]); -const idempotencyScopeSchema = Schema.Tuple([ - Schema.String, - Schema.String, - Schema.String, - Schema.String, -]); +const idempotencyScopeSchema = Schema.Tuple([Schema.String, Schema.String, Schema.String, Schema.String]); const encodeIdempotencyScope = Schema.encodeEffect(Schema.fromJsonString(idempotencyScopeSchema)); const testCommitAcknowledgementSqlState = ['0', '8007'].join(''); const completionTime = () => DateTime.toDateUtc(DateTime.makeUnsafe(0)); @@ -89,18 +82,9 @@ export const bindActionTestServices = < Services, Requirements, >( - registration: ActionRegistration< - Payload, - Result, - DomainError, - Events, - Owner, - Services, - Requirements - >, + registration: ActionRegistration, services: NoInfer, -): ActionTestServiceBinding => - Object.freeze(new ActionTestServiceBindingValue({ registration, services })); +): ActionTestServiceBinding => Object.freeze(new ActionTestServiceBindingValue({ registration, services })); export interface ActionTestHarnessOptions { readonly actionPermission?: ContextAccessDecision; @@ -159,13 +143,9 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe let pendingCommit: Effect.Effect[] = []; let connectionQueue = Effect.void; - const find = ( - id: string, - ): Effect.Effect => { + const find = (id: string): Effect.Effect => { const invocation = invocations.get(id); - return invocation === undefined - ? Effect.fail(persistenceFailure()) - : Effect.succeed(invocation); + return invocation === undefined ? Effect.fail(persistenceFailure()) : Effect.succeed(invocation); }; const prepare = Effect.fn('ActionTestHarness.prepare')(function* prepareTestInvocation( input: PrepareActionInvocationInput, @@ -210,10 +190,7 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe status: 'succeeded', }); }); - const recordRejection = ( - input: Input, - denials: Input[], - ) => + const recordRejection = (input: Input, denials: Input[]) => find(input.actionInvocationId).pipe( Effect.flatMap((invocation) => Effect.sync(() => { @@ -261,43 +238,43 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe ), }; - const executeTestQuery = Effect.fn('ActionTestHarness.executeQuery')( - function* executeTestQueryEffect( - scope: ActionTestConnectionScope, - query: Query, - values?: Values, - ) { - const decoded = yield* Schema.decodeUnknownEffect(querySchema)(query); - const sql = Schema.is(Schema.String)(decoded) ? decoded : decoded.text; - if (sql === 'begin') { - transactionCount += 1; - } else if (sql === 'commit') { - yield* Effect.all(pendingCommit, { concurrency: 1, discard: true }); - pendingCommit = []; - if (loseCommitAcknowledgement) { - loseCommitAcknowledgement = false; - return yield* new DatabaseCommitAcknowledgementAmbiguous({ - code: testCommitAcknowledgementSqlState, - kind: 'sqlstate', - }); - } - } else if (sql === 'rollback') { - pendingCommit = []; - } else if (sql.includes('set_config')) { - [scope.tenantId, scope.legalEntityId] = - yield* Schema.decodeUnknownEffect(scopeValuesSchema)(values); - } else if (sql.includes('current_setting')) { - return { - rows: [{ legal_entity_id: scope.legalEntityId, tenant_id: scope.tenantId }], - }; - } else { - return yield* Effect.die( - 'Owner SQL is unavailable in the Action test harness; bind typed services', - ); + const executeTestQuery = Effect.fn('ActionTestHarness.executeQuery')(function* executeTestQueryEffect( + scope: ActionTestConnectionScope, + query: Query, + values?: Values, + ) { + const decoded = yield* Schema.decodeUnknownEffect(querySchema)(query); + const sql = Schema.is(Schema.String)(decoded) ? decoded : decoded.text; + if (sql === 'begin') { + transactionCount += 1; + } else if (sql === 'commit') { + yield* Effect.all(pendingCommit, { concurrency: 1, discard: true }); + pendingCommit = []; + if (loseCommitAcknowledgement) { + loseCommitAcknowledgement = false; + return yield* new DatabaseCommitAcknowledgementAmbiguous({ + code: testCommitAcknowledgementSqlState, + kind: 'sqlstate', + }); } - return { rows: [] }; - }, - ); + } else if (sql === 'rollback') { + pendingCommit = []; + } else if (sql.includes('set_config')) { + [scope.tenantId, scope.legalEntityId] = yield* Schema.decodeUnknownEffect(scopeValuesSchema)(values); + } else if (sql.includes('current_setting')) { + return { + rows: [ + { + legal_entity_id: scope.legalEntityId, + tenant_id: scope.tenantId, + }, + ], + }; + } else { + return yield* Effect.die('Owner SQL is unavailable in the Action test harness; bind typed services'); + } + return { rows: [] }; + }); const acquireConnection = Effect.suspend(() => { const previous = connectionQueue; @@ -306,13 +283,13 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe return Effect.gen(function* acquireTestConnection() { yield* previous; yield* Effect.addFinalizer(() => Deferred.succeed(released, null)); - const scope: ActionTestConnectionScope = { legalEntityId: '', tenantId: '' }; + const scope: ActionTestConnectionScope = { + legalEntityId: '', + tenantId: '', + }; pendingCommit = []; const execute = (query: string, values: readonly unknown[]) => - executeTestQuery(scope, query.toLowerCase(), values).pipe( - Effect.map(queryRows), - Effect.mapError(sqlFailure), - ); + executeTestQuery(scope, query.toLowerCase(), values).pipe(Effect.map(queryRows), Effect.mapError(sqlFailure)); const unsupported = Effect.die('Owner SQL is unavailable in the Action test harness'); return { execute, @@ -351,8 +328,7 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe key, })), ), - modules: ({ moduleIds }) => - Effect.succeed(moduleIds.map((key) => ({ decision: 'allowed' as const, key }))), + modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision: 'allowed' as const, key }))), resources: ({ resources }) => Effect.succeed( resources.map((resource) => ({ @@ -405,7 +381,10 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe .filter((entrypoint) => entrypoint.scope === 'tenant') .map((entrypoint) => entrypoint.moduleKey), ), - ].map((moduleKey) => ({ moduleKey, state: options.moduleState ?? 'active' })), + ].map((moduleKey) => ({ + moduleKey, + state: options.moduleState ?? 'active', + })), ), ), recheckWrite: () => Effect.void, @@ -420,7 +399,9 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe const resolveServiceFactory: typeof getActionServiceFactory = < PayloadSchema extends Schema.ConstraintDecoder, ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder<{ readonly _tag: string }>, + DomainErrorSchema extends Schema.ConstraintDecoder<{ + readonly _tag: string; + }>, DomainEvents extends DomainEventContractMap, Owner extends string, Services, @@ -439,8 +420,7 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe if (!bindings.has(registration)) { return getActionServiceFactory(registration); } - return () => - Schema.decodeUnknownEffect(Schema.Any)(bindings.get(registration)).pipe(Effect.orDie); + return () => Schema.decodeUnknownEffect(Schema.Any)(bindings.get(registration)).pipe(Effect.orDie); }; const runtime = makeActionRuntime( database, @@ -473,9 +453,7 @@ const actionTestHarness = Effect.fn('ActionTestHarness.make')(function* actionTe snapshot: (): ActionTestSnapshot => Object.freeze({ committed: Object.freeze([...committed]), - invocations: Object.freeze( - [...invocations.values()].map((value) => Object.freeze({ ...value })), - ), + invocations: Object.freeze([...invocations.values()].map((value) => Object.freeze({ ...value }))), permissionDenials: Object.freeze([...permissionDenials]), policyDenials: Object.freeze([...policyDenials]), stages: Object.freeze([...stages]), diff --git a/app/packages/core-runtime/src/testing/live-operations.ts b/app/packages/core-runtime/src/testing/live-operations.ts index 839b8bb8f..675482d62 100644 --- a/app/packages/core-runtime/src/testing/live-operations.ts +++ b/app/packages/core-runtime/src/testing/live-operations.ts @@ -2,6 +2,7 @@ import { v1 } from '@authzed/authzed-node'; import { eq } from 'drizzle-orm'; import { Context, Duration, Effect, Exit, Layer, Random, Redacted, Schema, Scope } from 'effect'; import { Pool } from 'pg'; + import { ActionCommitIndeterminate, ActionTransactionError } from '../actions/errors.ts'; import type { ActionRepositoryService } from '../actions/repository.ts'; import { makeActionRepository } from '../actions/repository.ts'; @@ -24,10 +25,7 @@ import { buildActionAuthorizationRelationships } from '../install/action-authori import { makeModuleEntrypointGateway } from '../modules/module-entrypoint-gateway.ts'; import { makeModuleStateGate } from '../modules/module-state-gate.ts'; import { makeTenantModuleStateService } from '../modules/tenant-module-state-service.ts'; -import { - makeOperationalScopeRepository, - makeOperationalScopeResolver, -} from '../operations/context.ts'; +import { makeOperationalScopeRepository, makeOperationalScopeResolver } from '../operations/context.ts'; import { loadSpiceDbConfig } from '../permissions/config.ts'; import { makeContextAccessLive, @@ -60,15 +58,11 @@ const LiveOperationFixtureConfigurationSchema = Schema.Struct({ runtimeConnectionString: Schema.Redacted(Schema.String), }); -export type LiveOperationFixtureConfiguration = - typeof LiveOperationFixtureConfigurationSchema.Encoded; +export type LiveOperationFixtureConfiguration = typeof LiveOperationFixtureConfigurationSchema.Encoded; -class LiveOperationFixtureError extends Schema.TaggedError()( - 'LiveOperationFixtureError', - { - reason: Schema.String, - }, -) {} +class LiveOperationFixtureError extends Schema.TaggedError()('LiveOperationFixtureError', { + reason: Schema.String, +}) {} const fixtureFailure = (reason: string, cause?: unknown): LiveOperationFixtureError => { const failure = new LiveOperationFixtureError({ reason }); @@ -114,20 +108,20 @@ const makeFixtureId = Effect.fn('LiveOperations.makeFixtureId')(function* makeFi return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-a${value.slice(17, 20)}-${value.slice(20)}`; }); -const makeFixtureActor = Effect.fn('LiveOperations.makeFixtureActor')( - function* makeFixtureActorEffect(tenantId: string) { - const [authBindingId, principalId] = yield* Effect.all([makeFixtureId(), makeFixtureId()], { - concurrency: 2, - }); - return { - authBindingId, - authContextRef: `better-auth-session:${authBindingId}`, - authMethod: 'session' as const, - principalId, - tenantId, - }; - }, -); +const makeFixtureActor = Effect.fn('LiveOperations.makeFixtureActor')(function* makeFixtureActorEffect( + tenantId: string, +) { + const [authBindingId, principalId] = yield* Effect.all([makeFixtureId(), makeFixtureId()], { + concurrency: 2, + }); + return { + authBindingId, + authContextRef: `better-auth-session:${authBindingId}`, + authMethod: 'session' as const, + principalId, + tenantId, + }; +}); type FixtureActor = Effect.Success>; @@ -162,24 +156,13 @@ const fixtureAuthorizationRelationships = (input: { ...input.actors.map((principal) => relationship('tenant', input.tenantId, 'member', 'principal', principal.principalId), ), - ...[ - 'party_identity_manager', - 'party_identity_reader', - 'party_identity_reviewer', - 'party_relationship_manager', - ].map((relation) => - relationship('tenant', input.tenantId, relation, 'principal', input.manager.principalId), + ...['party_identity_manager', 'party_identity_reader', 'party_identity_reviewer', 'party_relationship_manager'].map( + (relation) => relationship('tenant', input.tenantId, relation, 'principal', input.manager.principalId), ), relationship('legal_entity', input.entityObject, 'tenant', 'tenant', input.tenantId), ...[input.manager, input.legalEntityOnly].flatMap((principal) => ['member', 'counterparty_manager', 'counterparty_reader'].map((relation) => - relationship( - 'legal_entity', - input.entityObject, - relation, - 'principal', - principal.principalId, - ), + relationship('legal_entity', input.entityObject, relation, 'principal', principal.principalId), ), ), ...buildActionAuthorizationRelationships(input.actionKeys, [ @@ -207,11 +190,7 @@ const setupLiveOperationFixture = Effect.fn('LiveOperations.setupLiveOperationFi status: 'active', tenantId: input.tenantId, }) - .pipe( - Effect.mapError((cause) => - fixtureFailure('Unable to create the live fixture tenant', cause), - ), - ); + .pipe(Effect.mapError((cause) => fixtureFailure('Unable to create the live fixture tenant', cause))); yield* input.executor .insert(legalEntities) .values({ @@ -222,35 +201,23 @@ const setupLiveOperationFixture = Effect.fn('LiveOperations.setupLiveOperationFi status: 'active', tenantId: input.tenantId, }) - .pipe( - Effect.mapError((cause) => - fixtureFailure('Unable to create the live fixture Legal Entity', cause), - ), - ); + .pipe(Effect.mapError((cause) => fixtureFailure('Unable to create the live fixture Legal Entity', cause))); yield* input.executor .insert(tenantModuleStates) - .values({ moduleKey: 'party.registry', state: 'active', tenantId: input.tenantId }) - .pipe( - Effect.mapError((cause) => - fixtureFailure('Unable to activate the live fixture module', cause), - ), - ); + .values({ + moduleKey: 'party.registry', + state: 'active', + tenantId: input.tenantId, + }) + .pipe(Effect.mapError((cause) => fixtureFailure('Unable to activate the live fixture module', cause))); yield* input.executor .insert(principals) .values(fixturePrincipalValues(input.actors, input.tenantId)) - .pipe( - Effect.mapError((cause) => - fixtureFailure('Unable to create the live fixture principals', cause), - ), - ); + .pipe(Effect.mapError((cause) => fixtureFailure('Unable to create the live fixture principals', cause))); yield* input.executor .insert(principalAuthBindings) .values(fixtureAuthBindingValues(input.actors, input.tenantId)) - .pipe( - Effect.mapError((cause) => - fixtureFailure('Unable to bind the live fixture principals', cause), - ), - ); + .pipe(Effect.mapError((cause) => fixtureFailure('Unable to bind the live fixture principals', cause))); const entityObject = toLegalEntityAccessObjectId(input.tenantId, input.legalEntityId); if (entityObject === undefined) { return yield* fixtureFailure('Invalid fixture Legal Entity'); @@ -281,18 +248,18 @@ const setupLiveOperationFixture = Effect.fn('LiveOperations.setupLiveOperationFi const makeFaultActionRepository = (state: FixtureFaultState): ActionRepositoryService => { const repository = makeActionRepository(); - const flushSuccess: ActionRepositoryService['flushSuccess'] = Effect.fn( - 'LiveOperations.flushSuccess', - )(function* flushSuccessEffect(transaction, input) { - state.invocationId = input.actionInvocationId; - if (state.active === 'rollback') { - return yield* new ActionTransactionError({ - code: 'action_transaction_failed', - reason: 'Controlled precommit rollback', - }); - } - return yield* repository.flushSuccess(transaction, input); - }); + const flushSuccess: ActionRepositoryService['flushSuccess'] = Effect.fn('LiveOperations.flushSuccess')( + function* flushSuccessEffect(transaction, input) { + state.invocationId = input.actionInvocationId; + if (state.active === 'rollback') { + return yield* new ActionTransactionError({ + code: 'action_transaction_failed', + reason: 'Controlled precommit rollback', + }); + } + return yield* repository.flushSuccess(transaction, input); + }, + ); return { ...repository, flushSuccess }; }; @@ -373,12 +340,8 @@ const grantFixtureResourceAccess = Effect.fn('LiveOperations.grantResourceAccess /** Real Core persistence and SpiceDB. Call only against a disposable local database. */ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperationFixture')( function* makeLiveOperationFixtureEffect(input: LiveOperationFixtureConfiguration) { - const configuration = yield* Schema.decodeUnknownEffect( - LiveOperationFixtureConfigurationSchema, - )(input).pipe( - Effect.mapError((cause) => - fixtureFailure('Invalid live operation fixture configuration', cause), - ), + const configuration = yield* Schema.decodeEffect(LiveOperationFixtureConfigurationSchema)(input).pipe( + Effect.mapError((cause) => fixtureFailure('Invalid live operation fixture configuration', cause)), ); const spiceDb = yield* loadSpiceDbConfig().pipe( Effect.mapError((cause) => fixtureFailure('Unable to load the SpiceDB configuration', cause)), @@ -387,10 +350,7 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati const address = yield* Schema.decodeEffect(Schema.URLFromString)(runtimeConnectionString).pipe( Effect.mapError((cause) => fixtureFailure('Invalid live operation database URL', cause)), ); - if ( - !['localhost', '127.0.0.1'].includes(address.hostname) || - !spiceDb.endpoint.startsWith('localhost:') - ) { + if (!['localhost', '127.0.0.1'].includes(address.hostname) || !spiceDb.endpoint.startsWith('localhost:')) { return yield* fixtureFailure('Live test fixtures require disposable localhost services'); } @@ -403,11 +363,7 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati Scope.provide(databaseScope), Effect.mapError((cause) => fixtureFailure('Unable to initialize fixture database', cause)), ); - const spice = v1.NewClient( - spiceDb.preSharedKey, - spiceDb.endpoint, - v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED, - ); + const spice = v1.NewClient(spiceDb.preSharedKey, spiceDb.endpoint, v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED); const [tenantId, legalEntityId] = yield* Effect.all([makeFixtureId(), makeFixtureId()], { concurrency: 2, }); @@ -432,8 +388,14 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati tenantId, }).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? closeResources : Effect.void))); - const faultState: FixtureFaultState = { active: null, invocationId: null, next: null }; - const actionDatabase = { executor } satisfies (typeof CoreDatabase)['Service']; + const faultState: FixtureFaultState = { + active: null, + invocationId: null, + next: null, + }; + const actionDatabase = { + executor, + } satisfies (typeof CoreDatabase)['Service']; const readDatabase = { executor } satisfies (typeof CoreDatabase)['Service']; const layer = Layer.effectContext( Effect.gen(function* makeLiveOperationRuntimeContext() { @@ -444,9 +406,7 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati ], { concurrency: 2 }, ); - const actionModuleStateGate = makeModuleStateGate( - makeTenantModuleStateService(actionDatabase), - ); + const actionModuleStateGate = makeModuleStateGate(makeTenantModuleStateService(actionDatabase)); const actionModuleEntrypointGateway = makeModuleEntrypointGateway(actionModuleStateGate); const actionScopeResolver = makeOperationalScopeResolver( makeOperationalScopeRepository(actionDatabase), @@ -469,32 +429,32 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati moduleStateGate: actionModuleStateGate, }, ); - const runAction: (typeof ActionRuntime)['Service']['runAction'] = Effect.fn( - 'LiveOperations.runAction', - )(function* runActionEffect(actionInput) { - const fault = faultState.next; - faultState.active = fault; - faultState.invocationId = null; - faultState.next = null; - const actionExit = yield* Effect.exit(baseActionRuntime.runAction(actionInput)).pipe( - Effect.ensuring( - Effect.sync(() => { - faultState.active = null; - }), - ), - ); - if (Exit.isFailure(actionExit)) { - return yield* Effect.failCause(actionExit.cause); - } - if (fault === 'lost-ack' && faultState.invocationId !== null) { - return yield* new ActionCommitIndeterminate({ - code: 'action_commit_indeterminate', - invocationId: faultState.invocationId, - reason: 'Controlled lost commit acknowledgement', - }); - } - return actionExit.value; - }); + const runAction: (typeof ActionRuntime)['Service']['runAction'] = Effect.fn('LiveOperations.runAction')( + function* runActionEffect(actionInput) { + const fault = faultState.next; + faultState.active = fault; + faultState.invocationId = null; + faultState.next = null; + const actionExit = yield* Effect.exit(baseActionRuntime.runAction(actionInput)).pipe( + Effect.ensuring( + Effect.sync(() => { + faultState.active = null; + }), + ), + ); + if (Exit.isFailure(actionExit)) { + return yield* Effect.failCause(actionExit.cause); + } + if (fault === 'lost-ack' && faultState.invocationId !== null) { + return yield* new ActionCommitIndeterminate({ + code: 'action_commit_indeterminate', + invocationId: faultState.invocationId, + reason: 'Controlled lost commit acknowledgement', + }); + } + return actionExit.value; + }, + ); const actionRuntime = { ...baseActionRuntime, runAction }; const readRuntime = makeReadRuntime( readDatabase, @@ -524,15 +484,7 @@ const makeLiveOperationFixtureEffect = Effect.fn('LiveOperations.makeLiveOperati }, principalId: string, permission: 'reader' | 'writer' = 'reader', - ) => - grantFixtureResourceAccess( - spice, - tenantId, - legalEntityId, - resource, - principalId, - permission, - ), + ) => grantFixtureResourceAccess(spice, tenantId, legalEntityId, resource, principalId, permission), layer, legalEntityId, legalEntityOnly, diff --git a/app/packages/core-runtime/tests/fixtures/operational-scope.ts b/app/packages/core-runtime/tests/fixtures/operational-scope.ts index 002ddcf71..a520aa919 100644 --- a/app/packages/core-runtime/tests/fixtures/operational-scope.ts +++ b/app/packages/core-runtime/tests/fixtures/operational-scope.ts @@ -1,13 +1,9 @@ import { Effect } from 'effect'; -import type { OperationalScopeResolverService } from '../../src/operations/context.ts'; + import { preserveSystemPrincipalContextTrust } from '../../src/auth/system-principal-context-provenance.ts'; +import type { OperationalScopeResolverService } from '../../src/operations/context.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, diff --git a/app/packages/core-runtime/tests/fixtures/outbox-worker-process.fixture.ts b/app/packages/core-runtime/tests/fixtures/outbox-worker-process.fixture.ts index c0be6d4b5..bd7dc6a8c 100644 --- a/app/packages/core-runtime/tests/fixtures/outbox-worker-process.fixture.ts +++ b/app/packages/core-runtime/tests/fixtures/outbox-worker-process.fixture.ts @@ -1,6 +1,7 @@ import { Effect, Layer, Schema } from 'effect'; -import { defineOutboxWorker } from '../../src/outbox/definition.ts'; + import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; +import { defineOutboxWorker } from '../../src/outbox/definition.ts'; import { startOutboxWorkerProcess } from '../../src/outbox/process.ts'; import { OutboxRuntime } from '../../src/outbox/runtime.ts'; diff --git a/app/packages/core-runtime/tests/integration/action-permission.test.ts b/app/packages/core-runtime/tests/integration/action-permission.test.ts index 9ad90d6f5..a3a215975 100644 --- a/app/packages/core-runtime/tests/integration/action-permission.test.ts +++ b/app/packages/core-runtime/tests/integration/action-permission.test.ts @@ -1,15 +1,15 @@ -// oxlint-disable-next-line max-classes-per-file -- Effect requires class declarations for both the typed error and fixture service; remove when this fixture no longer needs its client service. -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; + import { v1 } from '@authzed/authzed-node'; import { and, eq } from 'drizzle-orm'; import { Context, Effect, Layer, Exit, Schema, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; +import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; + import type { ActionHandlerContext } from '../../src/actions/context.ts'; import { defineAction } from '../../src/actions/definition.ts'; import { makeActionRepository } from '../../src/actions/repository.ts'; import { makeActionRuntime } from '../../src/actions/runtime.ts'; -import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; -import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { actionInvocations, @@ -36,10 +36,12 @@ import { } from '../../src/permissions/service.ts'; import { testOperationalScopeResolver } from '../fixtures/operational-scope.ts'; import { openActionRuntimeOptions } from '../support/action-runtime-options.ts'; +import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; +import { TestWriteError } from '../support/permission-write-error.ts'; -class TestWriteError extends Schema.TaggedError()('TestWriteError', { - reason: Schema.String, -}) {} +class PermissionAdmin extends Context.Service>()( + '@app/core-runtime/tests/integration/action-permission.test/PermissionAdmin', +) {} const suiteId = randomUUID(); const tenantId = randomUUID(); @@ -150,9 +152,7 @@ const relationship = ( objectType: executorSubject.objectType, }), optionalRelation: - relation === 'executor' && executorSubject.objectType === 'tenant' - ? executorSubject.optionalRelation - : '', + relation === 'executor' && executorSubject.objectType === 'tenant' ? executorSubject.optionalRelation : '', }), }); }; @@ -176,10 +176,7 @@ const NoDomainEvents = {}; interface PermissionActionServices { readonly transaction: ScopedTransactionExecutor; } -type PermissionActionContext = ActionHandlerContext< - typeof NoDomainEvents, - PermissionActionServices ->; +type PermissionActionContext = ActionHandlerContext; const registration = (actionKey: string, moduleStateKey: string, onExecute: () => void) => defineAction( @@ -194,7 +191,10 @@ const registration = (actionKey: string, moduleStateKey: string, onExecute: () = domainEvents: NoDomainEvents, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: actionKey, moduleKey: 'core.shell', role: 'action', @@ -221,9 +221,6 @@ const registration = (actionKey: string, moduleStateKey: string, onExecute: () = (transaction) => Effect.succeed({ transaction }), ); -class PermissionAdmin extends Context.Service>()( - '@app/core-runtime/tests/integration/action-permission.test/PermissionAdmin', -) {} const PermissionFixture = Layer.effect( PermissionAdmin, Effect.gen(function* integrationProgram1() { @@ -231,16 +228,12 @@ const PermissionFixture = Layer.effect( const adminClient = v1.NewClient( spiceDbConfig.preSharedKey, spiceDbConfig.endpoint, - spiceDbConfig.insecureLocal - ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED - : v1.ClientSecurity.SECURE, + spiceDbConfig.insecureLocal ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED : v1.ClientSecurity.SECURE, ); const prepare = Effect.gen(function* preparePermissionFixture() { yield* Effect.promise(() => - adminClient.promises.writeSchema( - v1.WriteSchemaRequest.create({ schema: ONTOS_SPICEDB_SCHEMA }), - ), + adminClient.promises.writeSchema(v1.WriteSchemaRequest.create({ schema: ONTOS_SPICEDB_SCHEMA })), ); yield* withDatabase( Effect.fn(function* seedPermissionFixture(database) { @@ -359,8 +352,9 @@ const PermissionFixture = Layer.effect( const cleanup = Effect.gen(function* cleanPermissionFixture() { const relationshipCleanupExit = yield* Effect.exit( - Effect.all( - [...relationshipActionKeys].map((actionKey) => + Effect.forEach( + [...relationshipActionKeys], + (actionKey) => Effect.promise(() => adminClient.promises.deleteRelationships( v1.DeleteRelationshipsRequest.create({ @@ -371,12 +365,12 @@ const PermissionFixture = Layer.effect( }), ), ), - ), { discard: true }, ).pipe( Effect.andThen( - Effect.all( - [tenantId, otherTenantId].map((membershipTenantId) => + Effect.forEach( + [tenantId, otherTenantId], + (membershipTenantId) => Effect.promise(() => adminClient.promises.deleteRelationships( v1.DeleteRelationshipsRequest.create({ @@ -387,7 +381,6 @@ const PermissionFixture = Layer.effect( }), ), ), - ), { discard: true }, ), ), @@ -398,57 +391,24 @@ const PermissionFixture = Layer.effect( yield* withDatabase((database) => Effect.forEach( [ - () => - database.executor - .delete(outboxMessages) - .where(eq(outboxMessages.tenantId, otherTenantId)), - () => - database.executor - .delete(domainEvents) - .where(eq(domainEvents.tenantId, otherTenantId)), - () => - database.executor - .delete(dataAccessEvents) - .where(eq(dataAccessEvents.tenantId, otherTenantId)), - () => - database.executor.delete(auditEvents).where(eq(auditEvents.tenantId, otherTenantId)), - () => - database.executor - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, otherTenantId)), - () => - database.executor - .delete(actionInvocations) - .where(eq(actionInvocations.tenantId, otherTenantId)), - () => - database.executor.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), + () => database.executor.delete(outboxMessages).where(eq(outboxMessages.tenantId, otherTenantId)), + () => database.executor.delete(domainEvents).where(eq(domainEvents.tenantId, otherTenantId)), + () => database.executor.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, otherTenantId)), + () => database.executor.delete(auditEvents).where(eq(auditEvents.tenantId, otherTenantId)), + () => database.executor.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, otherTenantId)), + () => database.executor.delete(actionInvocations).where(eq(actionInvocations.tenantId, otherTenantId)), + () => database.executor.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), () => database.executor.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), - () => - database.executor - .delete(dataAccessEvents) - .where(eq(dataAccessEvents.tenantId, tenantId)), + () => database.executor.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), () => database.executor.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), + () => database.executor.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), + () => database.executor.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), () => - database.executor - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, tenantId)), - () => - database.executor - .delete(actionInvocations) - .where(eq(actionInvocations.tenantId, tenantId)), - () => - database.executor - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, otherTenantId)), - () => - database.executor - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, tenantId)), - () => - database.executor.delete(principals).where(eq(principals.tenantId, otherTenantId)), + database.executor.delete(principalAuthBindings).where(eq(principalAuthBindings.tenantId, otherTenantId)), + () => database.executor.delete(principalAuthBindings).where(eq(principalAuthBindings.tenantId, tenantId)), + () => database.executor.delete(principals).where(eq(principals.tenantId, otherTenantId)), () => database.executor.delete(principals).where(eq(principals.tenantId, tenantId)), - () => - database.executor.delete(legalEntities).where(eq(legalEntities.tenantId, tenantId)), + () => database.executor.delete(legalEntities).where(eq(legalEntities.tenantId, tenantId)), () => database.executor.delete(tenants).where(eq(tenants.tenantId, tenantId)), () => database.executor.delete(tenants).where(eq(tenants.tenantId, otherTenantId)), ], @@ -507,10 +467,7 @@ const withDenialPersistenceFailure = ( ): ContextServiceContract => { const transaction: ContextServiceContract['executor']['transaction'] = (operation) => database.executor.transaction((current) => { - const prefix = - stage === 'audit' - ? 'insert into "core"."audit_events"' - : 'update "core"."action_invocations"'; + const prefix = stage === 'audit' ? 'insert into "core"."audit_events"' : 'update "core"."action_invocations"'; return operation(current).pipe( Effect.provideService(TestQueryHook, (statement) => statement.startsWith(prefix) @@ -526,10 +483,7 @@ const withDenialPersistenceFailure = ( ), ); }); - const executor: ContextServiceContract['executor'] = Object.assign( - Object.create(database.executor), - { transaction }, - ); + const executor: ContextServiceContract['executor'] = Object.assign(Object.create(database.executor), { transaction }); return { executor }; }; @@ -546,11 +500,7 @@ const runFailedAction = ( runtime.runAction({ payload: undefined, principal, - registration: registration( - actionKey, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), + registration: registration(actionKey, moduleStateKey, incrementExecution.bind(undefined, executions)), transport: transport(key, moduleStateKey), }), ); @@ -569,11 +519,7 @@ const testProgram1 = () => runtime.runAction({ payload: undefined, principal, - registration: registration( - actionKey, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), + registration: registration(actionKey, moduleStateKey, incrementExecution.bind(undefined, executions)), transport: transport(kind, moduleStateKey), }), ); @@ -611,10 +557,7 @@ const testProgram2 = () => .select() .from(auditEvents) .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)), - database.executor - .select() - .from(tenantModuleStates) - .where(eq(tenantModuleStates.moduleKey, moduleStateKey)), + database.executor.select().from(tenantModuleStates).where(eq(tenantModuleStates.moduleKey, moduleStateKey)), database.executor .select() .from(dataAccessEvents) @@ -623,10 +566,7 @@ const testProgram2 = () => .select() .from(domainEvents) .where(eq(domainEvents.actionInvocationId, invocation.actionInvocationId)), - database.executor - .select() - .from(outboxMessages) - .where(eq(outboxMessages.tenantId, tenantId)), + database.executor.select().from(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), ]); expect(Predicate.isTagged(failure, 'ActionPermissionDenied')).toBe(true); @@ -672,11 +612,7 @@ const testProgram3 = () => runtime.runAction({ payload: undefined, principal: deniedPrincipal, - registration: registration( - actionKey, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), + registration: registration(actionKey, moduleStateKey, incrementExecution.bind(undefined, executions)), transport: transport(kind, moduleStateKey), }), ), @@ -705,10 +641,9 @@ const testProgram4 = () => ), transport: transport(key, moduleStateKey), }; - const results = yield* Effect.all( - [1, 2].map(() => - runWithLivePermission(database, (runtime) => Effect.flip(runtime.runAction(input))), - ), + const results = yield* Effect.forEach( + [1, 2], + () => runWithLivePermission(database, (runtime) => Effect.flip(runtime.runAction(input))), { concurrency: 'unbounded' }, ); const [invocation] = yield* database.executor @@ -756,21 +691,15 @@ const testProgram5 = () => }), ), ); - const failure = yield* runWithLivePermission( - withDenialPersistenceFailure(database, stage), - (runtime) => - Effect.flip( - runtime.runAction({ - payload: undefined, - principal, - registration: registration( - actionKey, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), - transport: transport(key, moduleStateKey), - }), - ), + const failure = yield* runWithLivePermission(withDenialPersistenceFailure(database, stage), (runtime) => + Effect.flip( + runtime.runAction({ + payload: undefined, + principal, + registration: registration(actionKey, moduleStateKey, incrementExecution.bind(undefined, executions)), + transport: transport(key, moduleStateKey), + }), + ), ); const [invocation] = yield* database.executor .select() @@ -803,9 +732,11 @@ const testProgram6 = () => const moduleStateKey = `${actionPrefix}.state.invalid-credentials`; const failure = yield* runWithLivePermission( database, - (runtime) => - runFailedAction(runtime, actionKeys.unavailable, key, moduleStateKey, executions), - { ...(yield* loadSpiceDbConfig()), preSharedKey: 'invalid-integration-key' }, + (runtime) => runFailedAction(runtime, actionKeys.unavailable, key, moduleStateKey, executions), + { + ...(yield* loadSpiceDbConfig()), + preSharedKey: 'invalid-integration-key', + }, ); const [invocation] = yield* database.executor .select() @@ -819,10 +750,7 @@ const testProgram6 = () => .select() .from(auditEvents) .where( - and( - eq(auditEvents.actionInvocationId, invocation.actionInvocationId), - eq(auditEvents.outcomeStage, 'authz'), - ), + and(eq(auditEvents.actionInvocationId, invocation.actionInvocationId), eq(auditEvents.outcomeStage, 'authz')), ); expect(Predicate.isTagged(failure, 'ActionPermissionCheckError')).toBe(true); @@ -837,28 +765,13 @@ const testProgram6 = () => it.layer(PermissionFixture, { excludeTestServices: true })('Action permissions', (suite) => { suite.effect('allows direct Principal and Tenant-membership executor grants', testProgram1); - suite.effect( - 'persists one normalized terminal denial and no business or collected evidence', - testProgram2, - ); + suite.effect('persists one normalized terminal denial and no business or collected evidence', testProgram2); - suite.effect( - 'denies a legacy marker without an executor and membership-set outsiders', - testProgram3, - ); + suite.effect('denies a legacy marker without an executor and membership-set outsiders', testProgram3); - suite.effect( - 'serializes concurrent denials into one Audit Event without executing the handler', - testProgram4, - ); + suite.effect('serializes concurrent denials into one Audit Event without executing the handler', testProgram4); - suite.effect( - 'rolls back both denial evidence writes when either persistence step fails', - testProgram5, - ); + suite.effect('rolls back both denial evidence writes when either persistence step fails', testProgram5); - suite.effect( - 'fails closed for invalid SpiceDB credentials and leaves retryable received evidence', - testProgram6, - ); + suite.effect('fails closed for invalid SpiceDB credentials and leaves retryable received evidence', testProgram6); }); diff --git a/app/packages/core-runtime/tests/integration/action-runtime.test.ts b/app/packages/core-runtime/tests/integration/action-runtime.test.ts index f0d03a591..c3201a93e 100644 --- a/app/packages/core-runtime/tests/integration/action-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/action-runtime.test.ts @@ -1,22 +1,18 @@ -import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; -import { expect, it } from 'effect-rstest'; -import { ConnectionError, SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; +import { randomUUID } from 'node:crypto'; + import { and, eq } from 'drizzle-orm'; import { Cause, Deferred, Effect, Layer, Exit, Fiber, Option, Schema, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; +import { ConnectionError, SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; + import type { ActionHandlerContext } from '../../src/actions/context.ts'; import { defineAction } from '../../src/actions/definition.ts'; import { ActionInvocationPersistenceError } from '../../src/actions/errors.ts'; import { createDomainEventReference } from '../../src/actions/events.ts'; import type { ActionPolicy } from '../../src/actions/policy.ts'; -import { - defineGlobalPolicy, - defineMicroverticalPolicy, - denyPolicy, -} from '../../src/actions/policy.ts'; +import { defineGlobalPolicy, defineMicroverticalPolicy, denyPolicy } from '../../src/actions/policy.ts'; import { makeActionRepository } from '../../src/actions/repository.ts'; import { makeActionRuntime } from '../../src/actions/runtime.ts'; -import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { actionInvocations, @@ -37,28 +33,24 @@ import type { InstalledModuleCatalog } from '../../src/modules/catalog.ts'; import { InstalledModuleCatalogService } from '../../src/modules/catalog.ts'; import type { OntosModuleDeploymentContract } from '../../src/modules/manifest.ts'; import { makeModuleEntrypointGateway } from '../../src/modules/module-entrypoint-gateway.ts'; -import { - defineSystemModuleEntrypoint, - defineTenantModuleEntrypoint, -} from '../../src/modules/module-entrypoint.ts'; +import { defineSystemModuleEntrypoint, defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { makeModuleStateGate } from '../../src/modules/module-state-gate.ts'; import { TenantModuleStateService, makeTenantModuleStateService, } from '../../src/modules/tenant-module-state-service.ts'; +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { testOperationalScopeResolver } from '../fixtures/operational-scope.ts'; import { openActionRuntimeOptions } from '../support/action-runtime-options.ts'; +import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; const TestPersistenceErrorContract = Schema.TaggedStruct('TestPersistenceError', { reason: Schema.String, }); type TestPersistenceErrorSelf = typeof TestPersistenceErrorContract.Type; -const TestPersistenceError = Schema.TaggedError()( - 'TestPersistenceError', - { - reason: Schema.String, - }, -); +const TestPersistenceError = Schema.TaggedError()('TestPersistenceError', { + reason: Schema.String, +}); const TestDomainRejectedContract = Schema.TaggedStruct('TestDomainRejected', { reason: Schema.String, @@ -103,27 +95,21 @@ const inventoryStockContract: OntosModuleDeploymentContract = makeModuleContract description: 'Inventory integration fixture', displayName: 'Inventory', moduleId: 'inventory.stock', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }); const inventoryInstalledCatalog: InstalledModuleCatalog = Object.freeze({ contracts: Object.freeze([inventoryStockContract]), deploymentAppIds: Object.freeze(['inventory-stock']), deploymentStatuses: Object.freeze([ - { appId: 'inventory-stock', moduleId: 'inventory.stock', status: 'available' as const }, + { + appId: 'inventory-stock', + moduleId: 'inventory.stock', + status: 'available' as const, + }, ]), - getByDeploymentAppId: (appId: string) => - appId === 'inventory-stock' ? inventoryStockContract : undefined, - getByModuleId: (moduleId: string) => - moduleId === 'inventory.stock' ? inventoryStockContract : undefined, + getByDeploymentAppId: (appId: string) => (appId === 'inventory-stock' ? inventoryStockContract : undefined), + getByModuleId: (moduleId: string) => (moduleId === 'inventory.stock' ? inventoryStockContract : undefined), moduleIds: Object.freeze(['inventory.stock']), outboxSubscriptions: Object.freeze([]), }); @@ -326,7 +312,10 @@ const makeRegistration = ({ domainEvents: TestDomainEvents, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: actionKey, moduleKey: 'core.shell', role: 'action', @@ -336,7 +325,10 @@ const makeRegistration = ({ owningModuleKey: 'core.shell', payloadSchema: Schema.Struct({ value: Schema.String }), policies, - resultSchema: Schema.Struct({ stateId: TestStateIdSchema, value: Schema.String }), + resultSchema: Schema.Struct({ + stateId: TestStateIdSchema, + value: Schema.String, + }), schemaVersion: '1', }, Effect.fn(function* integrationHandler(payload, context: TestActionContext) { @@ -354,9 +346,7 @@ const makeRegistration = ({ .returning({ tenantModuleStateId: tenantModuleStates.tenantModuleStateId, }) - .pipe( - Effect.mapError(() => new TestPersistenceError({ reason: 'test business write failed' })), - ); + .pipe(Effect.mapError(() => new TestPersistenceError({ reason: 'test business write failed' }))); yield* context.recordDataAccess({ accessKind: 'read', @@ -394,12 +384,16 @@ const makeRegistration = ({ yield* Deferred.await(completionGate); } if (mode === 'reject') { - return yield* new TestDomainRejected({ reason: 'test domain rejection' }); + return yield* new TestDomainRejected({ + reason: 'test domain rejection', + }); } const [row] = inserted; if (row === undefined) { - return yield* new TestPersistenceError({ reason: 'test write returned no row' }); + return yield* new TestPersistenceError({ + reason: 'test write returned no row', + }); } return { stateId: TestStateIdSchema.make(row.tenantModuleStateId), @@ -574,45 +568,22 @@ const testProgram2 = Effect.fn(function* integrationProgram4() { }); const [states, invocations, audits, accesses, events, messages] = yield* Effect.all([ - database.executor - .select() - .from(tenantModuleStates) - .where(eq(tenantModuleStates.moduleKey, moduleStateKey)), - database.executor - .select() - .from(actionInvocations) - .where(eq(actionInvocations.idempotencyKey, key)), + database.executor.select().from(tenantModuleStates).where(eq(tenantModuleStates.moduleKey, moduleStateKey)), + database.executor.select().from(actionInvocations).where(eq(actionInvocations.idempotencyKey, key)), database.executor.select().from(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - database.executor - .select() - .from(dataAccessEvents) - .where(eq(dataAccessEvents.tenantId, tenantId)), - database.executor - .select() - .from(domainEvents) - .where(eq(domainEvents.subjectResourceId, moduleStateKey)), - database.executor - .select() - .from(outboxMessages) - .where(eq(outboxMessages.tenantId, tenantId)), + database.executor.select().from(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), + database.executor.select().from(domainEvents).where(eq(domainEvents.subjectResourceId, moduleStateKey)), + database.executor.select().from(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), ]); expect(result.value).toBe('committed'); expect(states.length).toBe(1); expect(invocations[0]?.status).toBe('succeeded'); expect(invocations[0]?.completedAt).toBeTruthy(); - expect( - audits.filter((row) => row.actionInvocationId === invocations[0]?.actionInvocationId) - .length, - ).toBe(1); - expect( - accesses.filter((row) => row.actionInvocationId === invocations[0]?.actionInvocationId) - .length, - ).toBe(1); + expect(audits.filter((row) => row.actionInvocationId === invocations[0]?.actionInvocationId).length).toBe(1); + expect(accesses.filter((row) => row.actionInvocationId === invocations[0]?.actionInvocationId).length).toBe(1); expect(events.length).toBe(1); - expect(messages.filter((row) => row.domainEventId === events[0]?.domainEventId).length).toBe( - 1, - ); + expect(messages.filter((row) => row.domainEventId === events[0]?.domainEventId).length).toBe(1); expect((events[0]?.tenantSequenceNo ?? 0) > 0).toBe(true); }), ); @@ -675,10 +646,7 @@ const testProgram4 = Effect.fn(function* integrationProgram8() { key: 'policy-denied-global', makeRegistration(handler: () => void) { const policy = defineGlobalPolicy<{ readonly value: string }>({ - evaluate: () => - Effect.fail( - denyPolicy('tenant_suspended', 'This tenant is suspended — contact support'), - ), + evaluate: () => Effect.fail(denyPolicy('tenant_suspended', 'This tenant is suspended — contact support')), policyKey: 'global.tenant-active.v1', }); return makeRegistration({ @@ -696,8 +664,7 @@ const testProgram4 = Effect.fn(function* integrationProgram8() { key: 'policy-denied-local', makeRegistration(handler: () => void) { const policy = defineMicroverticalPolicy<{ readonly value: string }, 'inventory.stock'>({ - evaluate: () => - Effect.fail(denyPolicy('stock_locked', 'Stock is locked for reconciliation')), + evaluate: () => Effect.fail(denyPolicy('stock_locked', 'Stock is locked for reconciliation')), owningModuleKey: 'inventory.stock', policyKey: 'inventory.stock.unlocked.v1', }); @@ -918,9 +885,7 @@ const testProgram6 = Effect.fn(function* integrationProgram14() { Effect.fn(function* integrationProgram15(scenario) { const moduleStateKey = `test.${scenario.key}.${tenantId}`; const runtime = makeActionRuntime( - scenario.key === 'evidence-failure' - ? withEvidencePersistenceFailure(database, 'audit') - : database, + scenario.key === 'evidence-failure' ? withEvidencePersistenceFailure(database, 'audit') : database, makeActionRepository(), allowedPermission, testOperationalScopeResolver, @@ -1037,22 +1002,13 @@ const testProgram7 = Effect.fn(function* integrationProgram16() { expect(invocation).not.toBe(undefined); const invocationId = invocation?.actionInvocationId ?? ''; const [audits, accesses, events, afterOutbox] = yield* Effect.all([ - database.executor - .select() - .from(auditEvents) - .where(eq(auditEvents.actionInvocationId, invocationId)), + database.executor.select().from(auditEvents).where(eq(auditEvents.actionInvocationId, invocationId)), database.executor .select() .from(dataAccessEvents) .where(eq(dataAccessEvents.actionInvocationId, invocationId)), - database.executor - .select() - .from(domainEvents) - .where(eq(domainEvents.actionInvocationId, invocationId)), - database.executor - .select() - .from(outboxMessages) - .where(eq(outboxMessages.tenantId, tenantId)), + database.executor.select().from(domainEvents).where(eq(domainEvents.actionInvocationId, invocationId)), + database.executor.select().from(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), ]); expect(hasFailure(exit, 'ActionTransactionError'), stage).toBe(true); @@ -1142,10 +1098,7 @@ const testProgram8 = () => transport: transport(concurrentKey), }; const concurrent = yield* Effect.all( - [ - Effect.exit(runtime.runAction(concurrentInput)), - Effect.exit(runtime.runAction(concurrentInput)), - ], + [Effect.exit(runtime.runAction(concurrentInput)), Effect.exit(runtime.runAction(concurrentInput))], { concurrency: 'unbounded' }, ); const [concurrentInvocation] = yield* database.executor @@ -1219,17 +1172,14 @@ const testProgram9 = () => transport: transport(key, moduleStateKey), }; - const success = yield* Effect.forkScoped( - allowedRuntime.runAction({ ...sharedInput, registration: allowed }), - ); + const success = yield* Effect.forkScoped(allowedRuntime.runAction({ ...sharedInput, registration: allowed })); yield* Deferred.await(handlerStarted); const rejected = yield* Effect.forkScoped( Effect.exit(deniedRuntime.runAction({ ...sharedInput, registration: denied })), ); - const [successResult, rejectedExit] = yield* Effect.all( - [Fiber.join(success), Fiber.join(rejected)], - { concurrency: 'unbounded' }, - ); + const [successResult, rejectedExit] = yield* Effect.all([Fiber.join(success), Fiber.join(rejected)], { + concurrency: 'unbounded', + }); const { audits, invocation } = yield* invocationEvidence(database, key); expect(successResult.value).toBe('same'); @@ -1279,19 +1229,12 @@ const testProgram10 = () => }), transport: transport(concurrentKey, concurrentModule), }; - const firstAttempt = yield* Effect.exit(runtime.runAction(concurrentInput)).pipe( - Effect.forkChild, - ); + const firstAttempt = yield* Effect.exit(runtime.runAction(concurrentInput)).pipe(Effect.forkChild); yield* Deferred.await(handlerStarted); - const secondAttempt = yield* Effect.exit(runtime.runAction(concurrentInput)).pipe( - Effect.forkChild, - ); + const secondAttempt = yield* Effect.exit(runtime.runAction(concurrentInput)).pipe(Effect.forkChild); yield* Deferred.await(secondPermissionChecked); yield* Deferred.succeed(handlerRelease, null); - const concurrentResults = yield* Effect.all([ - Fiber.join(firstAttempt), - Fiber.join(secondAttempt), - ]); + const concurrentResults = yield* Effect.all([Fiber.join(firstAttempt), Fiber.join(secondAttempt)]); expect(executions).toBe(1); expect(concurrentResults.filter(Exit.isSuccess).length).toBe(1); @@ -1416,9 +1359,7 @@ const testProgram11 = () => transport: transport('sequence-second', secondModule), }) .pipe( - Effect.provideService(TestQueryHook, () => - Deferred.succeed(secondInsertStarted, null).pipe(Effect.asVoid), - ), + Effect.provideService(TestQueryHook, () => Deferred.succeed(secondInsertStarted, null).pipe(Effect.asVoid)), Effect.ensuring( Effect.sync(() => { secondCompleted = true; @@ -1431,12 +1372,11 @@ const testProgram11 = () => expect(secondCompleted).toBe(false); yield* Deferred.succeed(firstCommitRelease, null); - yield* Effect.all([first, second].map(Fiber.join), { concurrency: 'unbounded' }); + yield* Effect.forEach([first, second], Fiber.join, { + concurrency: 'unbounded', + }); - const events = yield* database.executor - .select() - .from(domainEvents) - .where(eq(domainEvents.tenantId, tenantId)); + const events = yield* database.executor.select().from(domainEvents).where(eq(domainEvents.tenantId, tenantId)); const firstEvent = events.find((event) => event.subjectResourceId === firstModule); const secondEvent = events.find((event) => event.subjectResourceId === secondModule); @@ -1468,9 +1408,7 @@ const testProgram12 = () => }); const uncertainTransaction = { transaction: (transactionBody) => - database.executor - .transaction(transactionBody) - .pipe(Effect.andThen(Effect.die(acknowledgementLost))), + database.executor.transaction(transactionBody).pipe(Effect.andThen(Effect.die(acknowledgementLost))), } satisfies Pick; const uncertainRuntime = makeActionRuntime( @@ -1678,26 +1616,14 @@ const testProgram13 = () => }); expect(handlerExecutions).toBe(1); - const deniedStates = [ - 'inactive', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ] as const; + const deniedStates = ['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const; yield* Effect.forEach( deniedStates, Effect.fn(function* integrationProgram23(state, index) { yield* database.executor .update(tenantModuleStates) .set({ state }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, moduleKey), - ), - ); + .where(and(eq(tenantModuleStates.tenantId, tenantId), eq(tenantModuleStates.moduleKey, moduleKey))); const idempotencyKey = `module-state-denied-${index}`; const exit = yield* Effect.exit( runtime.runAction({ @@ -1719,12 +1645,7 @@ const testProgram13 = () => yield* database.executor .delete(tenantModuleStates) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, moduleKey), - ), - ); + .where(and(eq(tenantModuleStates.tenantId, tenantId), eq(tenantModuleStates.moduleKey, moduleKey))); const missingExit = yield* Effect.exit( runtime.runAction({ payload: undefined, @@ -1741,20 +1662,11 @@ const testProgram13 = () => it.layer(Layer.effectDiscard(Effect.acquireRelease(prepare, () => cleanup.pipe(Effect.orDie))), { excludeTestServices: true, })('Action runtime', (suite) => { - suite.effect( - 'rechecks business module state under the tenant lock and retries after Core recovery', - testProgram1, - ); + suite.effect('rechecks business module state under the tenant lock and retries after Core recovery', testProgram1); - suite.effect( - 'atomically commits business state, all success evidence, and the succeeded marker', - testProgram2, - ); + suite.effect('atomically commits business state, all success evidence, and the succeeded marker', testProgram2); - suite.effect( - 'commits allowed Policy checkpoints atomically before handler success evidence', - testProgram3, - ); + suite.effect('commits allowed Policy checkpoints atomically before handler success evidence', testProgram3); suite.effect( 'atomically rejects denied global and same-owner MicroVertical Policies without handler evidence', @@ -1763,22 +1675,13 @@ it.layer(Layer.effectDiscard(Effect.acquireRelease(prepare, () => cleanup.pipe(E suite.effect('rolls back every denied-Policy finalization persistence failure', testProgram5); - suite.effect( - 'rolls back domain rejection, evidence persistence failure, and orphan outbox attempts', - testProgram6, - ); + suite.effect('rolls back domain rejection, evidence persistence failure, and orphan outbox attempts', testProgram6); suite.effect('rolls back every individual success-evidence persistence failure', testProgram7); - suite.effect( - 'keeps Policy rejection terminal and deduplicates repeated and concurrent evidence', - testProgram8, - ); + suite.effect('keeps Policy rejection terminal and deduplicates repeated and concurrent evidence', testProgram8); - suite.effect( - 'never lets a losing Policy denial replace a running or successful invocation', - testProgram9, - ); + suite.effect('never lets a losing Policy denial replace a running or successful invocation', testProgram9); suite.effect( 'serializes concurrent requests and enforces committed, open-retry, and hash-conflict behavior', @@ -1787,13 +1690,7 @@ it.layer(Layer.effectDiscard(Effect.acquireRelease(prepare, () => cleanup.pipe(E suite.effect('serializes Domain Event allocation by tenant commit order', testProgram11); - suite.effect( - 'resolves a lost commit acknowledgement from the durable succeeded marker', - testProgram12, - ); + suite.effect('resolves a lost commit acknowledgement from the durable succeeded marker', testProgram12); - suite.effect( - 'persists no invocation or evidence for every non-writable business module state', - testProgram13, - ); + suite.effect('persists no invocation or evidence for every non-writable business module state', testProgram13); }); diff --git a/app/packages/core-runtime/tests/integration/contacts-identity-migration.test.ts b/app/packages/core-runtime/tests/integration/contacts-identity-migration.test.ts index 96a39b8f7..2a1063c56 100644 --- a/app/packages/core-runtime/tests/integration/contacts-identity-migration.test.ts +++ b/app/packages/core-runtime/tests/integration/contacts-identity-migration.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { NodeServices } from '@effect/platform-node'; import { Crypto, Effect, FileSystem, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { Pool } from 'pg'; + import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; const legacyModule = 'crm.core'; @@ -30,19 +31,9 @@ interface MigrationFixtureRow { const tableColumns = { action_invocations: ['action_key', 'target_module_key', 'target_resource_type'], audit_events: ['target_module_key', 'target_resource_type'], - data_access_events: [ - 'serving_module_key', - 'target_module_key', - 'target_resource_type', - 'evidence_policy_key', - ], + data_access_events: ['serving_module_key', 'target_module_key', 'target_resource_type', 'evidence_policy_key'], domain_events: ['producer_module_key', 'subject_module_key', 'subject_resource_type'], - evidence_references: [ - 'subject_module_key', - 'subject_resource_type', - 'evidence_policy_key', - 'retention_policy_key', - ], + evidence_references: ['subject_module_key', 'subject_resource_type', 'evidence_policy_key', 'retention_policy_key'], media_links: ['target_module_key', 'target_resource_type'], outbox_deliveries: ['consumer_module_key'], outbox_messages: ['producer_module_key'], @@ -58,12 +49,7 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const columnDefinitions = (columns: readonly MigrationColumn[]): string => columns.map((column) => `"${column}" text`).join(', '); -const loadTableResult = ( - pool: Pool, - quotedSchema: string, - table: string, - columns: readonly MigrationColumn[], -) => +const loadTableResult = (pool: Pool, quotedSchema: string, table: string, columns: readonly MigrationColumn[]) => Effect.tryPromise(() => pool.query(`select * from ${quotedSchema}."${table}" order by record_id`), ).pipe(Effect.map((result) => ({ columns, result, table }))); @@ -71,15 +57,20 @@ const loadTableResult = ( const runSequentially = ( values: readonly Value[], operation: (value: Value) => Effect.Effect, -): Effect.Effect => - Effect.forEach(values, operation, { concurrency: 1, discard: true }); +): Effect.Effect => Effect.forEach(values, operation, { concurrency: 1, discard: true }); const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMigration() { const configuration = yield* loadDatabaseConnectionPair(); const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const pool = yield* Effect.acquireRelease( - Effect.sync(() => new Pool({ connectionString: configuration.admin.connectionString, max: 1 })), + Effect.sync( + () => + new Pool({ + connectionString: configuration.admin.connectionString, + max: 1, + }), + ), (resource) => Effect.tryPromise(() => resource.end()).pipe(Effect.orDie), ); const schema = `core_contacts_identity_${(yield* crypto.randomUUIDv4).replaceAll('-', '')}`; @@ -110,7 +101,9 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi ), ); const recordedAt = '2026-01-02T03:04:05.678Z'; - const payload = { freeText: 'crm.core must remain untouched inside arbitrary JSON' }; + const payload = { + freeText: 'crm.core must remain untouched inside arbitrary JSON', + }; const encodedPayload = encodeJson(payload); yield* Effect.tryPromise(() => pool.query( @@ -128,16 +121,10 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi ...columns.map((_, index) => (index % 2 === 0 ? legacyModule : `${legacyModule}.record`)), encodedPayload, ]; - const unrelatedValues = [ - `${table}-unrelated`, - ...columns.map(() => 'commerce.core.record'), - encodedPayload, - ]; + const unrelatedValues = [`${table}-unrelated`, ...columns.map(() => 'commerce.core.record'), encodedPayload]; const placeholders = names.map((_, index) => `$${index + 1}`).join(', '); const quotedNames = names.map((name) => `"${name}"`).join(', '); - const unrelatedPlaceholders = names - .map((_, index) => `$${index + names.length + 1}`) - .join(', '); + const unrelatedPlaceholders = names.map((_, index) => `$${index + names.length + 1}`).join(', '); return Effect.tryPromise(() => pool.query( `insert into ${quotedSchema}."${table}" (${quotedNames}) @@ -148,18 +135,12 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi }); const migrationSource = yield* fileSystem.readFileString( - new URL( - '../../drizzle/20260901102632_rename-crm-module-identity/migration.sql', - import.meta.url, - ).pathname, + new URL('../../drizzle/20260901102632_rename-crm-module-identity/migration.sql', import.meta.url).pathname, ); const migrationTables = ['tenant_module_states', ...Object.keys(tableColumns)]; let isolatedMigrationSource = migrationSource; for (const table of migrationTables) { - isolatedMigrationSource = isolatedMigrationSource.replaceAll( - `core.${table}`, - `${quotedSchema}."${table}"`, - ); + isolatedMigrationSource = isolatedMigrationSource.replaceAll(`core.${table}`, `${quotedSchema}."${table}"`); } const statements = isolatedMigrationSource .split('--> statement-breakpoint') @@ -181,7 +162,10 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi ), ); expect( - stateResult.rows.map(({ module_key, record_id }) => ({ module_key, record_id })), + stateResult.rows.map(({ module_key, record_id }) => ({ + module_key, + record_id, + })), ).toEqual([ { module_key: contactsModule, record_id: 'legacy-state' }, { module_key: 'commerce.core', record_id: 'unrelated-state' }, @@ -204,9 +188,7 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi throw new Error('Expected unrelated'); } for (const column of columns) { - expect(String(migrated[column]), `${table}.${column} was not migrated`).toMatch( - /^contacts\.core(?:\.|$)/u, - ); + expect(String(migrated[column]), `${table}.${column} was not migrated`).toMatch(/^contacts\.core(?:\.|$)/u); expect(unrelated[column]).toBe('commerce.core.record'); } expect(migrated.payload).toEqual(payload); @@ -222,9 +204,7 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi [legacyModule, contactsModule], ), ); - const collisionError = yield* Effect.flip( - Effect.tryPromise(() => pool.query(statements[0] ?? '')), - ); + const collisionError = yield* Effect.flip(Effect.tryPromise(() => pool.query(statements[0] ?? ''))); expect(String(collisionError.cause)).toMatch(/would collide/u); const collisionRows = yield* Effect.tryPromise(() => pool.query<{ module_key: string }>( @@ -234,19 +214,14 @@ const contactsIdentityMigrationProgram = Effect.gen(function* contactsIdentityMi expect(collisionRows.rows.map((row) => row.module_key)).toEqual([contactsModule, legacyModule]); }).pipe( Effect.ensuring( - Effect.tryPromise(() => pool.query(`drop schema if exists ${quotedSchema} cascade`)).pipe( - Effect.orDie, - ), + Effect.tryPromise(() => pool.query(`drop schema if exists ${quotedSchema} cascade`)).pipe(Effect.orDie), ), ); }).pipe(Effect.scoped); -it.layer(NodeServices.layer, { excludeTestServices: true })( - 'contacts-identity-migration', - (suite) => { - suite.effect( - 'Contacts Core identity migration is preserving, scoped, rerunnable, and collision-safe', - () => contactsIdentityMigrationProgram, - ); - }, -); +it.layer(NodeServices.layer, { excludeTestServices: true })('contacts-identity-migration', (suite) => { + suite.effect( + 'Contacts Core identity migration is preserving, scoped, rerunnable, and collision-safe', + () => contactsIdentityMigrationProgram, + ); +}); diff --git a/app/packages/core-runtime/tests/integration/context-access.test.ts b/app/packages/core-runtime/tests/integration/context-access.test.ts index 615a04565..a0c495f1a 100644 --- a/app/packages/core-runtime/tests/integration/context-access.test.ts +++ b/app/packages/core-runtime/tests/integration/context-access.test.ts @@ -1,18 +1,16 @@ -import { expect, it } from 'effect-rstest'; -import { NodeServices } from '@effect/platform-node'; import { v1 } from '@authzed/authzed-node'; +import { NodeServices } from '@effect/platform-node'; import { Crypto, Effect, FileSystem } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { SPICEDB_CHECK_TIMEOUT_MS, createSpiceDbPermissionClient } from '../../src/permissions/client.ts'; +import { loadSpiceDbConfig } from '../../src/permissions/config.ts'; import { makeContextAccess, toLegalEntityAccessObjectId, toModuleAccessObjectId, toResourceAccessObjectId, } from '../../src/permissions/context-access.ts'; -import { - SPICEDB_CHECK_TIMEOUT_MS, - createSpiceDbPermissionClient, -} from '../../src/permissions/client.ts'; -import { loadSpiceDbConfig } from '../../src/permissions/config.ts'; const spiceDbEffect = (operation: PromiseLike) => Effect.tryPromise(() => operation); @@ -25,9 +23,15 @@ const relationship = ( ) => v1.Relationship.create({ relation, - resource: v1.ObjectReference.create({ objectId: resourceId, objectType: resourceType }), + resource: v1.ObjectReference.create({ + objectId: resourceId, + objectType: resourceType, + }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: subjectId, objectType: subjectType }), + object: v1.ObjectReference.create({ + objectId: subjectId, + objectType: subjectType, + }), }), }); @@ -35,40 +39,31 @@ const contextAccessProgram = Effect.gen(function* contextAccessIntegration() { const configuration = yield* loadSpiceDbConfig(); const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; - const [tenantId, otherTenantId, legalEntityId, otherLegalEntityId, principalId, resourceId] = - yield* Effect.all( - [ - crypto.randomUUIDv4, - crypto.randomUUIDv4, - crypto.randomUUIDv4, - crypto.randomUUIDv4, - crypto.randomUUIDv4, - crypto.randomUUIDv4, - ], - { concurrency: 'unbounded' }, - ); + const [tenantId, otherTenantId, legalEntityId, otherLegalEntityId, principalId, resourceId] = yield* Effect.all( + [ + crypto.randomUUIDv4, + crypto.randomUUIDv4, + crypto.randomUUIDv4, + crypto.randomUUIDv4, + crypto.randomUUIDv4, + crypto.randomUUIDv4, + ], + { concurrency: 'unbounded' }, + ); const moduleId = 'property.registry'; const resource = { moduleId, resourceId, resourceType: 'property.unit' }; const legalObjectId = toLegalEntityAccessObjectId(tenantId, legalEntityId); const moduleObjectId = toModuleAccessObjectId(tenantId, legalEntityId, moduleId); const resourceObjectId = toResourceAccessObjectId(tenantId, legalEntityId, resource); - if ( - legalObjectId === undefined || - moduleObjectId === undefined || - resourceObjectId === undefined - ) { + if (legalObjectId === undefined || moduleObjectId === undefined || resourceObjectId === undefined) { throw new Error('Expected valid SpiceDB object identifiers'); } const client = v1.NewClient( configuration.preSharedKey, configuration.endpoint, - configuration.insecureLocal - ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED - : v1.ClientSecurity.SECURE, - ); - const bootstrap = yield* fileSystem.readFileString( - new URL('../../spicedb/bootstrap.yaml', import.meta.url).pathname, + configuration.insecureLocal ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED : v1.ClientSecurity.SECURE, ); + const bootstrap = yield* fileSystem.readFileString(new URL('../../spicedb/bootstrap.yaml', import.meta.url).pathname); const bootstrapLines = bootstrap.split('\n'); const schemaStart = bootstrapLines.indexOf('schema: |-') + 1; const schemaEnd = bootstrapLines.indexOf('relationships: |-'); @@ -161,7 +156,12 @@ const contextAccessProgram = Effect.gen(function* contextAccessIntegration() { ]); } expect( - yield* access.modules({ legalEntityId, moduleIds: [moduleId], principalId, tenantId }), + yield* access.modules({ + legalEntityId, + moduleIds: [moduleId], + principalId, + tenantId, + }), ).toEqual([{ decision: 'allowed', key: moduleId }]); expect( yield* access.modules({ @@ -172,8 +172,18 @@ const contextAccessProgram = Effect.gen(function* contextAccessIntegration() { }), ).toEqual([{ decision: 'denied', key: moduleId }]); expect( - yield* access.resources({ legalEntityId, principalId, resources: [resource], tenantId }), - ).toEqual([{ decision: 'allowed', key: `${moduleId}:property.unit:${resource.resourceId}` }]); + yield* access.resources({ + legalEntityId, + principalId, + resources: [resource], + tenantId, + }), + ).toEqual([ + { + decision: 'allowed', + key: `${moduleId}:property.unit:${resource.resourceId}`, + }, + ]); }).pipe(Effect.ensuring(Effect.sync(() => permissionClient.close()))); }).pipe( Effect.ensuring( diff --git a/app/packages/core-runtime/tests/integration/identity-runtime.test.ts b/app/packages/core-runtime/tests/integration/identity-runtime.test.ts index 2c118321d..2c8afa1fc 100644 --- a/app/packages/core-runtime/tests/integration/identity-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/identity-runtime.test.ts @@ -1,10 +1,11 @@ -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { v1 } from '@authzed/authzed-node'; import { and, eq, inArray } from 'drizzle-orm'; import { DateTime, Effect, Option, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; import { Pool } from 'pg'; + import { makeActionRepository } from '../../src/actions/repository.ts'; import { makeActionRuntime } from '../../src/actions/runtime.ts'; import { managedPrincipalsRead } from '../../src/auth/principal-administration-reads.ts'; @@ -13,10 +14,7 @@ import { principalManagementRepositoryFromTransaction, } from '../../src/auth/principal-management.ts'; import { makeSupportRecoveryPrincipalContextResolver } from '../../src/auth/support-recovery-principal-context.ts'; -import { - makeSystemPrincipalContextResolver, - registerSystemWorkload, -} from '../../src/auth/system-principal-context.ts'; +import { makeSystemPrincipalContextResolver, registerSystemWorkload } from '../../src/auth/system-principal-context.ts'; import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; import { actionInvocations, @@ -34,31 +32,17 @@ import { createNonHumanPrincipalAction } from '../../src/modules/actions/create- import { recordSupportImpersonationAction } from '../../src/modules/actions/record-support-impersonation.action.ts'; import { setManagedApiKeyBindingStatusAction } from '../../src/modules/actions/set-managed-api-key-binding-status.action.ts'; import { setSelfApiKeyBindingStatusAction } from '../../src/modules/actions/set-self-api-key-binding-status.action.ts'; -import { - makeOperationalScopeRepository, - makeOperationalScopeResolver, -} from '../../src/operations/context.ts'; -import { - SPICEDB_CHECK_TIMEOUT_MS, - createSpiceDbPermissionClient, -} from '../../src/permissions/client.ts'; +import { makeOperationalScopeRepository, makeOperationalScopeResolver } from '../../src/operations/context.ts'; +import { SPICEDB_CHECK_TIMEOUT_MS, createSpiceDbPermissionClient } from '../../src/permissions/client.ts'; import { loadSpiceDbConfig } from '../../src/permissions/config.ts'; import { makeContextAccess } from '../../src/permissions/context-access.ts'; -import { - makeActionPermissionService, - toSpiceDbActionObjectId, -} from '../../src/permissions/service.ts'; +import { makeActionPermissionService, toSpiceDbActionObjectId } from '../../src/permissions/service.ts'; import { makeReadRuntime } from '../../src/reads/runtime.ts'; import { openActionRuntimeOptions } from '../support/action-runtime-options.ts'; import { makeTestDatabaseFromPool } from '../support/database.ts'; import { openModuleEntrypointGateway } from '../support/open-module-entrypoint-gateway.ts'; -const withOptionalProperty = < - Base extends object, - Key extends PropertyKey, - Value, - Trailing extends object, ->( +const withOptionalProperty = ( base: Base, condition: boolean, key: Key, @@ -75,573 +59,577 @@ const relationship = ( ) => v1.Relationship.create({ relation, - resource: v1.ObjectReference.create({ objectId: resourceId, objectType: resourceType }), + resource: v1.ObjectReference.create({ + objectId: resourceId, + objectType: resourceType, + }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: subjectId, objectType: subjectType }), + object: v1.ObjectReference.create({ + objectId: subjectId, + objectType: subjectType, + }), }), }); -it.live( - 'runs identity mutations and tenant-isolated administration through live Action and Read runtimes', - () => - Effect.gen(function* identityRuntimeIntegration() { - const connections = yield* loadDatabaseConnectionPair(); - const spiceDbConfiguration = yield* loadSpiceDbConfig(); - const adminPool = yield* Effect.acquireRelease( - Effect.sync(() => new Pool({ connectionString: connections.admin.connectionString })), - (pool) => Effect.promise(() => pool.end()).pipe(Effect.orDie), - ); - const runtimePool = yield* Effect.acquireRelease( - Effect.sync(() => new Pool({ connectionString: connections.runtime.connectionString })), - (pool) => Effect.promise(() => pool.end()).pipe(Effect.orDie), - ); - const admin = yield* makeTestDatabaseFromPool(adminPool, coreRelations); - const runtimeDatabase = yield* makeTestDatabaseFromPool(runtimePool, coreRelations); - const principalManagementRepository = - principalManagementRepositoryFromTransaction(runtimeDatabase); - const runIdentityAction = ( - action: Effect.Effect, - ) => - action.pipe( - Effect.provideService(PrincipalManagementRepository, principalManagementRepository), - ); - const tenantId = randomUUID(); - const foreignTenantId = randomUUID(); - const administratorPrincipalId = randomUUID(); - const administratorAuthBindingId = randomUUID(); - const foreignPrincipalId = randomUUID(); - const supportTargetPrincipalId = randomUUID(); - const supportTargetAuthBindingId = randomUUID(); - const systemPrincipalId = randomUUID(); - const providerUserId = `identity-runtime-user-${randomUUID()}`; - const providerKeyId = `identity-runtime-key-${randomUUID()}`; - const selfProviderKeyId = `identity-runtime-self-key-${randomUUID()}`; - const supportTargetUserId = `identity-runtime-target-${randomUUID()}`; - const spiceDbClient = v1.NewClient( - spiceDbConfiguration.preSharedKey, - spiceDbConfiguration.endpoint, - spiceDbConfiguration.insecureLocal - ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED - : v1.ClientSecurity.SECURE, - ); - const permissionClient = createSpiceDbPermissionClient( - spiceDbConfiguration, - SPICEDB_CHECK_TIMEOUT_MS, - ); - const contextAccess = makeContextAccess(permissionClient); - const actionPermission = makeActionPermissionService(permissionClient); - const operationalScope = makeOperationalScopeResolver( - makeOperationalScopeRepository({ executor: runtimeDatabase }), - contextAccess, - ); - const actionRuntime = makeActionRuntime( - { executor: runtimeDatabase }, - makeActionRepository(), - actionPermission, - operationalScope, - { ...openActionRuntimeOptions, contextAccess }, +it.live('runs identity mutations and tenant-isolated administration through live Action and Read runtimes', () => + Effect.gen(function* identityRuntimeIntegration() { + const connections = yield* loadDatabaseConnectionPair(); + const spiceDbConfiguration = yield* loadSpiceDbConfig(); + const adminPool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: connections.admin.connectionString })), + (pool) => Effect.promise(() => pool.end()).pipe(Effect.orDie), + ); + const runtimePool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: connections.runtime.connectionString })), + (pool) => Effect.promise(() => pool.end()).pipe(Effect.orDie), + ); + const admin = yield* makeTestDatabaseFromPool(adminPool, coreRelations); + const runtimeDatabase = yield* makeTestDatabaseFromPool(runtimePool, coreRelations); + const principalManagementRepository = principalManagementRepositoryFromTransaction(runtimeDatabase); + const runIdentityAction = (action: Effect.Effect) => + action.pipe(Effect.provideService(PrincipalManagementRepository, principalManagementRepository)); + const tenantId = randomUUID(); + const foreignTenantId = randomUUID(); + const administratorPrincipalId = randomUUID(); + const administratorAuthBindingId = randomUUID(); + const foreignPrincipalId = randomUUID(); + const supportTargetPrincipalId = randomUUID(); + const supportTargetAuthBindingId = randomUUID(); + const systemPrincipalId = randomUUID(); + const providerUserId = `identity-runtime-user-${randomUUID()}`; + const providerKeyId = `identity-runtime-key-${randomUUID()}`; + const selfProviderKeyId = `identity-runtime-self-key-${randomUUID()}`; + const supportTargetUserId = `identity-runtime-target-${randomUUID()}`; + const spiceDbClient = v1.NewClient( + spiceDbConfiguration.preSharedKey, + spiceDbConfiguration.endpoint, + spiceDbConfiguration.insecureLocal ? v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED : v1.ClientSecurity.SECURE, + ); + const permissionClient = createSpiceDbPermissionClient(spiceDbConfiguration, SPICEDB_CHECK_TIMEOUT_MS); + const contextAccess = makeContextAccess(permissionClient); + const actionPermission = makeActionPermissionService(permissionClient); + const operationalScope = makeOperationalScopeResolver( + makeOperationalScopeRepository({ executor: runtimeDatabase }), + contextAccess, + ); + const actionRuntime = makeActionRuntime( + { executor: runtimeDatabase }, + makeActionRepository(), + actionPermission, + operationalScope, + { ...openActionRuntimeOptions, contextAccess }, + ); + const readRuntime = makeReadRuntime( + { executor: runtimeDatabase }, + openModuleEntrypointGateway, + operationalScope, + contextAccess, + ); + const principal = { + authBindingId: administratorAuthBindingId, + authContextRef: `better-auth-session:${randomUUID()}`, + authMethod: 'session' as const, + principalId: administratorPrincipalId, + tenantId, + }; + const identityActionKeys = [ + 'core.identity.bind-managed-api-key', + 'core.identity.bind-self-api-key', + 'core.identity.change-principal-status', + 'core.identity.create-non-human-principal', + 'core.identity.record-support-impersonation', + 'core.identity.set-managed-api-key-binding-status', + 'core.identity.set-self-api-key-binding-status', + ] as const; + const spiceDbRelationships = [ + relationship('tenant', tenantId, 'member', 'principal', administratorPrincipalId), + relationship('tenant', tenantId, 'identity_admin', 'principal', administratorPrincipalId), + relationship('tenant', tenantId, 'support', 'principal', administratorPrincipalId), + ...identityActionKeys.flatMap((actionKey) => { + const objectId = toSpiceDbActionObjectId(actionKey); + return [ + relationship('action', objectId, 'executor', 'principal', administratorPrincipalId), + relationship('action', objectId, 'executor', 'principal', systemPrincipalId), + ]; + }), + ]; + const cleanup = Effect.gen(function* cleanIdentityRuntimeFixtures() { + yield* admin.delete(dataAccessEvents).where(inArray(dataAccessEvents.tenantId, [tenantId])); + yield* admin.delete(auditEvents).where(inArray(auditEvents.tenantId, [tenantId])); + yield* admin.delete(actionInvocations).where(inArray(actionInvocations.tenantId, [tenantId])); + yield* admin + .delete(principalAuthBindings) + .where(inArray(principalAuthBindings.tenantId, [tenantId, foreignTenantId])); + yield* admin.delete(principals).where(inArray(principals.tenantId, [tenantId, foreignTenantId])); + yield* admin.delete(tenants).where(inArray(tenants.tenantId, [tenantId, foreignTenantId])); + }); + + const exercise = Effect.gen(function* exerciseIdentityRuntime() { + const initialRelationshipsRequest = v1.WriteRelationshipsRequest.create({ + updates: spiceDbRelationships.map((item) => + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.TOUCH, + relationship: item, + }), + ), + }); + yield* Effect.promise(() => spiceDbClient.promises.writeRelationships(initialRelationshipsRequest)); + yield* admin.insert(tenants).values([ + { + defaultLocale: 'en', + name: 'Identity runtime tenant', + slug: `identity-runtime-${tenantId}`, + status: 'active', + tenantId, + }, + { + defaultLocale: 'en', + name: 'Foreign identity runtime tenant', + slug: `identity-runtime-${foreignTenantId}`, + status: 'active', + tenantId: foreignTenantId, + }, + ]); + yield* admin.insert(principals).values([ + { + displayName: 'Identity administrator', + kind: 'human', + principalId: administratorPrincipalId, + status: 'active', + tenantId, + }, + { + displayName: 'Foreign managed service', + kind: 'service', + principalId: foreignPrincipalId, + status: 'active', + tenantId: foreignTenantId, + }, + { + displayName: 'Support target', + kind: 'human', + principalId: supportTargetPrincipalId, + status: 'active', + tenantId, + }, + { + displayName: 'Identity runtime system', + kind: 'system', + principalId: systemPrincipalId, + status: 'active', + tenantId, + }, + ]); + yield* admin.insert(principalAuthBindings).values([ + { + principalAuthBindingId: administratorAuthBindingId, + principalId: administratorPrincipalId, + provider: 'better_auth', + providerSubjectId: providerUserId, + status: 'active', + subjectType: 'user', + tenantId, + }, + { + principalAuthBindingId: supportTargetAuthBindingId, + principalId: supportTargetPrincipalId, + provider: 'better_auth', + providerSubjectId: supportTargetUserId, + status: 'active', + subjectType: 'user', + tenantId, + }, + ]); + + const created = yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + displayName: 'Managed runtime service', + kind: 'service', + }, + principal, + registration: createNonHumanPrincipalAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, + }), ); - const readRuntime = makeReadRuntime( - { executor: runtimeDatabase }, - openModuleEntrypointGateway, - operationalScope, - contextAccess, + const binding = yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + principalId: created.principalId, + providerSubjectId: providerKeyId, + }, + principal, + registration: bindManagedApiKeyAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, + }), ); - const principal = { - authBindingId: administratorAuthBindingId, - authContextRef: `better-auth-session:${randomUUID()}`, - authMethod: 'session' as const, - principalId: administratorPrincipalId, - tenantId, - }; - const identityActionKeys = [ - 'core.identity.bind-managed-api-key', - 'core.identity.bind-self-api-key', - 'core.identity.change-principal-status', - 'core.identity.create-non-human-principal', - 'core.identity.record-support-impersonation', - 'core.identity.set-managed-api-key-binding-status', - 'core.identity.set-self-api-key-binding-status', - ] as const; - const spiceDbRelationships = [ - relationship('tenant', tenantId, 'member', 'principal', administratorPrincipalId), - relationship('tenant', tenantId, 'identity_admin', 'principal', administratorPrincipalId), - relationship('tenant', tenantId, 'support', 'principal', administratorPrincipalId), - ...identityActionKeys.flatMap((actionKey) => { - const objectId = toSpiceDbActionObjectId(actionKey); - return [ - relationship('action', objectId, 'executor', 'principal', administratorPrincipalId), - relationship('action', objectId, 'executor', 'principal', systemPrincipalId), - ]; + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + authBindingId: binding.authBindingId, + expectedStatus: 'active', + newStatus: 'disabled', + principalId: created.principalId, + }, + principal, + registration: setManagedApiKeyBindingStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, }), - ]; - const cleanup = Effect.gen(function* cleanIdentityRuntimeFixtures() { - yield* admin.delete(dataAccessEvents).where(inArray(dataAccessEvents.tenantId, [tenantId])); - yield* admin.delete(auditEvents).where(inArray(auditEvents.tenantId, [tenantId])); - yield* admin - .delete(actionInvocations) - .where(inArray(actionInvocations.tenantId, [tenantId])); - yield* admin - .delete(principalAuthBindings) - .where(inArray(principalAuthBindings.tenantId, [tenantId, foreignTenantId])); - yield* admin - .delete(principals) - .where(inArray(principals.tenantId, [tenantId, foreignTenantId])); - yield* admin.delete(tenants).where(inArray(tenants.tenantId, [tenantId, foreignTenantId])); - }); - - const exercise = Effect.gen(function* exerciseIdentityRuntime() { - const initialRelationshipsRequest = v1.WriteRelationshipsRequest.create({ - updates: spiceDbRelationships.map((item) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.TOUCH, - relationship: item, - }), - ), - }); - yield* Effect.promise(() => - spiceDbClient.promises.writeRelationships(initialRelationshipsRequest), - ); - yield* admin.insert(tenants).values([ - { - defaultLocale: 'en', - name: 'Identity runtime tenant', - slug: `identity-runtime-${tenantId}`, - status: 'active', - tenantId, + ); + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + authBindingId: binding.authBindingId, + expectedStatus: 'disabled', + newStatus: 'active', + principalId: created.principalId, }, - { - defaultLocale: 'en', - name: 'Foreign identity runtime tenant', - slug: `identity-runtime-${foreignTenantId}`, - status: 'active', - tenantId: foreignTenantId, + principal, + registration: setManagedApiKeyBindingStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), }, - ]); - yield* admin.insert(principals).values([ - { - displayName: 'Identity administrator', - kind: 'human', - principalId: administratorPrincipalId, - status: 'active', - tenantId, + }), + ); + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + expectedStatus: 'active', + newStatus: 'disabled', + principalId: created.principalId, + reason: 'Exercise disabled managed-principal state', }, - { - displayName: 'Foreign managed service', - kind: 'service', - principalId: foreignPrincipalId, - status: 'active', - tenantId: foreignTenantId, + principal, + registration: changePrincipalStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), }, - { - displayName: 'Support target', - kind: 'human', - principalId: supportTargetPrincipalId, - status: 'active', - tenantId, + }), + ); + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + expectedStatus: 'disabled', + newStatus: 'active', + principalId: created.principalId, }, - { - displayName: 'Identity runtime system', - kind: 'system', - principalId: systemPrincipalId, - status: 'active', - tenantId, + principal, + registration: changePrincipalStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), }, - ]); - yield* admin.insert(principalAuthBindings).values([ - { - principalAuthBindingId: administratorAuthBindingId, - principalId: administratorPrincipalId, - provider: 'better_auth', - providerSubjectId: providerUserId, - status: 'active', - subjectType: 'user', - tenantId, + }), + ); + const selfBinding = yield* runIdentityAction( + actionRuntime.runAction({ + payload: { providerSubjectId: selfProviderKeyId }, + principal, + registration: bindSelfApiKeyAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), }, - { - principalAuthBindingId: supportTargetAuthBindingId, - principalId: supportTargetPrincipalId, - provider: 'better_auth', - providerSubjectId: supportTargetUserId, - status: 'active', - subjectType: 'user', - tenantId, + }), + ); + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + authBindingId: selfBinding.authBindingId, + expectedStatus: 'active', + newStatus: 'disabled', }, - ]); - - const created = yield* runIdentityAction( - actionRuntime.runAction({ - payload: { displayName: 'Managed runtime service', kind: 'service' }, - principal, - registration: createNonHumanPrincipalAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - const binding = yield* runIdentityAction( - actionRuntime.runAction({ - payload: { principalId: created.principalId, providerSubjectId: providerKeyId }, - principal, - registration: bindManagedApiKeyAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - authBindingId: binding.authBindingId, - expectedStatus: 'active', - newStatus: 'disabled', - principalId: created.principalId, - }, - principal, - registration: setManagedApiKeyBindingStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - authBindingId: binding.authBindingId, - expectedStatus: 'disabled', - newStatus: 'active', - principalId: created.principalId, - }, - principal, - registration: setManagedApiKeyBindingStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - expectedStatus: 'active', - newStatus: 'disabled', - principalId: created.principalId, - reason: 'Exercise disabled managed-principal state', - }, - principal, - registration: changePrincipalStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - expectedStatus: 'disabled', - newStatus: 'active', - principalId: created.principalId, - }, - principal, - registration: changePrincipalStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - const selfBinding = yield* runIdentityAction( - actionRuntime.runAction({ - payload: { providerSubjectId: selfProviderKeyId }, - principal, - registration: bindSelfApiKeyAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - authBindingId: selfBinding.authBindingId, - expectedStatus: 'active', - newStatus: 'disabled', - }, - principal, - registration: setSelfApiKeyBindingStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - yield* runIdentityAction( - actionRuntime.runAction({ - payload: { - authBindingId: selfBinding.authBindingId, - expectedStatus: 'disabled', - newStatus: 'active', - }, - principal, - registration: setSelfApiKeyBindingStatusAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - const listed = yield* readRuntime.runRead({ - input: { limit: 100, offset: 0 }, principal, - registration: managedPrincipalsRead, - transport: { correlationId: randomUUID() }, - }); - - expect(binding.status).toBe('active'); - expect( - listed.items.map(({ authBindingId, principalId: listedPrincipalId }) => ({ - authBindingId: Option.getOrThrow(authBindingId), - principalId: listedPrincipalId, - })), - ).toEqual([{ authBindingId: binding.authBindingId, principalId: created.principalId }]); - yield* readRuntime.runRead({ - input: { limit: 100, offset: 0 }, - principal: { + registration: setSelfApiKeyBindingStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, + }), + ); + yield* runIdentityAction( + actionRuntime.runAction({ + payload: { authBindingId: selfBinding.authBindingId, - authContextRef: `better-auth-api-key:${selfProviderKeyId}`, - authMethod: 'api_key', - principalId: administratorPrincipalId, - tenantId, + expectedStatus: 'disabled', + newStatus: 'active', }, - registration: managedPrincipalsRead, - transport: { correlationId: randomUUID() }, - }); - const committed = yield* admin - .select({ actionKey: actionInvocations.actionKey, status: actionInvocations.status }) - .from(actionInvocations) - .where(eq(actionInvocations.tenantId, tenantId)); - expect( - [ - ...new Set( - committed - .filter(({ status }) => status === 'succeeded') - .map(({ actionKey }) => actionKey), - ), - ].toSorted(), - ).toEqual( - identityActionKeys.filter((actionKey) => !actionKey.includes('support')).toSorted(), - ); - const [readEvidence] = yield* admin - .select({ resultCount: dataAccessEvents.resultCount }) - .from(dataAccessEvents) - .where( - and( - eq(dataAccessEvents.tenantId, tenantId), - eq(dataAccessEvents.evidencePolicyKey, 'core.identity.managed-principals.access.v1'), - ), - ); - expect(readEvidence?.resultCount).toBe(1); - const [apiKeyReadEvidence] = yield* admin - .select({ authBindingId: dataAccessEvents.authBindingId }) - .from(dataAccessEvents) - .where( - and( - eq(dataAccessEvents.tenantId, tenantId), - eq(dataAccessEvents.authMethod, 'api_key'), - ), - ); - expect(apiKeyReadEvidence?.authBindingId).toBe(selfBinding.authBindingId); + principal, + registration: setSelfApiKeyBindingStatusAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, + }), + ); + const listed = yield* readRuntime.runRead({ + input: { limit: 100, offset: 0 }, + principal, + registration: managedPrincipalsRead, + transport: { correlationId: randomUUID() }, + }); - const systemPrincipal = yield* makeSystemPrincipalContextResolver({ - executor: runtimeDatabase, - }).resolve({ - principalId: systemPrincipalId, - registration: registerSystemWorkload({ jobKey: 'identity-runtime-integration' }), - runReference: randomUUID(), - tenantId, - }); - const systemDenied = yield* runIdentityAction( - Effect.flip( - actionRuntime.runAction({ - payload: { displayName: 'Executor-only system integration', kind: 'integration' }, - principal: systemPrincipal, - registration: createNonHumanPrincipalAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ), - ); - expect(Predicate.isTagged(systemDenied, 'ActionPermissionDenied')).toBe(true); - const systemTenantMember = relationship( - 'tenant', + expect(binding.status).toBe('active'); + expect( + listed.items.map(({ authBindingId, principalId: listedPrincipalId }) => ({ + authBindingId: Option.getOrThrow(authBindingId), + principalId: listedPrincipalId, + })), + ).toEqual([ + { + authBindingId: binding.authBindingId, + principalId: created.principalId, + }, + ]); + yield* readRuntime.runRead({ + input: { limit: 100, offset: 0 }, + principal: { + authBindingId: selfBinding.authBindingId, + authContextRef: `better-auth-api-key:${selfProviderKeyId}`, + authMethod: 'api_key', + principalId: administratorPrincipalId, tenantId, - 'member', - 'principal', - systemPrincipalId, - ); - const systemIdentityAdministrator = relationship( - 'tenant', - tenantId, - 'identity_admin', - 'principal', - systemPrincipalId, - ); - spiceDbRelationships.push(systemTenantMember, systemIdentityAdministrator); - const systemRelationshipsRequest = v1.WriteRelationshipsRequest.create({ - updates: [systemTenantMember, systemIdentityAdministrator].map((item) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.TOUCH, - relationship: item, - }), + }, + registration: managedPrincipalsRead, + transport: { correlationId: randomUUID() }, + }); + const committed = yield* admin + .select({ + actionKey: actionInvocations.actionKey, + status: actionInvocations.status, + }) + .from(actionInvocations) + .where(eq(actionInvocations.tenantId, tenantId)); + expect( + [ + ...new Set(committed.filter(({ status }) => status === 'succeeded').map(({ actionKey }) => actionKey)), + ].toSorted(), + ).toEqual(identityActionKeys.filter((actionKey) => !actionKey.includes('support')).toSorted()); + const [readEvidence] = yield* admin + .select({ resultCount: dataAccessEvents.resultCount }) + .from(dataAccessEvents) + .where( + and( + eq(dataAccessEvents.tenantId, tenantId), + eq(dataAccessEvents.evidencePolicyKey, 'core.identity.managed-principals.access.v1'), ), - }); - yield* Effect.promise(() => - spiceDbClient.promises.writeRelationships(systemRelationshipsRequest), ); - const systemCreated = yield* runIdentityAction( - actionRuntime.runAction({ - payload: { displayName: 'System-created integration', kind: 'integration' }, - principal: systemPrincipal, - registration: createNonHumanPrincipalAction, - transport: { correlationId: randomUUID(), idempotencyKey: randomUUID() }, - }), - ); - expect(systemCreated.status).toBe('active'); - const systemRead = yield* readRuntime.runRead({ - input: { limit: 100, offset: 0 }, - principal: systemPrincipal, - registration: managedPrincipalsRead, - transport: { correlationId: randomUUID() }, - }); - expect(systemRead.items.length >= 2).toBe(true); + expect(readEvidence?.resultCount).toBe(1); + const [apiKeyReadEvidence] = yield* admin + .select({ authBindingId: dataAccessEvents.authBindingId }) + .from(dataAccessEvents) + .where(and(eq(dataAccessEvents.tenantId, tenantId), eq(dataAccessEvents.authMethod, 'api_key'))); + expect(apiKeyReadEvidence?.authBindingId).toBe(selfBinding.authBindingId); - const supportReason = 'Investigate a live support incident'; - const supportSessionRef = `better-auth-session:${randomUUID()}`; - yield* runIdentityAction( - Effect.forEach( - ['requested', 'started'] as const, - (checkpoint) => - actionRuntime.runAction({ - payload: withOptionalProperty( - { - checkpoint, - originalPrincipalId: administratorPrincipalId, - reason: supportReason, - }, - checkpoint === 'started', - 'sessionRef', - supportSessionRef, - { - targetPrincipalId: supportTargetPrincipalId, - }, - ), - principal, - registration: recordSupportImpersonationAction, - transport: { - correlationId: randomUUID(), - idempotencyKey: `support-live-${checkpoint}-${randomUUID()}`, - }, - }), - { concurrency: 1, discard: true }, - ), - ); - const supportRelationship = relationship( - 'tenant', - tenantId, - 'support', - 'principal', - administratorPrincipalId, - ); - const removeSupportRelationshipRequest = v1.WriteRelationshipsRequest.create({ - updates: [ - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.DELETE, - relationship: supportRelationship, - }), - ], - }); - yield* Effect.promise(() => - spiceDbClient.promises.writeRelationships(removeSupportRelationshipRequest), - ); - yield* admin - .update(principalAuthBindings) - .set({ - revokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-08-09T00:00:00.000Z')), - status: 'revoked', - }) - .where(eq(principalAuthBindings.principalAuthBindingId, administratorAuthBindingId)); - yield* admin - .update(principals) - .set({ status: 'disabled' }) - .where( - inArray(principals.principalId, [administratorPrincipalId, supportTargetPrincipalId]), - ); - const recoveryPrincipal = yield* makeSupportRecoveryPrincipalContextResolver({ - executor: runtimeDatabase, - }).resolveStoppedImpersonation({ - originalAuthBindingId: administratorAuthBindingId, - originalPrincipalId: administratorPrincipalId, - originalSessionId: randomUUID(), - tenantId, - }); - const stopped = yield* runIdentityAction( + const systemPrincipal = yield* makeSystemPrincipalContextResolver({ + executor: runtimeDatabase, + }).resolve({ + principalId: systemPrincipalId, + registration: registerSystemWorkload({ + jobKey: 'identity-runtime-integration', + }), + runReference: randomUUID(), + tenantId, + }); + const systemDenied = yield* runIdentityAction( + Effect.flip( actionRuntime.runAction({ payload: { - checkpoint: 'stopped', - originalPrincipalId: administratorPrincipalId, - reason: supportReason, - sessionRef: supportSessionRef, - targetPrincipalId: supportTargetPrincipalId, + displayName: 'Executor-only system integration', + kind: 'integration', }, - principal: recoveryPrincipal, - registration: recordSupportImpersonationAction, + principal: systemPrincipal, + registration: createNonHumanPrincipalAction, transport: { correlationId: randomUUID(), - idempotencyKey: `support-live-stopped-${randomUUID()}`, + idempotencyKey: randomUUID(), }, }), - ); - expect(stopped).toEqual({ checkpoint: 'stopped', recorded: true }); - const supportAudits = yield* admin - .select({ evidence: auditEvents.evidenceJson }) - .from(auditEvents) - .where( - and(eq(auditEvents.tenantId, tenantId), eq(auditEvents.eventType, 'action.executed')), - ); - expect( - supportAudits - .map(({ evidence }) => - Predicate.isObjectKeyword(evidence) && evidence !== null && 'checkpoint' in evidence - ? evidence.checkpoint - : undefined, - ) - .filter((checkpoint): checkpoint is string => Predicate.isString(checkpoint)) - .toSorted(), - ).toEqual(['requested', 'started', 'stopped']); - const supportAccess = yield* admin - .select({ count: dataAccessEvents.resultCount }) - .from(dataAccessEvents) - .where( - and( - eq(dataAccessEvents.tenantId, tenantId), - eq( - dataAccessEvents.evidencePolicyKey, - 'core.identity.record-support-impersonation.access.v1', - ), - ), - ); - expect(supportAccess.length).toBe(6); - const succeededIdentityActions = yield* admin - .select({ actionKey: actionInvocations.actionKey }) - .from(actionInvocations) - .where( - and( - eq(actionInvocations.tenantId, tenantId), - eq(actionInvocations.status, 'succeeded'), - ), - ); - expect( - [...new Set(succeededIdentityActions.map(({ actionKey }) => actionKey))].toSorted(), - ).toEqual([...identityActionKeys].toSorted()); + ), + ); + expect(Predicate.isTagged(systemDenied, 'ActionPermissionDenied')).toBe(true); + const systemTenantMember = relationship('tenant', tenantId, 'member', 'principal', systemPrincipalId); + const systemIdentityAdministrator = relationship( + 'tenant', + tenantId, + 'identity_admin', + 'principal', + systemPrincipalId, + ); + spiceDbRelationships.push(systemTenantMember, systemIdentityAdministrator); + const systemRelationshipsRequest = v1.WriteRelationshipsRequest.create({ + updates: [systemTenantMember, systemIdentityAdministrator].map((item) => + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.TOUCH, + relationship: item, + }), + ), + }); + yield* Effect.promise(() => spiceDbClient.promises.writeRelationships(systemRelationshipsRequest)); + const systemCreated = yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + displayName: 'System-created integration', + kind: 'integration', + }, + principal: systemPrincipal, + registration: createNonHumanPrincipalAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: randomUUID(), + }, + }), + ); + expect(systemCreated.status).toBe('active'); + const systemRead = yield* readRuntime.runRead({ + input: { limit: 100, offset: 0 }, + principal: systemPrincipal, + registration: managedPrincipalsRead, + transport: { correlationId: randomUUID() }, }); - const cleanupRelationships = Effect.suspend(() => { - const request = v1.WriteRelationshipsRequest.create({ - updates: spiceDbRelationships.map((item) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.DELETE, - relationship: item, + expect(systemRead.items.length >= 2).toBe(true); + + const supportReason = 'Investigate a live support incident'; + const supportSessionRef = `better-auth-session:${randomUUID()}`; + yield* runIdentityAction( + Effect.forEach( + ['requested', 'started'] as const, + (checkpoint) => + actionRuntime.runAction({ + payload: withOptionalProperty( + { + checkpoint, + originalPrincipalId: administratorPrincipalId, + reason: supportReason, + }, + checkpoint === 'started', + 'sessionRef', + supportSessionRef, + { + targetPrincipalId: supportTargetPrincipalId, + }, + ), + principal, + registration: recordSupportImpersonationAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: `support-live-${checkpoint}-${randomUUID()}`, + }, }), - ), - }); - return Effect.promise(() => spiceDbClient.promises.writeRelationships(request)); + { concurrency: 1, discard: true }, + ), + ); + const supportRelationship = relationship('tenant', tenantId, 'support', 'principal', administratorPrincipalId); + const removeSupportRelationshipRequest = v1.WriteRelationshipsRequest.create({ + updates: [ + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.DELETE, + relationship: supportRelationship, + }), + ], + }); + yield* Effect.promise(() => spiceDbClient.promises.writeRelationships(removeSupportRelationshipRequest)); + yield* admin + .update(principalAuthBindings) + .set({ + revokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-08-09T00:00:00.000Z')), + status: 'revoked', + }) + .where(eq(principalAuthBindings.principalAuthBindingId, administratorAuthBindingId)); + yield* admin + .update(principals) + .set({ status: 'disabled' }) + .where(inArray(principals.principalId, [administratorPrincipalId, supportTargetPrincipalId])); + const recoveryPrincipal = yield* makeSupportRecoveryPrincipalContextResolver({ + executor: runtimeDatabase, + }).resolveStoppedImpersonation({ + originalAuthBindingId: administratorAuthBindingId, + originalPrincipalId: administratorPrincipalId, + originalSessionId: randomUUID(), + tenantId, }); - const release = cleanup.pipe( - Effect.ensuring(cleanupRelationships.pipe(Effect.orDie)), - Effect.ensuring( - Effect.sync(() => { - permissionClient.close(); - spiceDbClient.close(); + const stopped = yield* runIdentityAction( + actionRuntime.runAction({ + payload: { + checkpoint: 'stopped', + originalPrincipalId: administratorPrincipalId, + reason: supportReason, + sessionRef: supportSessionRef, + targetPrincipalId: supportTargetPrincipalId, + }, + principal: recoveryPrincipal, + registration: recordSupportImpersonationAction, + transport: { + correlationId: randomUUID(), + idempotencyKey: `support-live-stopped-${randomUUID()}`, + }, + }), + ); + expect(stopped).toEqual({ checkpoint: 'stopped', recorded: true }); + const supportAudits = yield* admin + .select({ evidence: auditEvents.evidenceJson }) + .from(auditEvents) + .where(and(eq(auditEvents.tenantId, tenantId), eq(auditEvents.eventType, 'action.executed'))); + expect( + supportAudits + .map(({ evidence }) => + Predicate.isObjectKeyword(evidence) && evidence !== null && 'checkpoint' in evidence + ? evidence.checkpoint + : undefined, + ) + .filter((checkpoint): checkpoint is string => Predicate.isString(checkpoint)) + .toSorted(), + ).toEqual(['requested', 'started', 'stopped']); + const supportAccess = yield* admin + .select({ count: dataAccessEvents.resultCount }) + .from(dataAccessEvents) + .where( + and( + eq(dataAccessEvents.tenantId, tenantId), + eq(dataAccessEvents.evidencePolicyKey, 'core.identity.record-support-impersonation.access.v1'), + ), + ); + expect(supportAccess.length).toBe(6); + const succeededIdentityActions = yield* admin + .select({ actionKey: actionInvocations.actionKey }) + .from(actionInvocations) + .where(and(eq(actionInvocations.tenantId, tenantId), eq(actionInvocations.status, 'succeeded'))); + expect([...new Set(succeededIdentityActions.map(({ actionKey }) => actionKey))].toSorted()).toEqual( + [...identityActionKeys].toSorted(), + ); + }); + const cleanupRelationships = Effect.suspend(() => { + const request = v1.WriteRelationshipsRequest.create({ + updates: spiceDbRelationships.map((item) => + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.DELETE, + relationship: item, }), ), - ); - yield* Effect.addFinalizer(() => release.pipe(Effect.orDie)); - yield* exercise; - }), + }); + return Effect.promise(() => spiceDbClient.promises.writeRelationships(request)); + }); + const release = cleanup.pipe( + Effect.ensuring(cleanupRelationships.pipe(Effect.orDie)), + Effect.ensuring( + Effect.sync(() => { + permissionClient.close(); + spiceDbClient.close(); + }), + ), + ); + yield* Effect.addFinalizer(() => release.pipe(Effect.orDie)); + yield* exercise; + }), ); diff --git a/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts b/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts index d38733da7..5d372445c 100644 --- a/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts +++ b/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts @@ -1,11 +1,11 @@ -import { expect, it } from 'effect-rstest'; - import { eq } from 'drizzle-orm'; import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeLegalEntityContext } from '../../src/auth/legal-entity-context.ts'; +import { makeCoreDatabase } from '../../src/db/client.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { legalEntities, tenants } from '../../src/db/schema.ts'; -import { makeCoreDatabase } from '../../src/db/client.ts'; const tenantOne = '11000000-0000-4000-8000-000000000001'; const tenantTwo = '11000000-0000-4000-8000-000000000002'; diff --git a/app/packages/core-runtime/tests/integration/module-state-gate.test.ts b/app/packages/core-runtime/tests/integration/module-state-gate.test.ts index e2142dd3e..8d944b7c6 100644 --- a/app/packages/core-runtime/tests/integration/module-state-gate.test.ts +++ b/app/packages/core-runtime/tests/integration/module-state-gate.test.ts @@ -1,22 +1,18 @@ -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; + import { and, eq } from 'drizzle-orm'; import { Effect, Exit, Option, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; + import type { CoreDatabase } from '../../src/db/client.ts'; import { makeCoreDatabase } from '../../src/db/client.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { tenantModuleStates, tenants } from '../../src/db/schema.ts'; import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; -import { - decideModuleStateAccess, - makeModuleStateGate, -} from '../../src/modules/module-state-gate.ts'; +import { decideModuleStateAccess, makeModuleStateGate } from '../../src/modules/module-state-gate.ts'; import { TenantModuleStateReadUnavailableError } from '../../src/modules/tenant-module-state-errors.ts'; import type { TenantModuleStateServiceContract } from '../../src/modules/tenant-module-state-service.ts'; -import { - TENANT_MODULE_STATES, - makeTenantModuleStateService, -} from '../../src/modules/tenant-module-state-service.ts'; +import { TENANT_MODULE_STATES, makeTenantModuleStateService } from '../../src/modules/tenant-module-state-service.ts'; type DatabaseService = (typeof CoreDatabase)['Service']; @@ -32,176 +28,163 @@ const unavailableStateService = (reason: string): TenantModuleStateServiceContra }; }; -it.live( - 'batches tenant-isolated states once, rejects malformed/unavailable reads, and rechecks transactionally', - () => - Effect.gen(function* moduleStateGate1() { - const tenantOne = randomUUID(); - const tenantTwo = randomUUID(); - const moduleKey = `gate.integration-${tenantOne}`; - const stateModuleKey = (state: (typeof TENANT_MODULE_STATES)[number]): string => - `${moduleKey}.${state.replaceAll('_', '-')}`; - const configuration = yield* loadDatabaseConfig(); - const database = yield* makeCoreDatabase(configuration); - yield* Effect.addFinalizer(() => - Effect.forEach( - [tenantModuleStates, tenants], - (table) => - Effect.forEach( - [tenantOne, tenantTwo], - (tenantId) => database.executor.delete(table).where(eq(table.tenantId, tenantId)), - { discard: true }, - ), - { discard: true }, - ).pipe(Effect.orDie), - ); - yield* database.executor.insert(tenants).values([ - { - defaultLocale: 'en', - name: 'Gate Integration One', - slug: `gate-one-${tenantOne}`, - status: 'active', - tenantId: tenantOne, - }, - { - defaultLocale: 'en', - name: 'Gate Integration Two', - slug: `gate-two-${tenantTwo}`, - status: 'active', - tenantId: tenantTwo, - }, - ]); - yield* database.executor.insert(tenantModuleStates).values([ - { moduleKey, state: 'active', tenantId: tenantOne }, - { moduleKey, state: 'quarantined', tenantId: tenantTwo }, - ...TENANT_MODULE_STATES.map((state) => ({ - moduleKey: stateModuleKey(state), - state, - tenantId: tenantOne, - })), - ]); +it.live('batches tenant-isolated states once, rejects malformed/unavailable reads, and rechecks transactionally', () => + Effect.gen(function* moduleStateGate1() { + const tenantOne = randomUUID(); + const tenantTwo = randomUUID(); + const moduleKey = `gate.integration-${tenantOne}`; + const stateModuleKey = (state: (typeof TENANT_MODULE_STATES)[number]): string => + `${moduleKey}.${state.replaceAll('_', '-')}`; + const configuration = yield* loadDatabaseConfig(); + const database = yield* makeCoreDatabase(configuration); + yield* Effect.addFinalizer(() => + Effect.forEach( + [tenantModuleStates, tenants], + (table) => + Effect.forEach( + [tenantOne, tenantTwo], + (tenantId) => database.executor.delete(table).where(eq(table.tenantId, tenantId)), + { discard: true }, + ), + { discard: true }, + ).pipe(Effect.orDie), + ); + yield* database.executor.insert(tenants).values([ + { + defaultLocale: 'en', + name: 'Gate Integration One', + slug: `gate-one-${tenantOne}`, + status: 'active', + tenantId: tenantOne, + }, + { + defaultLocale: 'en', + name: 'Gate Integration Two', + slug: `gate-two-${tenantTwo}`, + status: 'active', + tenantId: tenantTwo, + }, + ]); + yield* database.executor.insert(tenantModuleStates).values([ + { moduleKey, state: 'active', tenantId: tenantOne }, + { moduleKey, state: 'quarantined', tenantId: tenantTwo }, + ...TENANT_MODULE_STATES.map((state) => ({ + moduleKey: stateModuleKey(state), + state, + tenantId: tenantOne, + })), + ]); - let selects = 0; - const countingExecutor: DatabaseService['executor'] = Object.create(database.executor); - Object.defineProperty(countingExecutor, 'select', { - configurable: true, - get: () => { - selects += 1; - return database.executor.select; - }, - }); - const gate = makeModuleStateGate( - makeTenantModuleStateService({ executor: countingExecutor }), - ); - const read = defineTenantModuleEntrypoint({ - access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: `${moduleKey}.page`, - moduleKey, - role: 'page', - }); - const write = defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: `${moduleKey}.write`, - moduleKey, - role: 'action', - }); - const snapshot = yield* gate.prepareSnapshot(tenantOne, [read, read, write]); - yield* gate.check(snapshot, read); - yield* gate.check(snapshot, read); - expect(selects).toBe(1); + let selects = 0; + const countingExecutor: DatabaseService['executor'] = Object.create(database.executor); + Object.defineProperty(countingExecutor, 'select', { + configurable: true, + get: () => { + selects += 1; + return database.executor.select; + }, + }); + const gate = makeModuleStateGate(makeTenantModuleStateService({ executor: countingExecutor })); + const read = defineTenantModuleEntrypoint({ + access: 'read', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: `${moduleKey}.page`, + moduleKey, + role: 'page', + }); + const write = defineTenantModuleEntrypoint({ + access: 'write', + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, + entrypointKey: `${moduleKey}.write`, + moduleKey, + role: 'action', + }); + const snapshot = yield* gate.prepareSnapshot(tenantOne, [read, read, write]); + yield* gate.check(snapshot, read); + yield* gate.check(snapshot, read); + expect(selects).toBe(1); - const persistedStateDescriptors = TENANT_MODULE_STATES.map((state) => - defineTenantModuleEntrypoint({ - access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: `${stateModuleKey(state)}.page`, - moduleKey: stateModuleKey(state), - role: 'page', - }), - ); - selects = 0; - const firstPersistedDescriptor = Option.getOrThrow( - Option.fromNullishOr(persistedStateDescriptors[0]), - ); - expect(firstPersistedDescriptor).toBeDefined(); - const persistedStateSnapshot = yield* gate.prepareSnapshot(tenantOne, [ - ...persistedStateDescriptors, - firstPersistedDescriptor, - ]); - expect(selects).toBe(1); - const persistedStateExits = yield* Effect.forEach( - TENANT_MODULE_STATES, - (_, index) => { - const descriptor = Option.getOrThrow( - Option.fromNullishOr(persistedStateDescriptors[index]), - ); - expect(descriptor).toBeDefined(); - return Effect.exit(gate.check(persistedStateSnapshot, descriptor)); + const persistedStateDescriptors = TENANT_MODULE_STATES.map((state) => + defineTenantModuleEntrypoint({ + access: 'read', + authorization: { + kind: 'context_permission', + permission: 'module.access', }, - { concurrency: 'unbounded' }, - ); - for (const [index, state] of TENANT_MODULE_STATES.entries()) { - const descriptor = Option.getOrThrow( - Option.fromNullishOr(persistedStateDescriptors[index]), - ); + entrypointKey: `${stateModuleKey(state)}.page`, + moduleKey: stateModuleKey(state), + role: 'page', + }), + ); + selects = 0; + const firstPersistedDescriptor = Option.getOrThrow(Option.fromNullishOr(persistedStateDescriptors[0])); + expect(firstPersistedDescriptor).toBeDefined(); + const persistedStateSnapshot = yield* gate.prepareSnapshot(tenantOne, [ + ...persistedStateDescriptors, + firstPersistedDescriptor, + ]); + expect(selects).toBe(1); + const persistedStateExits = yield* Effect.forEach( + TENANT_MODULE_STATES, + (_, index) => { + const descriptor = Option.getOrThrow(Option.fromNullishOr(persistedStateDescriptors[index])); expect(descriptor).toBeDefined(); - const exit = Option.getOrThrow(Option.fromNullishOr(persistedStateExits[index])); - expect(exit).toBeDefined(); - expect(Exit.isSuccess(exit), state).toBe( - decideModuleStateAccess(state, 'read') === 'allow', - ); - } + return Effect.exit(gate.check(persistedStateSnapshot, descriptor)); + }, + { concurrency: 'unbounded' }, + ); + for (const [index, state] of TENANT_MODULE_STATES.entries()) { + const descriptor = Option.getOrThrow(Option.fromNullishOr(persistedStateDescriptors[index])); + expect(descriptor).toBeDefined(); + const exit = Option.getOrThrow(Option.fromNullishOr(persistedStateExits[index])); + expect(exit).toBeDefined(); + expect(Exit.isSuccess(exit), state).toBe(decideModuleStateAccess(state, 'read') === 'allow'); + } - const tenantTwoSnapshot = yield* gate.prepareSnapshot(tenantTwo, [read]); - const quarantined = yield* Effect.flip(gate.check(tenantTwoSnapshot, read)); - expect(Predicate.isTagged(quarantined, 'ModuleStateDeniedError')).toBe(true); + const tenantTwoSnapshot = yield* gate.prepareSnapshot(tenantTwo, [read]); + const quarantined = yield* Effect.flip(gate.check(tenantTwoSnapshot, read)); + expect(Predicate.isTagged(quarantined, 'ModuleStateDeniedError')).toBe(true); - const missingDescriptor = defineTenantModuleEntrypoint({ - access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: `${moduleKey}.missing`, - moduleKey: `${moduleKey}.missing-module`, - role: 'page', - }); - const missingSnapshot = yield* gate.prepareSnapshot(tenantOne, [missingDescriptor]); - const missing = yield* Effect.flip(gate.check(missingSnapshot, missingDescriptor)); - expect(Predicate.isTagged(missing, 'ModuleStateDeniedError')).toBe(true); + const missingDescriptor = defineTenantModuleEntrypoint({ + access: 'read', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: `${moduleKey}.missing`, + moduleKey: `${moduleKey}.missing-module`, + role: 'page', + }); + const missingSnapshot = yield* gate.prepareSnapshot(tenantOne, [missingDescriptor]); + const missing = yield* Effect.flip(gate.check(missingSnapshot, missingDescriptor)); + expect(Predicate.isTagged(missing, 'ModuleStateDeniedError')).toBe(true); - yield* database.executor.transaction((transaction) => - gate.recheckWrite(transaction, tenantOne, write), - ); - yield* database.executor - .update(tenantModuleStates) - .set({ state: 'read_only' }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantOne), - eq(tenantModuleStates.moduleKey, moduleKey), - ), - ); - const lockedDenial = yield* database.executor.transaction((transaction) => - Effect.flip(gate.recheckWrite(transaction, tenantOne, write)), - ); - expect(Predicate.isTagged(lockedDenial, 'ModuleStateDeniedError')).toBe(true); + yield* database.executor.transaction((transaction) => gate.recheckWrite(transaction, tenantOne, write)); + yield* database.executor + .update(tenantModuleStates) + .set({ state: 'read_only' }) + .where(and(eq(tenantModuleStates.tenantId, tenantOne), eq(tenantModuleStates.moduleKey, moduleKey))); + const lockedDenial = yield* database.executor.transaction((transaction) => + Effect.flip(gate.recheckWrite(transaction, tenantOne, write)), + ); + expect(Predicate.isTagged(lockedDenial, 'ModuleStateDeniedError')).toBe(true); - const unavailable = yield* Effect.flip( - makeModuleStateGate(unavailableStateService('secret db failure')).prepareSnapshot( - tenantOne, - [read], - ), - ); - expect(Predicate.isTagged(unavailable, 'ModuleStateCheckUnavailableError')).toBe(true); - expect(unavailable.reason).not.toMatch(/secret|db failure/u); + const unavailable = yield* Effect.flip( + makeModuleStateGate(unavailableStateService('secret db failure')).prepareSnapshot(tenantOne, [read]), + ); + expect(Predicate.isTagged(unavailable, 'ModuleStateCheckUnavailableError')).toBe(true); + expect(unavailable.reason).not.toMatch(/secret|db failure/u); - const malformed = yield* Effect.flip( - makeModuleStateGate(unavailableStateService('corrupt-storage-value')).prepareSnapshot( - tenantOne, - [read], - ), - ); - expect(Predicate.isTagged(malformed, 'ModuleStateCheckUnavailableError')).toBe(true); - expect(malformed.reason).not.toMatch(/corrupt|storage/u); - }), + const malformed = yield* Effect.flip( + makeModuleStateGate(unavailableStateService('corrupt-storage-value')).prepareSnapshot(tenantOne, [read]), + ); + expect(Predicate.isTagged(malformed, 'ModuleStateCheckUnavailableError')).toBe(true); + expect(malformed.reason).not.toMatch(/corrupt|storage/u); + }), ); diff --git a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts index 029cf4eca..8e62f34ae 100644 --- a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts @@ -1,8 +1,9 @@ -import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; import { randomUUID } from 'node:crypto'; + import { and, asc, eq, inArray } from 'drizzle-orm'; import { DateTime, Effect, Option, Schema, pipe } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeCoreDatabase } from '../../src/db/client.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { @@ -15,11 +16,12 @@ import { workerCheckpoints, } from '../../src/db/schema.ts'; import type { CoreDatabaseExecutor } from '../../src/db/types.ts'; -import { defineOutboxWorker } from '../../src/outbox/definition.ts'; import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; +import { defineOutboxWorker } from '../../src/outbox/definition.ts'; import type { AnyOutboxWorkerRegistration } from '../../src/outbox/definition.ts'; import { OutboxClaimLostError } from '../../src/outbox/errors.ts'; import { makeOutboxRepository } from '../../src/outbox/repository.ts'; +import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; const MessageKeySchema = Schema.String.pipe(Schema.brand('MessageKey')); const payloadSchema = Schema.Struct({ messageKey: MessageKeySchema }); @@ -139,9 +141,7 @@ const cleanupTenant = (database: CoreDatabaseExecutor, tenantId: string) => deliveries.map(({ deliveryId }) => deliveryId), ), ); - yield* database - .delete(outboxDeliveries) - .where(inArray(outboxDeliveries.outboxMessageId, messageIds)); + yield* database.delete(outboxDeliveries).where(inArray(outboxDeliveries.outboxMessageId, messageIds)); yield* purgeFixtureRows([ database.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), database.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), @@ -164,7 +164,9 @@ const matchedWorker = Effect.fn(function* matchedWorker( database: CoreDatabaseExecutor, tenantId: string, workerKey: string, - options: Parameters[1] & { readonly messages?: number } = {}, + options: Parameters[1] & { + readonly messages?: number; + } = {}, ) { yield* activateConsumer(database, tenantId); const messages = yield* Effect.forEach(Array.from({ length: options.messages ?? 1 }), () => @@ -184,19 +186,13 @@ it.live('matches zero, one, or multiple exact workers once without historical ba yield* insertMessage(database, tenantId, 'producer.unmatched'); const repository = makeOutboxRepository(database); const workers = [makeWorker('consumer.alpha'), makeWorker('consumer.beta')]; - const firstMatch = yield* repository.matchUnmatched( - workers.map(subscriptionOf), - dateAt('2026-08-03T10:00:00Z'), - ); + const firstMatch = yield* repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:00:00Z')); expect( firstMatch.deliveriesCreated, `Initial matcher batch processed ${firstMatch.messagesMatched} unmatched messages`, ).toBe(2); expect(firstMatch.messagesMatched >= 2).toBe(true); - const repeatMatch = yield* repository.matchUnmatched( - workers.map(subscriptionOf), - dateAt('2026-08-03T10:01:00Z'), - ); + const repeatMatch = yield* repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:01:00Z')); expect(repeatMatch.deliveriesCreated).toBe(0); const lateWorkerMatch = yield* repository.matchUnmatched( [...workers, makeWorker('consumer.late')].map(subscriptionOf), @@ -206,10 +202,7 @@ it.live('matches zero, one, or multiple exact workers once without historical ba const deliveries = yield* database .select() .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) + .innerJoin(outboxMessages, eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId)) .where(eq(outboxMessages.tenantId, tenantId)); expect(deliveries.length).toBe(2); expect(deliveries.map((row) => row.outbox_deliveries.workerKey).toSorted()).toEqual([ @@ -245,9 +238,7 @@ it.live('matches the complete subscription catalog before owner-local processes `Catalog matcher batch processed ${matched.messagesMatched} unmatched messages`, ).toBe(2); const claimAt = yield* DateTime.nowAsDate; - const consumerClaim = Option.getOrNull( - yield* repository.claimNext([consumerWorker], 'consumer-process', claimAt), - ); + const consumerClaim = Option.getOrNull(yield* repository.claimNext([consumerWorker], 'consumer-process', claimAt)); const reportingClaim = Option.getOrNull( yield* repository.claimNext([reportingWorker], 'reporting-process', claimAt), ); @@ -255,121 +246,86 @@ it.live('matches the complete subscription catalog before owner-local processes expect(reportingClaim?.workerKey).toBe('reporting.local'); }), ); -it.live( - 'gates claims on every non-active consumer state and permits one concurrent live claim', - () => - Effect.gen(function* gatesClaimsOnEveryNonactive() { - const { database, tenantId } = yield* tenantFixture; - yield* insertMessage(database, tenantId); - const registration = makeWorker('consumer.module-gated'); - const repository = makeOutboxRepository(database); - const matched = yield* repository.matchUnmatched( - [subscriptionOf(registration)], - dateAt('2026-08-03T11:00:00Z'), - ); - expect( - matched.deliveriesCreated, - `Module-gated matcher batch processed ${matched.messagesMatched} unmatched messages`, - ).toBe(1); - const claimAt = advanceDate(yield* DateTime.nowAsDate, 1000); - expect( - Option.getOrNull(yield* repository.claimNext([registration], 'runtime-a', claimAt)), - ).toBe(null); - yield* activateConsumer(database, tenantId, 'inactive'); - yield* pipe( - ['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const, - Effect.forEach((state) => - Effect.gen(function* checksInactiveState() { - yield* database - .update(tenantModuleStates) - .set({ state }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, 'consumer'), - ), - ); - expect( - Option.getOrNull( - yield* repository.claimNext([registration], `runtime-${state}`, claimAt), - ), - ).toBe(null); - }), - ), - ); - yield* database - .update(tenantModuleStates) - .set({ state: 'active' }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, 'consumer'), - ), - ); - const claimOptions = yield* Effect.all( - [ - repository.claimNext([registration], 'runtime-a', claimAt), - repository.claimNext([registration], 'runtime-b', claimAt), - ], - { concurrency: 'unbounded' }, - ); - const claims = claimOptions.map(Option.getOrNull); - expect(claims.filter((candidate) => candidate !== null).length).toBe(1); - const claimed = Option.getOrThrow( - Option.fromNullishOr(claims.find((candidate) => candidate !== null)), - ); - expect(claimed).toBeDefined(); - const attempt = Option.getOrThrow( - Option.fromNullishOr( - (yield* database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, claimed.deliveryId)))[0], - ), - ); - expect(attempt).toBeDefined(); - expect(attempt.finishedAt).toBe(null); - }), +it.live('gates claims on every non-active consumer state and permits one concurrent live claim', () => + Effect.gen(function* gatesClaimsOnEveryNonactive() { + const { database, tenantId } = yield* tenantFixture; + yield* insertMessage(database, tenantId); + const registration = makeWorker('consumer.module-gated'); + const repository = makeOutboxRepository(database); + const matched = yield* repository.matchUnmatched([subscriptionOf(registration)], dateAt('2026-08-03T11:00:00Z')); + expect( + matched.deliveriesCreated, + `Module-gated matcher batch processed ${matched.messagesMatched} unmatched messages`, + ).toBe(1); + const claimAt = advanceDate(yield* DateTime.nowAsDate, 1000); + expect(Option.getOrNull(yield* repository.claimNext([registration], 'runtime-a', claimAt))).toBe(null); + yield* activateConsumer(database, tenantId, 'inactive'); + yield* pipe( + ['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const, + Effect.forEach((state) => + Effect.gen(function* checksInactiveState() { + yield* database + .update(tenantModuleStates) + .set({ state }) + .where(and(eq(tenantModuleStates.tenantId, tenantId), eq(tenantModuleStates.moduleKey, 'consumer'))); + expect(Option.getOrNull(yield* repository.claimNext([registration], `runtime-${state}`, claimAt))).toBe(null); + }), + ), + ); + yield* database + .update(tenantModuleStates) + .set({ state: 'active' }) + .where(and(eq(tenantModuleStates.tenantId, tenantId), eq(tenantModuleStates.moduleKey, 'consumer'))); + const claimOptions = yield* Effect.all( + [ + repository.claimNext([registration], 'runtime-a', claimAt), + repository.claimNext([registration], 'runtime-b', claimAt), + ], + { concurrency: 'unbounded' }, + ); + const claims = claimOptions.map(Option.getOrNull); + expect(claims.filter((candidate) => candidate !== null).length).toBe(1); + const claimed = Option.getOrThrow(Option.fromNullishOr(claims.find((candidate) => candidate !== null))); + expect(claimed).toBeDefined(); + const attempt = Option.getOrThrow( + Option.fromNullishOr( + (yield* database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, claimed.deliveryId)))[0], + ), + ); + expect(attempt).toBeDefined(); + expect(attempt.finishedAt).toBe(null); + }), ); -it.live( - 'reclaims only expired leases, abandons the old attempt, and rejects stale finalization', - () => - Effect.gen(function* reclaimsOnlyExpiredLeasesAbandons() { - const { database, tenantId } = yield* tenantFixture; - const { - now: started, - registration, - repository, - } = yield* matchedWorker(database, tenantId, 'consumer.lease-proof'); - const first = Option.getOrThrow( - yield* repository.claimNext([registration], 'runtime-a', started), - ); - expect(first).toBeDefined(); - expect( - Option.getOrNull( - yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 999)), - ), - ).toBe(null); - const second = Option.getOrThrow( - yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 1001)), - ); - expect(second).toBeDefined(); - expect(second.claimId).not.toBe(first.claimId); - expect( - Schema.is(OutboxClaimLostError)( - yield* Effect.flip(repository.complete(first, advanceDate(started, 1002))), - ), - ).toBe(true); - const attempts = yield* database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, first.deliveryId)) - .orderBy(asc(outboxAttempts.startedAt)); - expect(attempts.length).toBe(2); - expect(attempts[0]?.errorMessage).toBe('Outbox Worker lease expired before completion'); - expect(Option.isSome(Option.fromNullishOr(attempts[0]?.finishedAt))).toBe(true); - expect(attempts[1]?.finishedAt).toBe(null); - }), +it.live('reclaims only expired leases, abandons the old attempt, and rejects stale finalization', () => + Effect.gen(function* reclaimsOnlyExpiredLeasesAbandons() { + const { database, tenantId } = yield* tenantFixture; + const { now: started, registration, repository } = yield* matchedWorker(database, tenantId, 'consumer.lease-proof'); + const first = Option.getOrThrow(yield* repository.claimNext([registration], 'runtime-a', started)); + expect(first).toBeDefined(); + expect(Option.getOrNull(yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 999)))).toBe( + null, + ); + const second = Option.getOrThrow( + yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 1001)), + ); + expect(second).toBeDefined(); + expect(second.claimId).not.toBe(first.claimId); + expect( + Schema.is(OutboxClaimLostError)(yield* Effect.flip(repository.complete(first, advanceDate(started, 1002)))), + ).toBe(true); + const attempts = yield* database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, first.deliveryId)) + .orderBy(asc(outboxAttempts.startedAt)); + expect(attempts.length).toBe(2); + expect(attempts[0]?.errorMessage).toBe('Outbox Worker lease expired before completion'); + expect(Option.isSome(Option.fromNullishOr(attempts[0]?.finishedAt))).toBe(true); + expect(attempts[1]?.finishedAt).toBe(null); + }), ); it.live('finishes an abandoned final attempt before dead-lettering its expired delivery', () => Effect.gen(function* finishesAnAbandonedFinalAttempt() { @@ -378,26 +334,21 @@ it.live('finishes an abandoned final attempt before dead-lettering its expired d now: started, registration, repository, - } = yield* matchedWorker(database, tenantId, 'consumer.final-lease', { maxAttempts: 1 }); - const claim = Option.getOrThrow( - yield* repository.claimNext([registration], 'runtime-a', started), - ); + } = yield* matchedWorker(database, tenantId, 'consumer.final-lease', { + maxAttempts: 1, + }); + const claim = Option.getOrThrow(yield* repository.claimNext([registration], 'runtime-a', started)); expect(claim).toBeDefined(); - expect( - Option.getOrNull( - yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 1001)), - ), - ).toBe(null); + expect(Option.getOrNull(yield* repository.claimNext([registration], 'runtime-b', advanceDate(started, 1001)))).toBe( + null, + ); const [delivery] = yield* database .select() .from(outboxDeliveries) .where(eq(outboxDeliveries.outboxDeliveryId, claim.deliveryId)); const attempt = Option.getOrThrow( Option.fromNullishOr( - (yield* database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, claim.deliveryId)))[0], + (yield* database.select().from(outboxAttempts).where(eq(outboxAttempts.outboxDeliveryId, claim.deliveryId)))[0], ), ); expect(delivery?.status).toBe('dead'); @@ -421,19 +372,13 @@ it.live('finalizes success atomically and advances only through contiguous done expect(first).toBeDefined(); expect(second).toBeDefined(); yield* repository.complete(second, advanceDate(now, 1)); - expect( - yield* database - .select() - .from(workerCheckpoints) - .where(eq(workerCheckpoints.tenantId, tenantId)), - ).toEqual([]); + expect(yield* database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId))).toEqual( + [], + ); yield* repository.complete(first, advanceDate(now, 2)); const checkpoint = Option.getOrThrow( Option.fromNullishOr( - (yield* database - .select() - .from(workerCheckpoints) - .where(eq(workerCheckpoints.tenantId, tenantId)))[0], + (yield* database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)))[0], ), ); expect(checkpoint).toBeDefined(); @@ -441,73 +386,48 @@ it.live('finalizes success atomically and advances only through contiguous done expect(checkpoint.streamKey).toBe('producer:producer.message-created'); expect(checkpoint.lastTenantSequenceNo).toBe(secondMessage.tenantSequenceNo); expect( - Option.getOrThrow(Option.fromNullishOr(checkpoint.lastTenantSequenceNo)) > - firstMessage.tenantSequenceNo, + Option.getOrThrow(Option.fromNullishOr(checkpoint.lastTenantSequenceNo)) > firstMessage.tenantSequenceNo, ).toBe(true); const deliveries = yield* database .select() .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) + .innerJoin(outboxMessages, eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId)) .where(eq(outboxMessages.tenantId, tenantId)); expect(deliveries.every((row) => row.outbox_deliveries.status === 'done')).toBe(true); expect(deliveries.every((row) => row.outbox_deliveries.claimedBy === null)).toBe(true); }), ); -it.live( - 'schedules bounded retry, dead-letters exhaustion, stores safe errors, and never checkpoints failure', - () => - Effect.gen(function* schedulesBoundedRetryDeadlettersExhaustion() { - const { database, tenantId } = yield* tenantFixture; - const { now, registration, repository } = yield* matchedWorker( - database, - tenantId, - 'consumer.retry-proof', - { maxAttempts: 2 }, - ); - const first = Option.getOrThrow( - yield* repository.claimNext([registration], 'runtime-a', now), - ); - expect(first).toBeDefined(); - expect(yield* repository.fail(first, ' safe\nretry\tmessage ', advanceDate(now, 1))).toBe( - 'pending', - ); - expect( - Option.getOrNull( - yield* repository.claimNext([registration], 'runtime-b', advanceDate(now, 999)), - ), - ).toBe(null); - const second = Option.getOrThrow( - yield* repository.claimNext([registration], 'runtime-b', advanceDate(now, 1001)), - ); - expect(second).toBeDefined(); - expect(yield* repository.fail(second, 'terminal safe failure', advanceDate(now, 1002))).toBe( - 'dead', - ); - const [delivery] = yield* database - .select() - .from(outboxDeliveries) - .where(eq(outboxDeliveries.outboxDeliveryId, second.deliveryId)); - expect(delivery?.status).toBe('dead'); - expect(delivery?.attemptsCount).toBe(2); - const attempts = yield* database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, second.deliveryId)) - .orderBy(asc(outboxAttempts.startedAt)); - expect(attempts.map(({ errorMessage }) => errorMessage)).toEqual([ - 'safe retry message', - 'terminal safe failure', - ]); - expect( - yield* database - .select() - .from(workerCheckpoints) - .where(eq(workerCheckpoints.tenantId, tenantId)), - ).toEqual([]); - }), +it.live('schedules bounded retry, dead-letters exhaustion, stores safe errors, and never checkpoints failure', () => + Effect.gen(function* schedulesBoundedRetryDeadlettersExhaustion() { + const { database, tenantId } = yield* tenantFixture; + const { now, registration, repository } = yield* matchedWorker(database, tenantId, 'consumer.retry-proof', { + maxAttempts: 2, + }); + const first = Option.getOrThrow(yield* repository.claimNext([registration], 'runtime-a', now)); + expect(first).toBeDefined(); + expect(yield* repository.fail(first, ' safe\nretry\tmessage ', advanceDate(now, 1))).toBe('pending'); + expect(Option.getOrNull(yield* repository.claimNext([registration], 'runtime-b', advanceDate(now, 999)))).toBe( + null, + ); + const second = Option.getOrThrow(yield* repository.claimNext([registration], 'runtime-b', advanceDate(now, 1001))); + expect(second).toBeDefined(); + expect(yield* repository.fail(second, 'terminal safe failure', advanceDate(now, 1002))).toBe('dead'); + const [delivery] = yield* database + .select() + .from(outboxDeliveries) + .where(eq(outboxDeliveries.outboxDeliveryId, second.deliveryId)); + expect(delivery?.status).toBe('dead'); + expect(delivery?.attemptsCount).toBe(2); + const attempts = yield* database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, second.deliveryId)) + .orderBy(asc(outboxAttempts.startedAt)); + expect(attempts.map(({ errorMessage }) => errorMessage)).toEqual(['safe retry message', 'terminal safe failure']); + expect(yield* database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId))).toEqual( + [], + ); + }), ); it('keeps test descriptor arrays compatible with the erased startup registry surface', () => { const registry: readonly AnyOutboxWorkerRegistration[] = [makeWorker('consumer.registry-proof')]; diff --git a/app/packages/core-runtime/tests/integration/pool-deadlines.test.ts b/app/packages/core-runtime/tests/integration/pool-deadlines.test.ts index e74342f70..13a7477e3 100644 --- a/app/packages/core-runtime/tests/integration/pool-deadlines.test.ts +++ b/app/packages/core-runtime/tests/integration/pool-deadlines.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Redacted } from 'effect'; +import { expect, it } from 'effect-rstest'; import { Pool } from 'pg'; import type { PoolClient } from 'pg'; + import { loadDatabaseConfig } from '../../src/db/config.ts'; import { configureDatabasePool } from '../../src/db/pool-configuration.ts'; @@ -14,10 +15,10 @@ const rollbackAndRelease = (client: PoolClient) => it.live('applies PostgreSQL pool connection and statement deadlines', () => Effect.gen(function* poolDeadlines1() { const databaseConfiguration = yield* loadDatabaseConfig(); - const poolConfiguration = yield* configureDatabasePool( - Redacted.make(databaseConfiguration.connectionString), - { connectionTimeoutMillis: 200, statement_timeout: 120 }, - ); + const poolConfiguration = yield* configureDatabasePool(Redacted.make(databaseConfiguration.connectionString), { + connectionTimeoutMillis: 200, + statement_timeout: 120, + }); const acquirePool = Effect.acquireRelease( Effect.sync(() => new Pool({ ...poolConfiguration, max: 1 })), (resource) => Effect.promise(() => resource.end()).pipe(Effect.orDie), @@ -36,20 +37,14 @@ it.live('applies PostgreSQL pool connection and statement deadlines', () => ); expect(settings.rows[0]?.statement_timeout).toBe('120ms'); - const identity = yield* Effect.promise(() => - client.query<{ current_user: string }>('select current_user'), - ); + const identity = yield* Effect.promise(() => client.query<{ current_user: string }>('select current_user')); expect(identity.rows[0]?.current_user).toBe(databaseConfiguration.user); - const pidResult = yield* Effect.promise(() => - client.query<{ pid: number }>('select pg_backend_pid() as pid'), - ); + const pidResult = yield* Effect.promise(() => client.query<{ pid: number }>('select pg_backend_pid() as pid')); const pid = pidResult.rows[0]?.pid; expect(pid !== undefined).toBe(true); - const cancellation = yield* Effect.flip( - Effect.tryPromise(() => client.query('select pg_sleep(1)')), - ); + const cancellation = yield* Effect.flip(Effect.tryPromise(() => client.query('select pg_sleep(1)'))); expect(cancellation.cause).toMatchObject({ code: '57014' }); const afterCancellation = yield* Effect.promise(() => @@ -59,7 +54,9 @@ it.live('applies PostgreSQL pool connection and statement deadlines', () => expect(afterCancellation.rows[0]?.ok).toBe(1); const timeout = yield* Effect.flip(Effect.tryPromise(() => pool.connect())); - expect(timeout.cause).toMatchObject({ message: expect.stringMatching(/timeout/iu) }); + expect(timeout.cause).toMatchObject({ + message: expect.stringMatching(/timeout/iu), + }); }), ); diff --git a/app/packages/core-runtime/tests/integration/principal-management.test.ts b/app/packages/core-runtime/tests/integration/principal-management.test.ts index 5ebd125b5..70bee9ea8 100644 --- a/app/packages/core-runtime/tests/integration/principal-management.test.ts +++ b/app/packages/core-runtime/tests/integration/principal-management.test.ts @@ -1,10 +1,10 @@ -import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { Effect, Predicate, Schema } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; import { Pool } from 'pg'; + import { bindApiKey, createNonHumanPrincipal, @@ -15,63 +15,65 @@ import { import { loadDatabaseConfig } from '../../src/db/config.ts'; import { coreRelations, principalAuthBindings, principals, tenants } from '../../src/db/schema.ts'; import { makeTestDatabaseFromPool } from '../support/database.ts'; +import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; -it.live( - 'persists managed key lifecycle without credential material and enforces global key cardinality', - () => - Effect.gen(function* principalManagement1() { - const tenantId = randomUUID(); - const providerKeyId = `better-auth-principal-management-${randomUUID()}`; - const configuration = yield* loadDatabaseConfig(); - const pool = yield* Effect.acquireRelease( - Effect.sync(() => new Pool({ connectionString: configuration.connectionString })), - (ownedPool) => Effect.promise(() => ownedPool.end()).pipe(Effect.orDie), - ); - const database = yield* makeTestDatabaseFromPool(pool, coreRelations); - const cleanup = purgeFixtureRows([ - database - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.providerSubjectId, providerKeyId)), - database.delete(principals).where(eq(principals.tenantId, tenantId)), - database.delete(tenants).where(eq(tenants.tenantId, tenantId)), - ]); +it.live('persists managed key lifecycle without credential material and enforces global key cardinality', () => + Effect.gen(function* principalManagement1() { + const tenantId = randomUUID(); + const providerKeyId = `better-auth-principal-management-${randomUUID()}`; + const configuration = yield* loadDatabaseConfig(); + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: configuration.connectionString })), + (ownedPool) => Effect.promise(() => ownedPool.end()).pipe(Effect.orDie), + ); + const database = yield* makeTestDatabaseFromPool(pool, coreRelations); + const cleanup = purgeFixtureRows([ + database.delete(principalAuthBindings).where(eq(principalAuthBindings.providerSubjectId, providerKeyId)), + database.delete(principals).where(eq(principals.tenantId, tenantId)), + database.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]); - yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); - yield* database.insert(tenants).values({ - defaultLocale: 'en', - name: 'Principal management integration', - slug: `principal-management-${tenantId}`, - status: 'active', + yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); + yield* database.insert(tenants).values({ + defaultLocale: 'en', + name: 'Principal management integration', + slug: `principal-management-${tenantId}`, + status: 'active', + tenantId, + }); + const first = yield* database.transaction((transaction) => + createNonHumanPrincipal({ + displayName: 'Managed integration', + kind: 'integration', tenantId, - }); - const first = yield* database.transaction((transaction) => - createNonHumanPrincipal({ - displayName: 'Managed integration', - kind: 'integration', - tenantId, - }).pipe( - Effect.provideService( - PrincipalManagementRepository, - principalManagementRepositoryFromTransaction(transaction), - ), - ), - ); - const second = yield* database.transaction((transaction) => - createNonHumanPrincipal({ - displayName: 'Managed service', - kind: 'service', - tenantId, - }).pipe( - Effect.provideService( - PrincipalManagementRepository, - principalManagementRepositoryFromTransaction(transaction), - ), - ), - ); - const binding = yield* database.transaction((transaction) => + }).pipe( + Effect.provideService(PrincipalManagementRepository, principalManagementRepositoryFromTransaction(transaction)), + ), + ); + const second = yield* database.transaction((transaction) => + createNonHumanPrincipal({ + displayName: 'Managed service', + kind: 'service', + tenantId, + }).pipe( + Effect.provideService(PrincipalManagementRepository, principalManagementRepositoryFromTransaction(transaction)), + ), + ); + const binding = yield* database.transaction((transaction) => + bindApiKey({ + managed: true, + principalId: first.principalId, + providerSubjectId: providerKeyId, + tenantId, + }).pipe( + Effect.provideService(PrincipalManagementRepository, principalManagementRepositoryFromTransaction(transaction)), + ), + ); + const duplicate = yield* database.transaction((transaction) => + Effect.flip( bindApiKey({ managed: true, - principalId: first.principalId, + principalId: second.principalId, providerSubjectId: providerKeyId, tenantId, }).pipe( @@ -80,51 +82,18 @@ it.live( principalManagementRepositoryFromTransaction(transaction), ), ), - ); - const duplicate = yield* database.transaction((transaction) => - Effect.flip( - bindApiKey({ - managed: true, - principalId: second.principalId, - providerSubjectId: providerKeyId, - tenantId, - }).pipe( - Effect.provideService( - PrincipalManagementRepository, - principalManagementRepositoryFromTransaction(transaction), - ), - ), - ), - ); - expect(Predicate.isTagged(duplicate, 'IdentityLifecycleConflictError')).toBe(true); - - const missingReason = yield* database.transaction((transaction) => - Effect.flip( - setApiKeyBindingStatus({ - authBindingId: binding.authBindingId, - expectedStatus: 'active', - managed: true, - newStatus: 'revoked', - principalId: first.principalId, - tenantId, - }).pipe( - Effect.provideService( - PrincipalManagementRepository, - principalManagementRepositoryFromTransaction(transaction), - ), - ), - ), - ); - expect(Predicate.isTagged(missingReason, 'IdentityTargetInvalidError')).toBe(true); + ), + ); + expect(Predicate.isTagged(duplicate, 'IdentityLifecycleConflictError')).toBe(true); - yield* database.transaction((transaction) => + const missingReason = yield* database.transaction((transaction) => + Effect.flip( setApiKeyBindingStatus({ authBindingId: binding.authBindingId, expectedStatus: 'active', managed: true, newStatus: 'revoked', principalId: first.principalId, - reason: 'Integration lifecycle proof', tenantId, }).pipe( Effect.provideService( @@ -132,16 +101,28 @@ it.live( principalManagementRepositoryFromTransaction(transaction), ), ), - ); - const [stored] = yield* database - .select() - .from(principalAuthBindings) - .where(eq(principalAuthBindings.principalAuthBindingId, binding.authBindingId)); - expect(stored?.status).toBe('revoked'); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(stored)).includes( - 'secret', - ), - ).toBe(false); - }), + ), + ); + expect(Predicate.isTagged(missingReason, 'IdentityTargetInvalidError')).toBe(true); + + yield* database.transaction((transaction) => + setApiKeyBindingStatus({ + authBindingId: binding.authBindingId, + expectedStatus: 'active', + managed: true, + newStatus: 'revoked', + principalId: first.principalId, + reason: 'Integration lifecycle proof', + tenantId, + }).pipe( + Effect.provideService(PrincipalManagementRepository, principalManagementRepositoryFromTransaction(transaction)), + ), + ); + const [stored] = yield* database + .select() + .from(principalAuthBindings) + .where(eq(principalAuthBindings.principalAuthBindingId, binding.authBindingId)); + expect(stored?.status).toBe('revoked'); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(stored)).includes('secret')).toBe(false); + }), ); diff --git a/app/packages/core-runtime/tests/integration/principal-resolver.test.ts b/app/packages/core-runtime/tests/integration/principal-resolver.test.ts index 063873767..315e9d9e1 100644 --- a/app/packages/core-runtime/tests/integration/principal-resolver.test.ts +++ b/app/packages/core-runtime/tests/integration/principal-resolver.test.ts @@ -1,11 +1,11 @@ -import { expect, it } from 'effect-rstest'; - import { and, eq } from 'drizzle-orm'; import { DateTime, Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makePrincipalResolver } from '../../src/auth/principal-resolver.ts'; +import { makeCoreDatabase } from '../../src/db/client.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { principalAuthBindings, principals, tenants } from '../../src/db/schema.ts'; -import { makeCoreDatabase } from '../../src/db/client.ts'; const tenantOne = '10000000-0000-4000-8000-000000000001'; const tenantTwo = '10000000-0000-4000-8000-000000000002'; @@ -13,131 +13,112 @@ const principalOne = '20000000-0000-4000-8000-000000000001'; const principalTwo = '20000000-0000-4000-8000-000000000002'; const subject = 'better-auth-integration-subject'; -it.live( - 'lists and selects multiple tenant-scoped principals and fails closed after access changes', - () => - Effect.gen(function* principalResolverIntegration() { - const configuration = yield* loadDatabaseConfig(); - const { executor: database } = yield* makeCoreDatabase(configuration); - const resolver = makePrincipalResolver({ executor: database }); - const cleanup = Effect.gen(function* cleanPrincipalResolverFixtures() { - yield* database - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.providerSubjectId, subject)); - yield* database - .delete(principals) - .where(and(eq(principals.principalId, principalOne), eq(principals.tenantId, tenantOne))); - yield* database - .delete(principals) - .where(and(eq(principals.principalId, principalTwo), eq(principals.tenantId, tenantTwo))); - yield* database.delete(tenants).where(eq(tenants.tenantId, tenantOne)); - yield* database.delete(tenants).where(eq(tenants.tenantId, tenantTwo)); - }); +it.live('lists and selects multiple tenant-scoped principals and fails closed after access changes', () => + Effect.gen(function* principalResolverIntegration() { + const configuration = yield* loadDatabaseConfig(); + const { executor: database } = yield* makeCoreDatabase(configuration); + const resolver = makePrincipalResolver({ executor: database }); + const cleanup = Effect.gen(function* cleanPrincipalResolverFixtures() { + yield* database.delete(principalAuthBindings).where(eq(principalAuthBindings.providerSubjectId, subject)); + yield* database + .delete(principals) + .where(and(eq(principals.principalId, principalOne), eq(principals.tenantId, tenantOne))); + yield* database + .delete(principals) + .where(and(eq(principals.principalId, principalTwo), eq(principals.tenantId, tenantTwo))); + yield* database.delete(tenants).where(eq(tenants.tenantId, tenantOne)); + yield* database.delete(tenants).where(eq(tenants.tenantId, tenantTwo)); + }); - yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); - yield* database.insert(tenants).values([ - { - defaultLocale: 'en', - name: 'Resolver tenant one', - slug: 'resolver-tenant-one', - status: 'active', - tenantId: tenantOne, - }, - { - defaultLocale: 'en', - name: 'Resolver tenant two', - slug: 'resolver-tenant-two', - status: 'active', - tenantId: tenantTwo, - }, - ]); - yield* database.insert(principals).values([ - { - displayName: 'Resolver principal one', - kind: 'human', - principalId: principalOne, - status: 'active', - tenantId: tenantOne, - }, - { - displayName: 'Resolver principal two', - kind: 'human', - principalId: principalTwo, - status: 'active', - tenantId: tenantTwo, - }, - ]); - yield* database.insert(principalAuthBindings).values([ - { - principalId: principalOne, - provider: 'better_auth', - providerSubjectId: subject, - status: 'active', - subjectType: 'user', - tenantId: tenantOne, - }, - { - principalId: principalTwo, - provider: 'better_auth', - providerSubjectId: subject, - status: 'active', - subjectType: 'user', - tenantId: tenantTwo, - }, - ]); + yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); + yield* database.insert(tenants).values([ + { + defaultLocale: 'en', + name: 'Resolver tenant one', + slug: 'resolver-tenant-one', + status: 'active', + tenantId: tenantOne, + }, + { + defaultLocale: 'en', + name: 'Resolver tenant two', + slug: 'resolver-tenant-two', + status: 'active', + tenantId: tenantTwo, + }, + ]); + yield* database.insert(principals).values([ + { + displayName: 'Resolver principal one', + kind: 'human', + principalId: principalOne, + status: 'active', + tenantId: tenantOne, + }, + { + displayName: 'Resolver principal two', + kind: 'human', + principalId: principalTwo, + status: 'active', + tenantId: tenantTwo, + }, + ]); + yield* database.insert(principalAuthBindings).values([ + { + principalId: principalOne, + provider: 'better_auth', + providerSubjectId: subject, + status: 'active', + subjectType: 'user', + tenantId: tenantOne, + }, + { + principalId: principalTwo, + provider: 'better_auth', + providerSubjectId: subject, + status: 'active', + subjectType: 'user', + tenantId: tenantTwo, + }, + ]); - expect(yield* resolver.listAvailableTenants(subject)).toEqual([ - { name: 'Resolver tenant one', tenantId: tenantOne }, - { name: 'Resolver tenant two', tenantId: tenantTwo }, - ]); - const resolvedOne = yield* resolver.resolveBetterAuthUserForTenant(subject, tenantOne); - const resolvedTwo = yield* resolver.resolveBetterAuthUserForTenant(subject, tenantTwo); - expect(resolvedOne.principalId).toBe(principalOne); - expect(resolvedTwo.principalId).toBe(principalTwo); - const foreignResolution = yield* Effect.flip( - resolver.resolveBetterAuthUserForTenant('foreign-better-auth-subject', tenantOne), - ); - expect(Predicate.isTagged(foreignResolution, 'PrincipalBindingMissingError')).toBe(true); + expect(yield* resolver.listAvailableTenants(subject)).toEqual([ + { name: 'Resolver tenant one', tenantId: tenantOne }, + { name: 'Resolver tenant two', tenantId: tenantTwo }, + ]); + const resolvedOne = yield* resolver.resolveBetterAuthUserForTenant(subject, tenantOne); + const resolvedTwo = yield* resolver.resolveBetterAuthUserForTenant(subject, tenantTwo); + expect(resolvedOne.principalId).toBe(principalOne); + expect(resolvedTwo.principalId).toBe(principalTwo); + const foreignResolution = yield* Effect.flip( + resolver.resolveBetterAuthUserForTenant('foreign-better-auth-subject', tenantOne), + ); + expect(Predicate.isTagged(foreignResolution, 'PrincipalBindingMissingError')).toBe(true); - yield* database - .update(principalAuthBindings) - .set({ - revokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z')), - status: 'revoked', - }) - .where(eq(principalAuthBindings.tenantId, tenantOne)); - expect(yield* resolver.listAvailableTenants(subject)).toEqual([ - { name: 'Resolver tenant two', tenantId: tenantTwo }, - ]); - const revokedResolution = yield* Effect.flip( - resolver.resolveBetterAuthUserForTenant(subject, tenantOne), - ); - expect(Predicate.isTagged(revokedResolution, 'PrincipalBindingInactiveError')).toBe(true); + yield* database + .update(principalAuthBindings) + .set({ + revokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z')), + status: 'revoked', + }) + .where(eq(principalAuthBindings.tenantId, tenantOne)); + expect(yield* resolver.listAvailableTenants(subject)).toEqual([ + { name: 'Resolver tenant two', tenantId: tenantTwo }, + ]); + const revokedResolution = yield* Effect.flip(resolver.resolveBetterAuthUserForTenant(subject, tenantOne)); + expect(Predicate.isTagged(revokedResolution, 'PrincipalBindingInactiveError')).toBe(true); - yield* database - .update(principalAuthBindings) - .set({ revokedAt: null, status: 'active' }) - .where(eq(principalAuthBindings.tenantId, tenantOne)); - yield* database - .update(principals) - .set({ status: 'disabled' }) - .where(eq(principals.principalId, principalOne)); - const inactivePrincipal = yield* Effect.flip( - resolver.resolveBetterAuthUserForTenant(subject, tenantOne), - ); - expect(Predicate.isTagged(inactivePrincipal, 'PrincipalInactiveError')).toBe(true); + yield* database + .update(principalAuthBindings) + .set({ revokedAt: null, status: 'active' }) + .where(eq(principalAuthBindings.tenantId, tenantOne)); + yield* database.update(principals).set({ status: 'disabled' }).where(eq(principals.principalId, principalOne)); + const inactivePrincipal = yield* Effect.flip(resolver.resolveBetterAuthUserForTenant(subject, tenantOne)); + expect(Predicate.isTagged(inactivePrincipal, 'PrincipalInactiveError')).toBe(true); - yield* database - .update(principals) - .set({ status: 'active' }) - .where(eq(principals.principalId, principalOne)); - yield* database - .update(tenants) - .set({ status: 'suspended' }) - .where(eq(tenants.tenantId, tenantOne)); - const inactiveTenant = yield* Effect.flip( - resolver.resolveBetterAuthUserForTenant(subject, tenantOne), - ); - expect(Predicate.isTagged(inactiveTenant, 'TenantInactiveError')).toBe(true); - }), + yield* database.update(principals).set({ status: 'active' }).where(eq(principals.principalId, principalOne)); + yield* database.update(tenants).set({ status: 'suspended' }).where(eq(tenants.tenantId, tenantOne)); + const inactiveTenant = yield* Effect.flip(resolver.resolveBetterAuthUserForTenant(subject, tenantOne)); + expect(Predicate.isTagged(inactiveTenant, 'TenantInactiveError')).toBe(true); + }), ); diff --git a/app/packages/core-runtime/tests/integration/read-runtime.test.ts b/app/packages/core-runtime/tests/integration/read-runtime.test.ts index d04c687d7..d647cde5c 100644 --- a/app/packages/core-runtime/tests/integration/read-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/read-runtime.test.ts @@ -1,18 +1,13 @@ -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { getTableConfig } from 'drizzle-orm/pg-core'; import { Effect, Schema } from 'effect'; -import { randomUUID } from 'node:crypto'; -import { - makeSystemPrincipalContextResolver, - registerSystemWorkload, -} from '../../src/auth/system-principal-context.ts'; +import { expect, it } from 'effect-rstest'; + +import { makeSystemPrincipalContextResolver, registerSystemWorkload } from '../../src/auth/system-principal-context.ts'; import { coreRelations, dataAccessEvents } from '../../src/db/schema.ts'; import { defineSystemModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; -import { - makeOperationalScopeRepository, - makeOperationalScopeResolver, -} from '../../src/operations/context.ts'; +import { makeOperationalScopeRepository, makeOperationalScopeResolver } from '../../src/operations/context.ts'; import { defineRead } from '../../src/reads/definition.ts'; import { makeReadRuntime } from '../../src/reads/runtime.ts'; import { makeTestDatabaseFromPool, testDatabasePools } from '../support/database.ts'; @@ -40,12 +35,18 @@ it.live('commits live allowed evidence before releasing a governed read result', accessKind: 'list', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: readKey, moduleKey: 'core.shell', role: 'api', }), - evidencePolicy: { captureMode: 'metadata_only', policyKey: `${readKey}.v1` }, + evidencePolicy: { + captureMode: 'metadata_only', + policyKey: `${readKey}.v1`, + }, inputSchema: Schema.Struct({}), legalEntityScope: 'forbidden', owningModuleKey: 'core.shell', @@ -65,12 +66,8 @@ it.live('commits live allowed evidence before releasing a governed read result', yield* Effect.promise(() => admin.query('delete from core.data_access_events where tenant_id = $1', [tenantId]), ); - yield* Effect.promise(() => - admin.query('delete from core.principals where tenant_id = $1', [tenantId]), - ); - yield* Effect.promise(() => - admin.query('delete from core.tenants where tenant_id = $1', [tenantId]), - ); + yield* Effect.promise(() => admin.query('delete from core.principals where tenant_id = $1', [tenantId])); + yield* Effect.promise(() => admin.query('delete from core.tenants where tenant_id = $1', [tenantId])); }).pipe(Effect.orDie), ); @@ -96,17 +93,16 @@ it.live('commits live allowed evidence before releasing a governed read result', executor: runtimeDatabase, }).resolve({ principalId, - registration: registerSystemWorkload({ jobKey: 'read-runtime-integration' }), + registration: registerSystemWorkload({ + jobKey: 'read-runtime-integration', + }), runReference: readKey, tenantId, }); const runtime = makeReadRuntime( { executor: runtimeDatabase }, openModuleEntrypointGateway, - makeOperationalScopeResolver( - makeOperationalScopeRepository({ executor: runtimeDatabase }), - contextAccess, - ), + makeOperationalScopeResolver(makeOperationalScopeRepository({ executor: runtimeDatabase }), contextAccess), contextAccess, ); expect( diff --git a/app/packages/core-runtime/tests/integration/search-persistence.test.ts b/app/packages/core-runtime/tests/integration/search-persistence.test.ts index 41e5c2332..c38321d49 100644 --- a/app/packages/core-runtime/tests/integration/search-persistence.test.ts +++ b/app/packages/core-runtime/tests/integration/search-persistence.test.ts @@ -1,11 +1,12 @@ -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { Effect, Function as Fn, Schema, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; import type { Pool, QueryResult, QueryResultRow } from 'pg'; + import { coreRelations } from '../../src/db/schema.ts'; import { makePostgresCoreSearchProjectionStore } from '../../src/search/persistence.ts'; -import { createCoreSearchQueryRuntime } from '../../src/search/projection.ts'; +import { CoreSearchProjectionStore, createCoreSearchQueryRuntime } from '../../src/search/projection.ts'; import { makeTestDatabaseFromPool, testDatabasePools } from '../support/database.ts'; const queryEffect = ( @@ -13,257 +14,244 @@ const queryEffect = ( statement: string, parameters?: readonly unknown[], ): Effect.Effect> => - Effect.suspend(() => - Effect.promise(Fn.constant(client.query(statement, [...(parameters ?? [])]))), - ); + Effect.suspend(() => Effect.promise(Fn.constant(client.query(statement, [...(parameters ?? [])])))); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -it.live( - 'durably rebuilds tenant projections with tombstones and selected-Legal-Entity filtering', - () => - Effect.gen(function* searchPersistenceIntegration() { - const { admin, runtimePool } = yield* testDatabasePools; - const tenantId = randomUUID(); - const otherTenantId = randomUUID(); - const legalEntityId = randomUUID(); - const otherLegalEntityId = randomUUID(); - const partyId = randomUUID(); - const removedPartyId = randomUUID(); - const counterpartyId = randomUUID(); - const otherCounterpartyId = randomUUID(); - const aliasRef = { +it.live('durably rebuilds tenant projections with tombstones and selected-Legal-Entity filtering', () => + Effect.gen(function* searchPersistenceIntegration() { + const { admin, runtimePool } = yield* testDatabasePools; + const tenantId = randomUUID(); + const otherTenantId = randomUUID(); + const legalEntityId = randomUUID(); + const otherLegalEntityId = randomUUID(); + const partyId = randomUUID(); + const removedPartyId = randomUUID(); + const counterpartyId = randomUUID(); + const otherCounterpartyId = randomUUID(); + const aliasRef = { + moduleId: 'party.registry', + resourceId: randomUUID(), + resourceType: 'party.registry.party', + tenantId, + }; + const store = makePostgresCoreSearchProjectionStore({ + executor: yield* makeTestDatabaseFromPool(runtimePool, coreRelations), + }); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + const partyDocument = (resourceId: string, projectionVersion: string, title: string) => ({ + aliases: [ + { + kind: 'resource', + ref: aliasRef, + searchableText: ['Former Acme'], + temporalSearchableText: [ + { + validFrom: '2026-01-01T00:00:00Z', + validTo: '2026-02-01T00:00:00Z', + value: 'alias-private@example.test', + }, + ], + }, + ], + archived: false, + facets: [], + metadata: [], + projectionVersion, + ref: { moduleId: 'party.registry', - resourceId: randomUUID(), + resourceId, resourceType: 'party.registry.party', tenantId, - }; - const store = makePostgresCoreSearchProjectionStore({ - executor: yield* makeTestDatabaseFromPool(runtimePool, coreRelations), - }); - const search = createCoreSearchQueryRuntime(store); - const partyDocument = (resourceId: string, projectionVersion: string, title: string) => ({ - aliases: [ - { - kind: 'resource', - ref: aliasRef, - searchableText: ['Former Acme'], - temporalSearchableText: [ - { - validFrom: '2026-01-01T00:00:00Z', - validTo: '2026-02-01T00:00:00Z', - value: 'alias-private@example.test', - }, - ], - }, - ], - archived: false, - facets: [], - metadata: [], - projectionVersion, - ref: { - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.party', - tenantId, + }, + searchableText: [title, 'private@example.test'], + temporalSearchableText: [ + { + validFrom: '2026-02-01T00:00:00Z', + value: 'canonical-private@example.test', }, - searchableText: [title, 'private@example.test'], - temporalSearchableText: [ - { - validFrom: '2026-02-01T00:00:00Z', - value: 'canonical-private@example.test', - }, - ], - title, - }); - const counterpartyDocument = (resourceId: string, selectedLegalEntityId: string) => ({ - archived: false, - facets: [], - metadata: [], - projectionVersion: '1', - ref: { - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.counterparty', - tenantId, - }, - searchableText: ['Acme counterparty'], - selectedLegalEntityId, - title: 'Acme counterparty', - }); + ], + title, + }); + const counterpartyDocument = (resourceId: string, selectedLegalEntityId: string) => ({ + archived: false, + facets: [], + metadata: [], + projectionVersion: '1', + ref: { + moduleId: 'party.registry', + resourceId, + resourceType: 'party.registry.counterparty', + tenantId, + }, + searchableText: ['Acme counterparty'], + selectedLegalEntityId, + title: 'Acme counterparty', + }); - const cleanup = Effect.gen(function* cleanSearchPersistenceFixtures() { - yield* queryEffect(admin, `delete from core.search_index_entries where tenant_id = $1`, [ - tenantId, - ]); - yield* queryEffect( - admin, - `delete from core.search_projection_rebuilds where tenant_id = $1`, - [tenantId], - ); - yield* queryEffect(admin, `delete from core.legal_entities where tenant_id = $1`, [ - tenantId, - ]); - yield* queryEffect(admin, `delete from core.tenants where tenant_id in ($1, $2)`, [ - tenantId, - otherTenantId, - ]); - }).pipe(Effect.orDie); + const cleanup = Effect.gen(function* cleanSearchPersistenceFixtures() { + yield* queryEffect(admin, `delete from core.search_index_entries where tenant_id = $1`, [tenantId]); + yield* queryEffect(admin, `delete from core.search_projection_rebuilds where tenant_id = $1`, [tenantId]); + yield* queryEffect(admin, `delete from core.legal_entities where tenant_id = $1`, [tenantId]); + yield* queryEffect(admin, `delete from core.tenants where tenant_id in ($1, $2)`, [tenantId, otherTenantId]); + }).pipe(Effect.orDie); - yield* Effect.addFinalizer(() => cleanup); - yield* queryEffect( - admin, - `insert into core.tenants (tenant_id, slug, name, status, default_locale) values ($1, $2, 'Search tenant', 'active', 'en'), ($3, $4, 'Other tenant', 'active', 'en')`, - [tenantId, `search-${tenantId}`, otherTenantId, `search-${otherTenantId}`], - ); - yield* queryEffect( - admin, - `insert into core.legal_entities (legal_entity_id, tenant_id, legal_name, registration_country, registration_number, status) values ($1::uuid, $2, 'Search LE', 'CZ', $1::uuid::text, 'active'), ($3::uuid, $2, 'Other LE', 'CZ', $3::uuid::text, 'active')`, - [legalEntityId, tenantId, otherLegalEntityId], - ); + yield* Effect.addFinalizer(() => cleanup); + yield* queryEffect( + admin, + `insert into core.tenants (tenant_id, slug, name, status, default_locale) values ($1, $2, 'Search tenant', 'active', 'en'), ($3, $4, 'Other tenant', 'active', 'en')`, + [tenantId, `search-${tenantId}`, otherTenantId, `search-${otherTenantId}`], + ); + yield* queryEffect( + admin, + `insert into core.legal_entities (legal_entity_id, tenant_id, legal_name, registration_country, registration_number, status) values ($1::uuid, $2, 'Search LE', 'CZ', $1::uuid::text, 'active'), ($3::uuid, $2, 'Other LE', 'CZ', $3::uuid::text, 'active')`, + [legalEntityId, tenantId, otherLegalEntityId], + ); - yield* store.replace({ - documents: [ - partyDocument(partyId, '1', 'Acme'), - partyDocument(removedPartyId, '1', 'Remove me'), - ], - moduleId: 'party.registry', - rebuildVersion: '1', - resourceType: 'party.registry.party', - tenantId, - }); - yield* store.replace({ - documents: [partyDocument(partyId, '2', 'Acme current')], - moduleId: 'party.registry', - rebuildVersion: '2', - resourceType: 'party.registry.party', - tenantId, - }); - yield* store.apply({ - document: partyDocument(partyId, '1', 'Acme stale'), - kind: 'upsert', - }); - yield* store.apply({ - document: counterpartyDocument(counterpartyId, legalEntityId), - kind: 'upsert', - }); - yield* store.apply({ - document: counterpartyDocument(otherCounterpartyId, otherLegalEntityId), - kind: 'upsert', - }); + yield* store.replace({ + documents: [partyDocument(partyId, '1', 'Acme'), partyDocument(removedPartyId, '1', 'Remove me')], + moduleId: 'party.registry', + rebuildVersion: '1', + resourceType: 'party.registry.party', + tenantId, + }); + yield* store.replace({ + documents: [partyDocument(partyId, '2', 'Acme current')], + moduleId: 'party.registry', + rebuildVersion: '2', + resourceType: 'party.registry.party', + tenantId, + }); + yield* store.apply({ + document: partyDocument(partyId, '1', 'Acme stale'), + kind: 'upsert', + }); + yield* store.apply({ + document: counterpartyDocument(counterpartyId, legalEntityId), + kind: 'upsert', + }); + yield* store.apply({ + document: counterpartyDocument(otherCounterpartyId, otherLegalEntityId), + kind: 'upsert', + }); - const partyHits = yield* search.search({ + const partyHits = yield* search.search({ + includeArchived: false, + moduleId: 'party.registry', + query: 'private@example.test', + resourceType: 'party.registry.party', + tenantId, + }); + expect(partyHits.map(({ title }) => title)).toEqual(['Acme current']); + expect(yield* encodeJson(partyHits)).not.toMatch(/private@example\.test/u); + const evidenceSearch = (query: string, effectiveAt = '2026-02-01T00:00:00Z') => + search.search({ + effectiveAt, includeArchived: false, moduleId: 'party.registry', - query: 'private@example.test', + query, resourceType: 'party.registry.party', tenantId, }); - expect(partyHits.map(({ title }) => title)).toEqual(['Acme current']); - expect(yield* encodeJson(partyHits)).not.toMatch(/private@example\.test/u); - const evidenceSearch = (query: string, effectiveAt = '2026-02-01T00:00:00Z') => - search.search({ - effectiveAt, - includeArchived: false, - moduleId: 'party.registry', - query, - resourceType: 'party.registry.party', - tenantId, - }); - const aliasHits = yield* evidenceSearch('former'); - expect(aliasHits.length).toBe(1); - expect(aliasHits[0]?.matchedRef).toEqual(aliasRef); - const canonicalHits = yield* evidenceSearch('acme'); - expect(canonicalHits[0]?.matchedRef).toBe(undefined); - const historicalAliasHits = yield* evidenceSearch('alias-private', '2026-01-01T00:00:00Z'); - expect(historicalAliasHits[0]?.matchedRef).toEqual(aliasRef); - expect(yield* evidenceSearch('alias-private')).toEqual([]); - expect(yield* evidenceSearch('canonical-private', '2026-01-31T00:00:00Z')).toEqual([]); - const temporalHits = yield* evidenceSearch('canonical-private'); - expect(temporalHits.length).toBe(1); - expect(temporalHits[0]?.matchedRef).toBe(undefined); - expect(yield* encodeJson([aliasHits, temporalHits])).not.toMatch( - /private@example|searchableText|aliases/u, - ); - const floorRef = { ...aliasRef, resourceType: 'party.registry.floor-test' }; - const emptyRebuild = { - documents: [], + const aliasHits = yield* evidenceSearch('former'); + expect(aliasHits.length).toBe(1); + expect(aliasHits[0]?.matchedRef).toEqual(aliasRef); + const canonicalHits = yield* evidenceSearch('acme'); + expect(canonicalHits[0]?.matchedRef).toBe(undefined); + const historicalAliasHits = yield* evidenceSearch('alias-private', '2026-01-01T00:00:00Z'); + expect(historicalAliasHits[0]?.matchedRef).toEqual(aliasRef); + expect(yield* evidenceSearch('alias-private')).toEqual([]); + expect(yield* evidenceSearch('canonical-private', '2026-01-31T00:00:00Z')).toEqual([]); + const temporalHits = yield* evidenceSearch('canonical-private'); + expect(temporalHits.length).toBe(1); + expect(temporalHits[0]?.matchedRef).toBe(undefined); + expect(yield* encodeJson([aliasHits, temporalHits])).not.toMatch(/private@example|searchableText|aliases/u); + const floorRef = { + ...aliasRef, + resourceType: 'party.registry.floor-test', + }; + const emptyRebuild = { + documents: [], + moduleId: floorRef.moduleId, + rebuildVersion: '2', + resourceType: floorRef.resourceType, + tenantId, + }; + const staleDocument = { + ...partyDocument(floorRef.resourceId, '1', 'Unseen resource'), + ref: floorRef, + }; + yield* store.replace(emptyRebuild); + // A fresh service instance must observe the durable floor, not process-local state. + const restarted = makePostgresCoreSearchProjectionStore({ + executor: yield* makeTestDatabaseFromPool(runtimePool, coreRelations), + }); + const restartedSearch = yield* createCoreSearchQueryRuntime.pipe( + Effect.provideService(CoreSearchProjectionStore, restarted), + ); + const floorSearch = () => + restartedSearch.search({ + includeArchived: false, moduleId: floorRef.moduleId, - rebuildVersion: '2', + query: 'unseen', resourceType: floorRef.resourceType, tenantId, - }; - const staleDocument = { - ...partyDocument(floorRef.resourceId, '1', 'Unseen resource'), - ref: floorRef, - }; - yield* store.replace(emptyRebuild); - // A fresh service instance must observe the durable floor, not process-local state. - const restarted = makePostgresCoreSearchProjectionStore({ - executor: yield* makeTestDatabaseFromPool(runtimePool, coreRelations), }); - const floorSearch = () => - createCoreSearchQueryRuntime(restarted).search({ - includeArchived: false, - moduleId: floorRef.moduleId, - query: 'unseen', - resourceType: floorRef.resourceType, - tenantId, - }); - yield* restarted.apply({ document: staleDocument, kind: 'upsert' }); - yield* restarted.replace({ - ...emptyRebuild, - documents: [staleDocument], - rebuildVersion: '1', - }); - expect(yield* floorSearch()).toEqual([]); - yield* restarted.replace(emptyRebuild); - const divergence = yield* Effect.flip( - restarted.replace({ ...emptyRebuild, documents: [staleDocument] }), - ); - expect(Predicate.isTagged(divergence, 'CoreSearchProjectionInvalid')).toBe(true); - yield* restarted.apply({ - document: { ...staleDocument, projectionVersion: '3' }, - kind: 'upsert', - }); - yield* restarted.replace(emptyRebuild); - const rebuiltFloorHits = yield* floorSearch(); - expect(rebuiltFloorHits.length).toBe(1); - const rebuildRows = yield* queryEffect( - runtimePool, - `select rebuild_version from core.search_projection_rebuilds where tenant_id = $1`, - [tenantId], - ); - expect(rebuildRows.rowCount).toBe(0); - const counterpartyHits = yield* search.search({ + yield* restarted.apply({ document: staleDocument, kind: 'upsert' }); + yield* restarted.replace({ + ...emptyRebuild, + documents: [staleDocument], + rebuildVersion: '1', + }); + expect(yield* floorSearch()).toEqual([]); + yield* restarted.replace(emptyRebuild); + const divergence = yield* Effect.flip(restarted.replace({ ...emptyRebuild, documents: [staleDocument] })); + expect(Predicate.isTagged(divergence, 'CoreSearchProjectionInvalid')).toBe(true); + yield* restarted.apply({ + document: { ...staleDocument, projectionVersion: '3' }, + kind: 'upsert', + }); + yield* restarted.replace(emptyRebuild); + const rebuiltFloorHits = yield* floorSearch(); + expect(rebuiltFloorHits.length).toBe(1); + const rebuildRows = yield* queryEffect( + runtimePool, + `select rebuild_version from core.search_projection_rebuilds where tenant_id = $1`, + [tenantId], + ); + expect(rebuildRows.rowCount).toBe(0); + const counterpartyHits = yield* search.search({ + includeArchived: false, + moduleId: 'party.registry', + query: 'acme', + resourceType: 'party.registry.counterparty', + selectedLegalEntityId: legalEntityId, + tenantId, + }); + expect(counterpartyHits.map(({ ref }) => ref.resourceId)).toEqual([counterpartyId]); + expect( + yield* search.search({ includeArchived: false, moduleId: 'party.registry', query: 'acme', resourceType: 'party.registry.counterparty', - selectedLegalEntityId: legalEntityId, tenantId, - }); - expect(counterpartyHits.map(({ ref }) => ref.resourceId)).toEqual([counterpartyId]); - expect( - yield* search.search({ - includeArchived: false, - moduleId: 'party.registry', - query: 'acme', - resourceType: 'party.registry.counterparty', - tenantId, - }), - ).toEqual([]); + }), + ).toEqual([]); - const runtimeRows = yield* queryEffect( - runtimePool, - `select source_resource_id from core.search_index_entries where tenant_id = $1`, - [tenantId], - ); - expect(runtimeRows.rowCount).toBe(0); - const stored = yield* queryEffect<{ deleted: boolean; projection_version: string }>( - admin, - `select deleted, projection_version::text from core.search_index_entries where tenant_id = $1 and source_resource_id = $2`, - [tenantId, removedPartyId], - ); - expect(stored.rows).toEqual([{ deleted: true, projection_version: '2' }]); - }), + const runtimeRows = yield* queryEffect( + runtimePool, + `select source_resource_id from core.search_index_entries where tenant_id = $1`, + [tenantId], + ); + expect(runtimeRows.rowCount).toBe(0); + const stored = yield* queryEffect<{ + deleted: boolean; + projection_version: string; + }>( + admin, + `select deleted, projection_version::text from core.search_index_entries where tenant_id = $1 and source_resource_id = $2`, + [tenantId, removedPartyId], + ); + expect(stored.rows).toEqual([{ deleted: true, projection_version: '2' }]); + }), ); diff --git a/app/packages/core-runtime/tests/integration/search-worker-snapshot.test.ts b/app/packages/core-runtime/tests/integration/search-worker-snapshot.test.ts index 5e9d13cd9..b9472b5b6 100644 --- a/app/packages/core-runtime/tests/integration/search-worker-snapshot.test.ts +++ b/app/packages/core-runtime/tests/integration/search-worker-snapshot.test.ts @@ -1,10 +1,10 @@ -import { expect, it } from 'effect-rstest'; - import { NodeServices } from '@effect/platform-node'; import { eq, sql } from 'drizzle-orm'; import { Cause, Crypto, Deferred, Effect, Fiber, Option } from 'effect'; +import { expect, it } from 'effect-rstest'; import type { PoolClient } from 'pg'; import { Pool } from 'pg'; + import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; import { coreRelations, domainEvents } from '../../src/db/schema.ts'; import type { OutboxWorkerHandlerContext } from '../../src/outbox/definition.ts'; @@ -32,14 +32,13 @@ const readLegalEntitySettings = (executor: CoreSearchSnapshotReadExecutor, event const readTenantMaxVersion = (executor: CoreSearchSnapshotReadExecutor, tenantId: string) => executor - .select({ version: sql`max(${domainEvents.tenantSequenceNo})::text` }) + .select({ + version: sql`max(${domainEvents.tenantSequenceNo})::text`, + }) .from(domainEvents) .where(eq(domainEvents.tenantId, tenantId)); -const readSnapshotPosition = ( - source: CoreSearchWorkerSnapshotService, - context: OutboxWorkerHandlerContext, -) => +const readSnapshotPosition = (source: CoreSearchWorkerSnapshotService, context: OutboxWorkerHandlerContext) => source.read(context, (snapshot) => Effect.succeed({ eventWatermark: snapshot.eventWatermark, @@ -61,12 +60,7 @@ const commitTransaction = (client: PoolClient) => try: () => client.query('commit'), }); -const insertPendingEvent = ( - client: PoolClient, - pendingEventId: string, - tenantId: string, - pendingSubjectId: string, -) => +const insertPendingEvent = (client: PoolClient, pendingEventId: string, tenantId: string, pendingSubjectId: string) => Effect.tryPromise({ catch: (cause) => new Cause.UnknownError(cause), @@ -84,7 +78,9 @@ const workerSnapshotProgram = Effect.gen(function* workerSnapshotIntegration() { [crypto.randomUUIDv4, crypto.randomUUIDv4, crypto.randomUUIDv4], { concurrency: 'unbounded' }, ); - const admin = new Pool({ connectionString: connections.admin.connectionString }); + const admin = new Pool({ + connectionString: connections.admin.connectionString, + }); const applicationName = `core-search-snapshot-${tenantId}`; const runtimePool = new Pool({ application_name: applicationName, @@ -115,10 +111,7 @@ const workerSnapshotProgram = Effect.gen(function* workerSnapshotIntegration() { yield* Effect.tryPromise({ catch: (cause) => new Cause.UnknownError(cause), - try: () => - admin.query('delete from core.search_projection_generations where tenant_id = $1', [ - tenantId, - ]), + try: () => admin.query('delete from core.search_projection_generations where tenant_id = $1', [tenantId]), }); yield* Effect.tryPromise({ catch: (cause) => new Cause.UnknownError(cause), @@ -189,10 +182,8 @@ const workerSnapshotProgram = Effect.gen(function* workerSnapshotIntegration() { topic: 'party.registry.party-updated.v1', workerKey: 'party.registry.project-party-updated-to-search', }); - const readEventSettings = (executor: CoreSearchSnapshotReadExecutor) => - readLegalEntitySettings(executor, eventId); - const readMaxTenantVersion = (executor: CoreSearchSnapshotReadExecutor) => - readTenantMaxVersion(executor, tenantId); + const readEventSettings = (executor: CoreSearchSnapshotReadExecutor) => readLegalEntitySettings(executor, eventId); + const readMaxTenantVersion = (executor: CoreSearchSnapshotReadExecutor) => readTenantMaxVersion(executor, tenantId); let newerVersion = ''; const result = yield* source.read(context, (snapshot) => Effect.gen(function* inspectSnapshot() { @@ -214,11 +205,12 @@ const workerSnapshotProgram = Effect.gen(function* workerSnapshotIntegration() { }, ]); expect(result.version).toBe(originalVersion); - expect( - yield* source.read(context, (snapshot) => Effect.succeed(snapshot.projectionVersion)), - ).toBe('2'); + expect(yield* source.read(context, (snapshot) => Effect.succeed(snapshot.projectionVersion))).toBe('2'); const nextSnapshot = yield* readSnapshotPosition(source, context); - expect(nextSnapshot).toEqual({ eventWatermark: newerVersion, generation: '3' }); + expect(nextSnapshot).toEqual({ + eventWatermark: newerVersion, + generation: '3', + }); // A second snapshot starts while the first owns the generation row. It must // retry its old RR snapshot after the first commits, never publish stale data @@ -273,8 +265,14 @@ const workerSnapshotProgram = Effect.gen(function* workerSnapshotIntegration() { const [firstResult, secondResult] = yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: 'unbounded', }); - expect(firstResult).toEqual({ eventWatermark: newerVersion, generation: '4' }); - expect(secondResult).toEqual({ eventWatermark: latestEvent, generation: '5' }); + expect(firstResult).toEqual({ + eventWatermark: newerVersion, + generation: '4', + }); + expect(secondResult).toEqual({ + eventWatermark: latestEvent, + generation: '5', + }); // Business transactions may commit event allocation sequences out of order. // Both snapshots below have the same event max but must get new generations. diff --git a/app/packages/core-runtime/tests/integration/tenant-isolation.test.ts b/app/packages/core-runtime/tests/integration/tenant-isolation.test.ts index af969bd7d..a32562ac8 100644 --- a/app/packages/core-runtime/tests/integration/tenant-isolation.test.ts +++ b/app/packages/core-runtime/tests/integration/tenant-isolation.test.ts @@ -1,10 +1,11 @@ -import { expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { getTableConfig, pgSchema, text, uuid } from 'drizzle-orm/pg-core'; import { Effect, Option, Schema } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { expect, it } from 'effect-rstest'; import type { PoolClient, QueryResult, QueryResultRow } from 'pg'; import { Pool } from 'pg'; + import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; import { actionInvocations, @@ -23,10 +24,7 @@ import { tenantModuleStateChanges, } from '../../src/db/schema.ts'; import { defineSystemModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; -import { - makeOperationalScopeRepository, - makeOperationalScopeResolver, -} from '../../src/operations/context.ts'; +import { makeOperationalScopeRepository, makeOperationalScopeResolver } from '../../src/operations/context.ts'; import type { ReadHandlerContext } from '../../src/reads/context.ts'; import { defineRead } from '../../src/reads/definition.ts'; import { makeReadRuntime } from '../../src/reads/runtime.ts'; @@ -37,17 +35,15 @@ type DatabaseQueryFailureSelf = typeof DatabaseQueryFailureContract.Type; const DatabaseQueryFailureContract = Schema.TaggedStruct('DatabaseQueryFailure', { code: Schema.String, }); -const DatabaseQueryFailure = Schema.TaggedError()( - 'DatabaseQueryFailure', - { code: Schema.String }, -); +const DatabaseQueryFailure = Schema.TaggedError()('DatabaseQueryFailure', { + code: Schema.String, +}); const DatabaseErrorCode = Schema.Struct({ code: Schema.String }); const queryEffect = ( client: Pool | PoolClient, statement: string, parameters?: readonly unknown[], -): Effect.Effect> => - Effect.promise(() => client.query(statement, [...(parameters ?? [])])); +): Effect.Effect> => Effect.promise(() => client.query(statement, [...(parameters ?? [])])); const queryTryEffect = ( client: Pool | PoolClient, statement: string, @@ -103,9 +99,7 @@ it('declares the composite same-tenant parent keys used by isolation foreign key .foreignKeys.map((foreignKey) => foreignKey.reference().columns.map((column) => column.name)) .filter((columns) => columns.some((column) => column !== 'tenant_id')); expect(businessReferences.length > 0).toBe(true); - expect( - businessReferences.every((columns) => columns.length === 2 && columns[0] === 'tenant_id'), - ).toBe(true); + expect(businessReferences.every((columns) => columns.length === 2 && columns[0] === 'tenant_id')).toBe(true); } }); @@ -118,7 +112,11 @@ it.live('runtime RLS isolates tenant and legal-entity rows and never leaks trans ); const runtime = yield* Effect.acquireRelease( Effect.sync( - () => new Pool({ connectionString: connections.runtime.connectionString, max: 1 }), + () => + new Pool({ + connectionString: connections.runtime.connectionString, + max: 1, + }), ), (pool) => Effect.promise(() => pool.end()), ); @@ -164,10 +162,7 @@ it.live('runtime RLS isolates tenant and legal-entity rows and never leaks trans `create policy records_delete on ${schema}.records for delete to ontos_runtime using (${predicate})`, ); yield* queryEffect(admin, `grant usage on schema ${schema} to ontos_runtime`); - yield* queryEffect( - admin, - `grant select, insert, update, delete on ${schema}.records to ontos_runtime`, - ); + yield* queryEffect(admin, `grant select, insert, update, delete on ${schema}.records to ontos_runtime`); yield* queryEffect( admin, `insert into ${schema}.records (tenant_id, legal_entity_id, resource_id, value) values ($1, $2, $4, 'entity-a'), ($1, $3, $4, 'entity-b'), ($5, $6, $4, 'tenant-b')`, @@ -205,20 +200,14 @@ it.live('runtime RLS isolates tenant and legal-entity rows and never leaks trans "select set_config('ontos.tenant_id', $1, true), set_config('ontos.legal_entity_id', $2, true)", [tenantA, entityA], ); - const entityARows = yield* queryEffect<{ value: string }>( - client, - `select value from ${schema}.records`, - ); + const entityARows = yield* queryEffect<{ value: string }>(client, `select value from ${schema}.records`); expect(entityARows.rows).toEqual([{ value: 'entity-a' }]); const foreignUpdate = yield* queryEffect( client, `update ${schema}.records set value = 'hacked' where value = 'tenant-b'`, ); expect(foreignUpdate.rowCount).toBe(0); - const foreignDelete = yield* queryEffect( - client, - `delete from ${schema}.records where value = 'entity-b'`, - ); + const foreignDelete = yield* queryEffect(client, `delete from ${schema}.records where value = 'entity-b'`); expect(foreignDelete.rowCount).toBe(0); const forbiddenInsert = yield* Effect.flip( queryTryEffect( @@ -236,10 +225,7 @@ it.live('runtime RLS isolates tenant and legal-entity rows and never leaks trans "select set_config('ontos.tenant_id', $1, true), set_config('ontos.legal_entity_id', $2, true)", [tenantA, entityB], ); - const entityBRows = yield* queryEffect<{ value: string }>( - client, - `select value from ${schema}.records`, - ); + const entityBRows = yield* queryEffect<{ value: string }>(client, `select value from ${schema}.records`); expect(entityBRows.rows).toEqual([{ value: 'entity-b' }]); yield* queryEffect(client, 'commit'); }).pipe(Effect.ensuring(Effect.sync(() => client.release()))); @@ -250,11 +236,7 @@ it.live('runtime RLS isolates tenant and legal-entity rows and never leaks trans admin, `select value from ${schema}.records order by value`, ); - expect(protectedRows.rows).toEqual([ - { value: 'entity-a' }, - { value: 'entity-b' }, - { value: 'tenant-b' }, - ]); + expect(protectedRows.rows).toEqual([{ value: 'entity-a' }, { value: 'entity-b' }, { value: 'tenant-b' }]); }); const release = queryEffect(admin, `drop schema if exists ${schema} cascade`); yield* exercise.pipe(Effect.ensuring(release)); @@ -294,7 +276,10 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr const predicate = `tenant_id = nullif(current_setting('ontos.tenant_id', true), '')::uuid and legal_entity_id = nullif(current_setting('ontos.legal_entity_id', true), '')::uuid`; const entrypoint = defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.shell.governed-isolation-fixture', moduleKey: 'core.shell', role: 'api', @@ -328,9 +313,7 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr ( _input, context: ReadHandlerContext<{ - readonly listWithoutPredicates: () => Effect.Effect< - readonly { readonly value: string }[] - >; + readonly listWithoutPredicates: () => Effect.Effect; }>, ) => context.services.listWithoutPredicates().pipe(Effect.map(toReadResult)), (transaction) => { @@ -344,7 +327,12 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr ); const contextAccess = { legalEntities: ({ legalEntityIds }: { readonly legalEntityIds: readonly string[] }) => - Effect.succeed(legalEntityIds.map((key) => ({ decision: 'allowed' as const, key }))), + Effect.succeed( + legalEntityIds.map((key) => ({ + decision: 'allowed' as const, + key, + })), + ), modules: () => Effect.succeed([]), resources: () => Effect.succeed([]), tenants: () => Effect.succeed([]), @@ -352,10 +340,7 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr const runtime = makeReadRuntime( { executor: runtimeDatabase }, openModuleEntrypointGateway, - makeOperationalScopeResolver( - makeOperationalScopeRepository({ executor: runtimeDatabase }), - contextAccess, - ), + makeOperationalScopeResolver(makeOperationalScopeRepository({ executor: runtimeDatabase }), contextAccess), contextAccess, ); return runtime.runRead({ @@ -403,16 +388,7 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr yield* queryEffect( admin, `insert into core.legal_entities (legal_entity_id, tenant_id, legal_name, registration_country, registration_number, status) values ($1, $4, 'Entity A', 'CZ', $6, 'active'), ($2, $4, 'Entity B', 'CZ', $7, 'active'), ($3, $5, 'Entity C', 'CZ', $8, 'active')`, - [ - entityA, - entityB, - entityC, - tenantA, - tenantB, - `A-${entityA}`, - `B-${entityB}`, - `C-${entityC}`, - ], + [entityA, entityB, entityC, tenantA, tenantB, `A-${entityA}`, `B-${entityB}`, `C-${entityC}`], ); yield* queryEffect( admin, @@ -422,16 +398,7 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr yield* queryEffect( admin, `insert into core.principal_auth_bindings (principal_auth_binding_id, tenant_id, principal_id, provider, subject_type, provider_subject_id, status) values ($1, $3, $5, 'better_auth', 'user', $7, 'active'), ($2, $4, $6, 'better_auth', 'user', $8, 'active')`, - [ - bindingA, - bindingB, - tenantA, - tenantB, - principalA, - principalB, - `user-${principalA}`, - `user-${principalB}`, - ], + [bindingA, bindingB, tenantA, tenantB, principalA, principalB, `user-${principalA}`, `user-${principalB}`], ); yield* queryEffect( admin, @@ -471,27 +438,14 @@ it.live('an unscoped owner repository remains isolated inside a governed read tr ).toEqual(['tenant-b-entity-c']); }); const release = Effect.gen(function* cleanGovernedReadIsolation() { - yield* queryEffect(admin, 'delete from core.data_access_events where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]); - yield* queryEffect( - admin, - 'delete from core.principal_auth_bindings where tenant_id in ($1, $2)', - [tenantA, tenantB], - ); - yield* queryEffect(admin, 'delete from core.principals where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]); - yield* queryEffect(admin, 'delete from core.legal_entities where tenant_id in ($1, $2)', [ - tenantA, - tenantB, - ]); - yield* queryEffect(admin, 'delete from core.tenants where tenant_id in ($1, $2)', [ + yield* queryEffect(admin, 'delete from core.data_access_events where tenant_id in ($1, $2)', [tenantA, tenantB]); + yield* queryEffect(admin, 'delete from core.principal_auth_bindings where tenant_id in ($1, $2)', [ tenantA, tenantB, ]); + yield* queryEffect(admin, 'delete from core.principals where tenant_id in ($1, $2)', [tenantA, tenantB]); + yield* queryEffect(admin, 'delete from core.legal_entities where tenant_id in ($1, $2)', [tenantA, tenantB]); + yield* queryEffect(admin, 'delete from core.tenants where tenant_id in ($1, $2)', [tenantA, tenantB]); yield* queryEffect(admin, `drop schema if exists ${schemaName} cascade`); }).pipe(Effect.orDie); yield* exercise.pipe(Effect.ensuring(release)); @@ -541,27 +495,15 @@ it.live('PostgreSQL rejects cross-tenant entity, principal, and Action reference ); const invocationInsert = `insert into core.action_invocations (action_invocation_id, tenant_id, legal_entity_id, principal_id, action_key, status, request_hash) values ($1, $2, $3, $4, 'isolation.test', 'received', 'bounded-hash')`; - yield* expectForeignKeyFailure(invocationInsert, [ - randomUUID(), - tenantA, - entityB, - principalA, - ]); - yield* expectForeignKeyFailure(invocationInsert, [ - randomUUID(), - tenantA, - entityA, - principalB, - ]); + yield* expectForeignKeyFailure(invocationInsert, [randomUUID(), tenantA, entityB, principalA]); + yield* expectForeignKeyFailure(invocationInsert, [randomUUID(), tenantA, entityA, principalB]); yield* queryEffect(client, invocationInsert, [invocationA, tenantA, entityA, principalA]); yield* expectForeignKeyFailure( `insert into core.tenant_module_state_changes (tenant_id, module_key, new_state, changed_by_principal_id, action_invocation_id, change_source) values ($1, 'core.shell', 'active', $2, $3, 'user')`, [tenantB, principalB, invocationA], ); }); - const release = queryEffect(client, 'rollback').pipe( - Effect.ensuring(Effect.sync(() => client.release())), - ); + const release = queryEffect(client, 'rollback').pipe(Effect.ensuring(Effect.sync(() => client.release()))); yield* exercise.pipe(Effect.ensuring(release)); }), ); diff --git a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts index aa4635217..b77cfb2dd 100644 --- a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts @@ -1,13 +1,10 @@ +import { and, asc, eq, inArray } from 'drizzle-orm'; +import { Cause, Effect, Exit, Match, Option, Schema, Layer } from 'effect'; import { expect, it } from 'effect-rstest'; -import { makeInstalledCatalogFixture as catalogFrom } from '../support/installed-catalog.ts'; -import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; -import { and, asc, eq, inArray } from 'drizzle-orm'; -import { Cause, Effect, Exit, Match, Option, Schema, Layer } from 'effect'; import { makeActionRepository } from '../../src/actions/repository.ts'; import { makeActionRuntime } from '../../src/actions/runtime.ts'; -import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; import { actionInvocations, @@ -26,8 +23,11 @@ import { TenantModuleStateService, makeTenantModuleStateService, } from '../../src/modules/tenant-module-state-service.ts'; +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { testOperationalScopeResolver } from '../fixtures/operational-scope.ts'; import { openActionRuntimeOptions } from '../support/action-runtime-options.ts'; +import { makeFaultInjectableCoreDatabase, TestQueryHook } from '../support/database-faults.ts'; +import { makeInstalledCatalogFixture as catalogFrom } from '../support/installed-catalog.ts'; const tenantOne = '70000000-0000-4000-8000-000000000001'; const tenantTwo = '70000000-0000-4000-8000-000000000002'; @@ -48,15 +48,7 @@ const installedContract = (moduleId: string): OntosModuleDeploymentContract => description: 'Integration test module', displayName: 'Integration test module', moduleId, - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }); // State-transition tests deliberately accept arbitrary module IDs without discovery. @@ -161,9 +153,7 @@ const setup = Effect.gen(function* initializeTenantModuleStateFixtures() { ); }); -const Fixtures = Layer.effectDiscard( - Effect.acquireRelease(setup, () => cleanup.pipe(Effect.orDie)), -); +const Fixtures = Layer.effectDiscard(Effect.acquireRelease(setup, () => cleanup.pipe(Effect.orDie))); const allowedPermission = { checkActionPermission: () => Effect.succeed('allowed' as const), @@ -214,10 +204,7 @@ const failureTag = (exit: Exit.Exit): string | undefined return Option.getOrUndefined(tag); }; -const verifyHistoryEvidence = ( - database: DatabaseService, - row: typeof tenantModuleStateChanges.$inferSelect, -) => +const verifyHistoryEvidence = (database: DatabaseService, row: typeof tenantModuleStateChanges.$inferSelect) => Effect.gen(function* verifyHistoryEvidenceEffect() { const [invocation] = yield* database.executor .select() @@ -253,17 +240,13 @@ const tenantModuleStateTest1 = withDatabase((database) => { moduleKey: 'list.alpha', state: 'active' }, { moduleKey: 'list.zeta', state: 'active' }, ]); - expect(yield* service.listActiveTenantModules(tenantTwo)).toEqual([ - { moduleKey: 'list.alpha', state: 'active' }, - ]); + expect(yield* service.listActiveTenantModules(tenantTwo)).toEqual([{ moduleKey: 'list.alpha', state: 'active' }]); expect(yield* service.listTenantModuleStates(tenantOne)).toEqual([ { moduleKey: 'list.alpha', state: 'active' }, { moduleKey: 'list.inactive', state: 'inactive' }, { moduleKey: 'list.zeta', state: 'active' }, ]); - expect(yield* service.listTenantModuleStates(tenantTwo)).toEqual([ - { moduleKey: 'list.alpha', state: 'active' }, - ]); + expect(yield* service.listTenantModuleStates(tenantTwo)).toEqual([{ moduleKey: 'list.alpha', state: 'active' }]); }), ); const tenantModuleStateTest2 = Effect.gen(function* createAndTransitionTenantModuleState() { @@ -279,7 +262,11 @@ const tenantModuleStateTest2 = Effect.gen(function* createAndTransitionTenantMod ); return Effect.gen(function* transitionSequence() { const created = yield* runtime.runAction(actionInput(moduleKey, 'active', 'create')); - expect(created).toEqual({ moduleKey, newState: 'active', previousState: null }); + expect(created).toEqual({ + moduleKey, + newState: 'active', + previousState: null, + }); const suspended = yield* runtime.runAction(actionInput(moduleKey, 'suspended', 'suspend')); expect(suspended).toEqual({ moduleKey, @@ -300,21 +287,11 @@ const tenantModuleStateTest2 = Effect.gen(function* createAndTransitionTenantMod const [current] = yield* database.executor .select() .from(tenantModuleStates) - .where( - and( - eq(tenantModuleStates.tenantId, tenantOne), - eq(tenantModuleStates.moduleKey, moduleKey), - ), - ); + .where(and(eq(tenantModuleStates.tenantId, tenantOne), eq(tenantModuleStates.moduleKey, moduleKey))); const history = yield* database.executor .select() .from(tenantModuleStateChanges) - .where( - and( - eq(tenantModuleStateChanges.tenantId, tenantOne), - eq(tenantModuleStateChanges.moduleKey, moduleKey), - ), - ) + .where(and(eq(tenantModuleStateChanges.tenantId, tenantOne), eq(tenantModuleStateChanges.moduleKey, moduleKey))) .orderBy(asc(tenantModuleStateChanges.occurredAt)); expect(current?.state).toBe('active'); expect(history.length).toBe(3); @@ -326,15 +303,21 @@ const tenantModuleStateTest2 = Effect.gen(function* createAndTransitionTenantMod })), ).toEqual([ { changeSource: 'user', newState: 'active', previousState: null }, - { changeSource: 'user', newState: 'suspended', previousState: 'active' }, - { changeSource: 'user', newState: 'active', previousState: 'suspended' }, + { + changeSource: 'user', + newState: 'suspended', + previousState: 'active', + }, + { + changeSource: 'user', + newState: 'active', + previousState: 'suspended', + }, ]); expect(current?.lastChangeId).toBe(history.at(-1)?.moduleStateChangeId); expect(history.every((row) => row.changedByPrincipalId === principalOne)).toBe(true); expect(history.every((row) => row.actionInvocationId !== null)).toBe(true); - expect( - history.every((row) => row.reason?.startsWith('Integration transition to ') === true), - ).toBe(true); + expect(history.every((row) => row.reason?.startsWith('Integration transition to ') === true)).toBe(true); yield* Effect.forEach(history, (row) => verifyHistoryEvidence(database, row), { concurrency: 1, @@ -345,10 +328,7 @@ const tenantModuleStateTest2 = Effect.gen(function* createAndTransitionTenantMod const tenantModuleStateTest3 = Effect.gen(function* allDeclaredTenantModuleStates() { const otherModuleKey = testModuleKey('other', tenantOne); const targetModuleKey = testModuleKey('independent', tenantOne); - const transitionCatalog = catalogFrom( - installedContract(otherModuleKey), - installedContract(targetModuleKey), - ); + const transitionCatalog = catalogFrom(installedContract(otherModuleKey), installedContract(targetModuleKey)); yield* withDatabase((database) => database.executor.insert(tenantModuleStates).values({ moduleKey: otherModuleKey, @@ -373,19 +353,10 @@ const tenantModuleStateTest3 = Effect.gen(function* allDeclaredTenantModuleState load: Effect.succeed(transitionCatalog), }), ); - const states = [ - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - 'inactive', - ] as const; + const states = ['active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived', 'inactive'] as const; return Effect.forEach( states, - (state) => - withCatalog(runtime.runAction(actionInput(targetModuleKey, state, `independent-${state}`))), + (state) => withCatalog(runtime.runAction(actionInput(targetModuleKey, state, `independent-${state}`))), { concurrency: 1, discard: true }, ); }); @@ -393,7 +364,10 @@ const tenantModuleStateTest3 = Effect.gen(function* allDeclaredTenantModuleState yield* withDatabase((database) => Effect.gen(function* verifyAllDeclaredStates() { const stateRows = yield* database.executor - .select({ moduleKey: tenantModuleStates.moduleKey, state: tenantModuleStates.state }) + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + }) .from(tenantModuleStates) .where(inArray(tenantModuleStates.moduleKey, [otherModuleKey, targetModuleKey])); const historyRows = yield* database.executor @@ -498,14 +472,8 @@ const withTenantStateWriteFailure = (database: DatabaseService): DatabaseService ), ), ); - const transactionOverride = { transaction } satisfies Pick< - DatabaseService['executor'], - 'transaction' - >; - const executor: DatabaseService['executor'] = Object.assign( - Object.create(database.executor), - transactionOverride, - ); + const transactionOverride = { transaction } satisfies Pick; + const executor: DatabaseService['executor'] = Object.assign(Object.create(database.executor), transactionOverride); return { executor }; }; const tenantModuleStateTest5 = Effect.gen(function* rollbackFailedTenantModuleStateWrite() { @@ -520,10 +488,9 @@ const tenantModuleStateTest5 = Effect.gen(function* rollbackFailedTenantModuleSt ); return Effect.exit(runtime.runAction(actionInput(moduleKey, 'active', 'forced-failure'))); }); - expect( - failureTag(failure), - Exit.isFailure(failure) ? Cause.pretty(failure.cause) : 'success', - ).toBe('TenantModuleStatePersistenceUnavailableError'); + expect(failureTag(failure), Exit.isFailure(failure) ? Cause.pretty(failure.cause) : 'success').toBe( + 'TenantModuleStatePersistenceUnavailableError', + ); yield* withDatabase((database) => Effect.gen(function* verifyFailedWriteRollback() { @@ -550,65 +517,61 @@ const tenantModuleStateTest5 = Effect.gen(function* rollbackFailedTenantModuleSt }), ); }); -const tenantModuleStateTest6 = Effect.gen( - function* serializeConcurrentTenantModuleStateTransitions() { - const moduleKey = testModuleKey('concurrency', tenantOne); - yield* withDatabase((database) => { - const runtime = makeActionRuntime( - database, - makeActionRepository(), - allowedPermission, - testOperationalScopeResolver, - openActionRuntimeOptions, - ); - return runtime.runAction(actionInput(moduleKey, 'inactive', 'concurrent-initial')); - }); - - const exits = yield* Effect.forEach( - [ - ['active', 'concurrent-active'], - ['suspended', 'concurrent-suspended'], - ] as const, - ([state, key]) => - withDatabase((database) => { - const runtime = makeActionRuntime( - database, - makeActionRepository(), - allowedPermission, - testOperationalScopeResolver, - openActionRuntimeOptions, - ); - return Effect.exit(runtime.runAction(actionInput(moduleKey, state, key))); - }), - { concurrency: 'unbounded' }, +const tenantModuleStateTest6 = Effect.gen(function* serializeConcurrentTenantModuleStateTransitions() { + const moduleKey = testModuleKey('concurrency', tenantOne); + yield* withDatabase((database) => { + const runtime = makeActionRuntime( + database, + makeActionRepository(), + allowedPermission, + testOperationalScopeResolver, + openActionRuntimeOptions, ); - expect(exits.every(Exit.isSuccess)).toBe(true); - - yield* withDatabase((database) => - Effect.gen(function* verifySerializedTransitions() { - const [current] = yield* database.executor - .select() - .from(tenantModuleStates) - .where(eq(tenantModuleStates.moduleKey, moduleKey)); - const history = yield* database.executor - .select() - .from(tenantModuleStateChanges) - .where(eq(tenantModuleStateChanges.moduleKey, moduleKey)); - expect(history.length).toBe(3); - const last = history.find((row) => row.moduleStateChangeId === current?.lastChangeId); - const concurrentFirst = history.find( - (row) => - row.previousState === 'inactive' && - row.moduleStateChangeId !== last?.moduleStateChangeId, + return runtime.runAction(actionInput(moduleKey, 'inactive', 'concurrent-initial')); + }); + + const exits = yield* Effect.forEach( + [ + ['active', 'concurrent-active'], + ['suspended', 'concurrent-suspended'], + ] as const, + ([state, key]) => + withDatabase((database) => { + const runtime = makeActionRuntime( + database, + makeActionRepository(), + allowedPermission, + testOperationalScopeResolver, + openActionRuntimeOptions, ); - expect(last).toBeDefined(); - expect(concurrentFirst).toBeDefined(); - expect(last?.previousState).toBe(concurrentFirst?.newState); - expect(current?.state).toBe(last?.newState); + return Effect.exit(runtime.runAction(actionInput(moduleKey, state, key))); }), - ); - }, -); + { concurrency: 'unbounded' }, + ); + expect(exits.every(Exit.isSuccess)).toBe(true); + + yield* withDatabase((database) => + Effect.gen(function* verifySerializedTransitions() { + const [current] = yield* database.executor + .select() + .from(tenantModuleStates) + .where(eq(tenantModuleStates.moduleKey, moduleKey)); + const history = yield* database.executor + .select() + .from(tenantModuleStateChanges) + .where(eq(tenantModuleStateChanges.moduleKey, moduleKey)); + expect(history.length).toBe(3); + const last = history.find((row) => row.moduleStateChangeId === current?.lastChangeId); + const concurrentFirst = history.find( + (row) => row.previousState === 'inactive' && row.moduleStateChangeId !== last?.moduleStateChangeId, + ); + expect(last).toBeDefined(); + expect(concurrentFirst).toBeDefined(); + expect(last?.previousState).toBe(concurrentFirst?.newState); + expect(current?.state).toBe(last?.newState); + }), + ); +}); const tenantModuleStateTest7 = Effect.gen(function* deriveTrustedTenantScope() { const moduleKey = testModuleKey('isolation', tenantOne); yield* withDatabase((database) => @@ -633,7 +596,10 @@ const tenantModuleStateTest7 = Effect.gen(function* deriveTrustedTenantScope() { yield* withDatabase((database) => Effect.gen(function* verifyTrustedTenantScope() { const rows = yield* database.executor - .select({ state: tenantModuleStates.state, tenantId: tenantModuleStates.tenantId }) + .select({ + state: tenantModuleStates.state, + tenantId: tenantModuleStates.tenantId, + }) .from(tenantModuleStates) .where(eq(tenantModuleStates.moduleKey, moduleKey)) .orderBy(asc(tenantModuleStates.tenantId)); @@ -670,13 +636,7 @@ it.layer(Fixtures, { excludeTestServices: true })('tenant module state', (suite) () => tenantModuleStateTest5, ); - suite.effect( - 'serializes concurrent transitions into one truthful history chain', - () => tenantModuleStateTest6, - ); + suite.effect('serializes concurrent transitions into one truthful history chain', () => tenantModuleStateTest6); - suite.effect( - 'derives tenant scope only from the trusted principal', - () => tenantModuleStateTest7, - ); + suite.effect('derives tenant scope only from the trusted principal', () => tenantModuleStateTest7); }); diff --git a/app/packages/core-runtime/tests/support/database-faults.ts b/app/packages/core-runtime/tests/support/database-faults.ts index 6c6938d53..71cf8fa98 100644 --- a/app/packages/core-runtime/tests/support/database-faults.ts +++ b/app/packages/core-runtime/tests/support/database-faults.ts @@ -5,6 +5,7 @@ import { Reactivity } from 'effect/unstable/reactivity'; import type { Connection } from 'effect/unstable/sql/SqlConnection'; import type { SqlError } from 'effect/unstable/sql/SqlError'; import { Pool } from 'pg'; + import { acquirePoolResource } from '../../src/db/client.ts'; import type { DatabaseConfigValue } from '../../src/db/config.ts'; import { coreRelations } from '../../src/db/schema.ts'; @@ -16,34 +17,25 @@ export const TestQueryHook = Context.Reference('TestQueryHook', { export const makeFaultInjectableCoreDatabase = Effect.fn('makeFaultInjectableCoreDatabase')( function* makeFaultInjectableCoreDatabase(configuration: DatabaseConfigValue) { - const pool = yield* acquirePoolResource( - () => new Pool({ connectionString: configuration.connectionString }), - ); + const pool = yield* acquirePoolResource(() => new Pool({ connectionString: configuration.connectionString })); const reactivity = yield* Reactivity.make; - const source = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( - Effect.provideService(Reactivity.Reactivity, reactivity), - ); - const before = (statement: string) => - TestQueryHook.pipe(Effect.flatMap((hook) => hook(statement))); + const source = yield* PgClient.fromPool({ + acquire: Effect.succeed(pool), + }).pipe(Effect.provideService(Reactivity.Reactivity, reactivity)); + const before = (statement: string) => TestQueryHook.pipe(Effect.flatMap((hook) => hook(statement))); const acquirer = source.reserve.pipe( Effect.map((connection): Connection => ({ ...connection, execute: (statement, params, transform) => - before(statement).pipe( - Effect.andThen(() => connection.execute(statement, params, transform)), - ), + before(statement).pipe(Effect.andThen(() => connection.execute(statement, params, transform))), executeRaw: (statement, params) => before(statement).pipe(Effect.andThen(() => connection.executeRaw(statement, params))), executeUnprepared: (statement, params, transform) => - before(statement).pipe( - Effect.andThen(() => connection.executeUnprepared(statement, params, transform)), - ), + before(statement).pipe(Effect.andThen(() => connection.executeUnprepared(statement, params, transform))), executeValues: (statement, params) => before(statement).pipe(Effect.andThen(() => connection.executeValues(statement, params))), executeValuesUnprepared: (statement, params) => - before(statement).pipe( - Effect.andThen(() => connection.executeValuesUnprepared(statement, params)), - ), + before(statement).pipe(Effect.andThen(() => connection.executeValuesUnprepared(statement, params))), })), ); const client = yield* PgClient.makeWith({ diff --git a/app/packages/core-runtime/tests/support/database.ts b/app/packages/core-runtime/tests/support/database.ts index 0785503f8..1a6f235b5 100644 --- a/app/packages/core-runtime/tests/support/database.ts +++ b/app/packages/core-runtime/tests/support/database.ts @@ -1,14 +1,15 @@ -import { Pool } from 'pg'; import { PgClient } from '@effect/sql-pg'; import type { AnyRelations } from 'drizzle-orm'; import { makeWithDefaults } from 'drizzle-orm/effect-postgres'; import { Effect } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; -import { testSqlConnection } from './sql-connection.ts'; import type { SqlError } from 'effect/unstable/sql/SqlError'; -import { coreRelations } from '../../src/db/schema.ts'; -import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; +import { Pool } from 'pg'; + import { acquirePoolResource } from '../../src/db/client.ts'; +import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; +import { coreRelations } from '../../src/db/schema.ts'; +import { testSqlConnection } from './sql-connection.ts'; /** Native SQL connection fixture; Drizzle and Effect own query and transaction execution. */ export const makeTestDatabase = ( @@ -31,26 +32,19 @@ export const makeTestDatabase = ( ); /** The caller owns the pool and keeps this scope open until its tests finish. */ -export const makeTestDatabaseFromPool = ( - pool: Pool, - relations: Relations, -) => +export const makeTestDatabaseFromPool = (pool: Pool, relations: Relations) => Effect.gen(function* makePoolTestDatabase() { const reactivity = yield* Reactivity.make; - const client = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( - Effect.provideService(Reactivity.Reactivity, reactivity), - ); - return yield* makeWithDefaults({ relations }).pipe( - Effect.provideService(PgClient.PgClient, client), - ); + const client = yield* PgClient.fromPool({ + acquire: Effect.succeed(pool), + }).pipe(Effect.provideService(Reactivity.Reactivity, reactivity)); + return yield* makeWithDefaults({ relations }).pipe(Effect.provideService(PgClient.PgClient, client)); }); /** Fresh pools per execution; the caller's scope releases them after test cleanup. */ export const testDatabasePools = Effect.gen(function* acquireTestDatabasePools() { const connections = yield* loadDatabaseConnectionPair(); - const admin = yield* acquirePoolResource( - () => new Pool({ connectionString: connections.admin.connectionString }), - ); + const admin = yield* acquirePoolResource(() => new Pool({ connectionString: connections.admin.connectionString })); const runtimePool = yield* acquirePoolResource( () => new Pool({ connectionString: connections.runtime.connectionString }), ); diff --git a/app/packages/core-runtime/tests/support/fixture-cleanup.ts b/app/packages/core-runtime/tests/support/fixture-cleanup.ts index b65cce827..4f7e29b63 100644 --- a/app/packages/core-runtime/tests/support/fixture-cleanup.ts +++ b/app/packages/core-runtime/tests/support/fixture-cleanup.ts @@ -5,6 +5,5 @@ import { Effect } from 'effect'; * the first deletion that fails. Callers build the delete Effects inline, which keeps the * owned table order explicit at the call site instead of behind a generic cascade. */ -export const purgeFixtureRows = ( - deletions: readonly Effect.Effect[], -): Effect.Effect => Effect.all(deletions, { concurrency: 1, discard: true }); +export const purgeFixtureRows = (deletions: readonly Effect.Effect[]): Effect.Effect => + Effect.all(deletions, { concurrency: 1, discard: true }); diff --git a/app/packages/core-runtime/tests/support/installed-catalog.ts b/app/packages/core-runtime/tests/support/installed-catalog.ts index 1d636d587..e78842c0e 100644 --- a/app/packages/core-runtime/tests/support/installed-catalog.ts +++ b/app/packages/core-runtime/tests/support/installed-catalog.ts @@ -14,8 +14,7 @@ export const makeInstalledCatalogFixture = ( status: 'available' as const, })), ), - getByDeploymentAppId: (appId: string) => - contracts.find(({ deployment }) => deployment.appId === appId), + getByDeploymentAppId: (appId: string) => contracts.find(({ deployment }) => deployment.appId === appId), getByModuleId: (moduleId: string) => byModuleId.get(moduleId), moduleIds: Object.freeze(contracts.map(({ manifest }) => manifest.module.id)), outboxSubscriptions: Object.freeze([]), diff --git a/app/packages/core-runtime/tests/support/open-module-entrypoint-gateway.ts b/app/packages/core-runtime/tests/support/open-module-entrypoint-gateway.ts index f2c7b25a1..37e97d518 100644 --- a/app/packages/core-runtime/tests/support/open-module-entrypoint-gateway.ts +++ b/app/packages/core-runtime/tests/support/open-module-entrypoint-gateway.ts @@ -1,7 +1,8 @@ import { Effect } from 'effect'; + import { decodeTrustedPrincipalContext } from '../../src/auth/system-principal-context-provenance.ts'; -import type { ModuleEntrypointDescriptor } from '../../src/modules/module-entrypoint.ts'; import type { ModuleEntrypointGatewayService } from '../../src/modules/module-entrypoint-gateway.ts'; +import type { ModuleEntrypointDescriptor } from '../../src/modules/module-entrypoint.ts'; import { ModuleStateCheckUnavailableError } from '../../src/modules/module-state-gate-errors.ts'; import { openModuleStateGate } from './open-module-state-gate.ts'; @@ -11,19 +12,13 @@ const unavailable = () => reason: 'Module state could not be checked safely', }); -const prepareSnapshotInput = ( - context: Input, - entrypoints: readonly ModuleEntrypointDescriptor[], -) => +const prepareSnapshotInput = (context: Input, entrypoints: readonly ModuleEntrypointDescriptor[]) => decodeTrustedPrincipalContext(context).pipe( Effect.mapError(unavailable), - Effect.flatMap((trustedContext) => - openModuleStateGate.prepareSnapshot(trustedContext.tenantId, entrypoints), - ), + Effect.flatMap((trustedContext) => openModuleStateGate.prepareSnapshot(trustedContext.tenantId, entrypoints)), ); -const run: ModuleEntrypointGatewayService['run'] = (input) => - input.authorize.pipe(Effect.andThen(input.load)); +const run: ModuleEntrypointGatewayService['run'] = (input) => input.authorize.pipe(Effect.andThen(input.load)); export const openModuleEntrypointGateway: ModuleEntrypointGatewayService = Object.freeze({ check: () => Effect.void, diff --git a/app/packages/core-runtime/tests/support/open-module-state-gate.ts b/app/packages/core-runtime/tests/support/open-module-state-gate.ts index cf577aeb3..604f90377 100644 --- a/app/packages/core-runtime/tests/support/open-module-state-gate.ts +++ b/app/packages/core-runtime/tests/support/open-module-state-gate.ts @@ -1,4 +1,5 @@ import { Effect } from 'effect'; + import type { ModuleStateGateService } from '../../src/modules/module-state-gate.ts'; const prepareSnapshot: ModuleStateGateService['prepareSnapshot'] = (tenantId, entrypoints) => @@ -6,11 +7,7 @@ const prepareSnapshot: ModuleStateGateService['prepareSnapshot'] = (tenantId, en Object.freeze({ entrypointKeys: Object.freeze(entrypoints.map(({ entrypointKey }) => entrypointKey)), moduleKeys: Object.freeze([ - ...new Set( - entrypoints - .filter((entrypoint) => entrypoint.scope === 'tenant') - .map(({ moduleKey }) => moduleKey), - ), + ...new Set(entrypoints.filter((entrypoint) => entrypoint.scope === 'tenant').map(({ moduleKey }) => moduleKey)), ]), tenantId, }), diff --git a/app/packages/core-runtime/tests/support/permission-write-error.ts b/app/packages/core-runtime/tests/support/permission-write-error.ts new file mode 100644 index 000000000..24251bfb9 --- /dev/null +++ b/app/packages/core-runtime/tests/support/permission-write-error.ts @@ -0,0 +1,5 @@ +import { Schema } from 'effect'; + +export class TestWriteError extends Schema.TaggedError()('TestWriteError', { + reason: Schema.String, +}) {} diff --git a/app/packages/core-runtime/tests/unit/action-authorization-rollout.test.ts b/app/packages/core-runtime/tests/unit/action-authorization-rollout.test.ts index 5068e5671..c3bb73a26 100644 --- a/app/packages/core-runtime/tests/unit/action-authorization-rollout.test.ts +++ b/app/packages/core-runtime/tests/unit/action-authorization-rollout.test.ts @@ -1,5 +1,6 @@ import { Effect, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; + import { decideAuthorizationRollout } from '../../src/authorization/rollout-decision.ts'; import type { AuthorizationWouldDenyEvent } from '../../src/authorization/rollout-decision.ts'; @@ -23,43 +24,35 @@ const input = { surface: 'action' as const, }; -it.effect( - 'active, baselined report-only compatibility preserves only missing-policy behavior', - () => - Effect.gen(function* authorizationRollout() { - const events: AuthorizationWouldDenyEvent[] = []; - expect( - decideAuthorizationRollout(input, { - contract, - emit: (event) => { - events.push(event); - }, - }), - ).toBe('allowed'); - expect(events).toEqual([ - { - denialReason: 'missing_policy', - entrypointKey: 'contacts.create-contact', - inventoryHash: 'inventory-hash', - policyClass: 'action_execution', - schemaVersion: 1, - sourceRevision: 'source-revision', - surface: 'action', - timestamp: '2026-09-10T00:00:00.000Z', - type: 'authorization.would_deny', +it.effect('active, baselined report-only compatibility preserves only missing-policy behavior', () => + Effect.gen(function* authorizationRollout() { + const events: AuthorizationWouldDenyEvent[] = []; + expect( + decideAuthorizationRollout(input, { + contract, + emit: (event) => { + events.push(event); }, - ]); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(events)).includes( - 'principal', - ), - ).toBe(false); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(events)).includes( - 'tenant', - ), - ).toBe(false); - }), + }), + ).toBe('allowed'); + expect(events).toEqual([ + { + denialReason: 'missing_policy', + entrypointKey: 'contacts.create-contact', + inventoryHash: 'inventory-hash', + policyClass: 'action_execution', + schemaVersion: 1, + sourceRevision: 'source-revision', + surface: 'action', + timestamp: '2026-09-10T00:00:00.000Z', + type: 'authorization.would_deny', + }, + ]); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(events)).includes('principal')).toBe( + false, + ); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(events)).includes('tenant')).toBe(false); + }), ); it('enforced, expired, and unbaselined entrypoints deny without evidence', () => { diff --git a/app/packages/core-runtime/tests/unit/action-collector.test.ts b/app/packages/core-runtime/tests/unit/action-collector.test.ts index b3f9cc2f0..cb8e89a44 100644 --- a/app/packages/core-runtime/tests/unit/action-collector.test.ts +++ b/app/packages/core-runtime/tests/unit/action-collector.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { createActionCollector } from '../../src/actions/collector.ts'; const event = (id: string) => @@ -33,7 +34,10 @@ const makeCollector = () => captureMode: 'metadata_only', policyKey: 'counter.read.v1', }, - Schema.Struct({ checkpoint: Schema.String, nested: Schema.optionalKey(Schema.Json) }), + Schema.Struct({ + checkpoint: Schema.String, + nested: Schema.optionalKey(Schema.Json), + }), ); it.effect('preserves event order, multiple messages, and events without messages', () => @@ -55,13 +59,8 @@ it.effect('preserves event order, multiple messages, and events without messages const snapshot = collector.snapshot(); - expect(snapshot.domainEvents.map((item) => item.subjectResourceId)).toEqual([ - 'first', - 'second', - ]); - expect( - snapshot.outboxMessages.map((item) => [item.domainEventIndex, item.message.topic]), - ).toEqual([ + expect(snapshot.domainEvents.map((item) => item.subjectResourceId)).toEqual(['first', 'second']); + expect(snapshot.outboxMessages.map((item) => [item.domainEventIndex, item.message.topic])).toEqual([ [0, 'counter.project'], [0, 'counter.notify'], ]); @@ -75,12 +74,8 @@ it.effect('rejects orphan and foreign Domain Event references', () => const second = makeCollector(); const foreign = yield* first.addDomainEvent(event('foreign')); - const foreignError = yield* Effect.flip( - second.addOutboxMessage(foreign, message('counter.project')), - ); - const orphanError = yield* Effect.flip( - second.addOutboxMessageInput({}, message('counter.project')), - ); + const foreignError = yield* Effect.flip(second.addOutboxMessage(foreign, message('counter.project'))); + const orphanError = yield* Effect.flip(second.addOutboxMessageInput({}, message('counter.project'))); expect(Predicate.isTagged(foreignError, 'ActionCollectorError')).toBe(true); expect(Predicate.isTagged(orphanError, 'ActionCollectorError')).toBe(true); @@ -129,11 +124,12 @@ it.effect('captures one immutable JSON audit-evidence object and rejects invalid expect(Object.isFrozen(snapshot.auditEvidence['nested'])).toBe(true); const repeated = yield* Effect.flip(collector.recordAuditEvidence({ checkpoint: 'stopped' })); - const invalid = yield* Effect.flip( - makeCollector().recordAuditEvidenceInput({ value: undefined }), - ); + const invalid = yield* Effect.flip(makeCollector().recordAuditEvidenceInput({ value: undefined })); const undeclared = yield* Effect.flip( - makeCollector().recordAuditEvidence({ checkpoint: 'started', secret: 'must-not-persist' }), + makeCollector().recordAuditEvidence({ + checkpoint: 'started', + secret: 'must-not-persist', + }), ); const missingSchema = yield* Effect.flip( createActionCollector(domainEventContracts, 'shell.core', { @@ -175,12 +171,8 @@ it.effect('applies descriptor evidence policy and rejects incompatible evidence' resultCount: 1, servingModuleKey: 'shell.core', }); - expect(metadataCollector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe( - 'metadata_only', - ); - expect(metadataCollector.snapshot().dataAccessEvents[0]?.evidencePolicyKey).toBe( - 'counter.read.v1', - ); + expect(metadataCollector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe('metadata_only'); + expect(metadataCollector.snapshot().dataAccessEvents[0]?.evidencePolicyKey).toBe('counter.read.v1'); }), ); diff --git a/app/packages/core-runtime/tests/unit/action-definition.test.ts b/app/packages/core-runtime/tests/unit/action-definition.test.ts index d34db7632..ae000170e 100644 --- a/app/packages/core-runtime/tests/unit/action-definition.test.ts +++ b/app/packages/core-runtime/tests/unit/action-definition.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { decodeActionPayload, decodeActionResult, @@ -8,21 +9,24 @@ import { validateActionDescriptorInput, } from '../../src/actions/definition.ts'; import { defineGlobalPolicy, defineMicroverticalPolicy } from '../../src/actions/policy.ts'; -import { - defineSystemModuleEntrypoint, - defineTenantModuleEntrypoint, -} from '../../src/modules/module-entrypoint.ts'; +import { defineSystemModuleEntrypoint, defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; const counterActionDescriptor = () => ({ - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.change', auditProfile: 'standard', domainErrorSchema: Schema.Never, domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'shell.counter.change', moduleKey: 'core.shell', role: 'action', @@ -62,16 +66,16 @@ it.effect('defines an immutable typed descriptor and decodes typed payloads and ); it('keeps the Resource permission resolver private behind an immutable declaration', () => { - const permission = defineActionResourcePermission<{ readonly counterpartyId: string }>( - ({ counterpartyId }) => ({ - permission: 'write', - resource: { - moduleId: 'party.registry', - resourceId: counterpartyId, - resourceType: 'counterparty', - }, - }), - ); + const permission = defineActionResourcePermission<{ + readonly counterpartyId: string; + }>(({ counterpartyId }) => ({ + permission: 'write', + resource: { + moduleId: 'party.registry', + resourceId: counterpartyId, + resourceType: 'counterparty', + }, + })); expect(Object.isFrozen(permission)).toBe(true); expect(Object.keys(permission)).toEqual(['kind']); @@ -82,7 +86,10 @@ it('keeps the Resource permission resolver private behind an immutable declarati it('requires trusted Legal Entity scope for a Counterparty permission declaration', () => { const entrypoint = defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.create-counterparty', moduleKey: 'party.registry', role: 'action', @@ -111,7 +118,10 @@ it.effect('uses Schema.Void for a no-payload Action', () => Effect.gen(function* usesSchemaVoidForANopayloadAction() { const registration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'cache.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'cache.read.v1', + }, actionKey: 'shell.cache.refresh', auditProfile: 'minimal', domainErrorSchema: Schema.Never, @@ -137,11 +147,11 @@ it.effect('uses Schema.Void for a no-payload Action', () => () => Effect.void, ); - // oxlint-disable-next-line unicorn/no-useless-undefined -- Required argument exercises the no-payload contract. - const payload = yield* decodeActionPayload(registration.descriptor.payloadSchema, undefined); - const invalid = yield* Effect.flip( - decodeActionPayload(registration.descriptor.payloadSchema, {}), + const payload = yield* decodeActionPayload( + registration.descriptor.payloadSchema, + Option.getOrUndefined(Option.none()), ); + const invalid = yield* Effect.flip(decodeActionPayload(registration.descriptor.payloadSchema, {})); expect(payload).toBeUndefined(); expect(Predicate.isTagged(invalid, 'ActionPayloadValidationError')).toBe(true); @@ -164,32 +174,28 @@ it('keeps the private handler outside the public Action registration', () => { it.effect('rejects invalid declared results through a typed error', () => Effect.gen(function* rejectsInvalidDeclaredResultsThroughATypedError() { - const error = yield* Effect.flip( - decodeActionResult(Schema.Struct({ id: Schema.String }), { id: 1 }), - ); + const error = yield* Effect.flip(decodeActionResult(Schema.Struct({ id: Schema.String }), { id: 1 })); expect(Predicate.isTagged(error, 'ActionResultValidationError')).toBe(true); expect(error.code).toBe('action_result_invalid'); }), ); -it.effect( - 'validates decoded DateTime and Option results through their encoded representation', - () => - Effect.gen(function* validatesDecodedDateTimeAndOptionResultsThroughTheir() { - const resultSchema = Schema.Struct({ - archivedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), - createdAt: Schema.DateTimeUtcFromString, - }); - const decoded = yield* Schema.decodeUnknownEffect(resultSchema)({ - archivedAt: null, - createdAt: '2026-09-07T10:30:00.000Z', - }); - const result = yield* decodeActionResult(resultSchema, decoded); +it.effect('validates decoded DateTime and Option results through their encoded representation', () => + Effect.gen(function* validatesDecodedDateTimeAndOptionResultsThroughTheir() { + const resultSchema = Schema.Struct({ + archivedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), + createdAt: Schema.DateTimeUtcFromString, + }); + const decoded = yield* Schema.decodeEffect(resultSchema)({ + archivedAt: null, + createdAt: '2026-09-07T10:30:00.000Z', + }); + const result = yield* decodeActionResult(resultSchema, decoded); - expect(Option.isNone(result.archivedAt)).toBe(true); - expect(DateTime.formatIso(result.createdAt)).toBe('2026-09-07T10:30:00.000Z'); - }), + expect(Option.isNone(result.archivedAt)).toBe(true); + expect(DateTime.formatIso(result.createdAt)).toBe('2026-09-07T10:30:00.000Z'); + }), ); it('accepts global and same-owner Policy references and copies the collection', () => { @@ -205,14 +211,20 @@ it('accepts global and same-owner Policy references and copies the collection', const policies = [globalPolicy, modulePolicy]; const registration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: 'inventory.stock.reserve', auditProfile: 'standard', domainErrorSchema: Schema.Never, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', @@ -242,14 +254,20 @@ it('rejects cross-owner, string, copied, and missing Policy references at defini policyKey: 'billing.invoice.open.v1', }); const descriptor = { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: 'inventory.stock.reserve', auditProfile: 'standard', domainErrorSchema: Schema.Never, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', @@ -261,7 +279,9 @@ it('rejects cross-owner, string, copied, and missing Policy references at defini resultSchema: Schema.Void, schemaVersion: '1', } as const; - const incompatiblePayloadPolicy = defineGlobalPolicy<{ readonly sku: string }>({ + const incompatiblePayloadPolicy = defineGlobalPolicy<{ + readonly sku: string; + }>({ evaluate: () => Effect.void, policyKey: 'global.sku-required.v1', }); @@ -302,7 +322,10 @@ it('rejects cross-owner, string, copied, and missing Policy references at defini }), ).toThrow(); expect(() => - validateActionDescriptorInput({ ...descriptor, policies: [{ ...foreignPolicy }] }), + validateActionDescriptorInput({ + ...descriptor, + policies: [{ ...foreignPolicy }], + }), ).toThrow(); expect(() => validateActionDescriptorInput(descriptor)).toThrow(); }); @@ -310,14 +333,20 @@ it('rejects cross-owner, string, copied, and missing Policy references at defini it('rejects Action entrypoint owner, scope, role/access, and forged immutability mismatches', () => { const registration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: 'inventory.stock.reserve', auditProfile: 'standard', domainErrorSchema: Schema.Never, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', @@ -337,7 +366,10 @@ it('rejects Action entrypoint owner, scope, role/access, and forged immutability ...registration.descriptor, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'billing.invoice.reserve', moduleKey: 'billing.invoice', role: 'action', @@ -349,7 +381,10 @@ it('rejects Action entrypoint owner, scope, role/access, and forged immutability ...registration.descriptor, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', @@ -361,7 +396,10 @@ it('rejects Action entrypoint owner, scope, role/access, and forged immutability ...registration.descriptor, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.modules.change-state', moduleKey: 'core.modules', role: 'action', diff --git a/app/packages/core-runtime/tests/unit/action-errors.test.ts b/app/packages/core-runtime/tests/unit/action-errors.test.ts index cb725af40..3e6569ae6 100644 --- a/app/packages/core-runtime/tests/unit/action-errors.test.ts +++ b/app/packages/core-runtime/tests/unit/action-errors.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { ACTION_CORE_ERROR_TAGS, ActionAlreadyCommitted, diff --git a/app/packages/core-runtime/tests/unit/action-http-runner.test.ts b/app/packages/core-runtime/tests/unit/action-http-runner.test.ts index 48bfbdbf7..9a4a94257 100644 --- a/app/packages/core-runtime/tests/unit/action-http-runner.test.ts +++ b/app/packages/core-runtime/tests/unit/action-http-runner.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Redacted, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { defineAction } from '../../src/actions/definition.ts'; import { ActionRuntime } from '../../src/actions/runtime.ts'; import type { ActionRuntimeService } from '../../src/actions/runtime.ts'; @@ -26,7 +27,10 @@ const registration = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.test.http-runner', moduleKey: 'core.shell', role: 'action', @@ -54,43 +58,40 @@ const invalidProblem = { _tag: 'InvalidProblem' as const }; const internalProblem = { _tag: 'InternalProblem' as const }; const authorization = (value?: string) => Redacted.make(value); -it.effect( - 'invalid correlation metadata is rejected before principal acquisition and runtime lookup', - () => - Effect.gen(function* rejectInvalidCorrelation() { - const requestHeaders = [ - {}, - { 'x-correlation-id': '' }, - { 'x-correlation-id': ' ' }, - ] as const; - let authenticationCalls = 0; - let runtimeCalls = 0; - const runtime = unusedRuntime(() => { - runtimeCalls += 1; - }); - const authenticate = () => { - authenticationCalls += 1; - return Effect.succeed(principal); - }; +it.effect('invalid correlation metadata is rejected before principal acquisition and runtime lookup', () => + Effect.gen(function* rejectInvalidCorrelation() { + const requestHeaders = [{}, { 'x-correlation-id': '' }, { 'x-correlation-id': ' ' }] as const; + let authenticationCalls = 0; + let runtimeCalls = 0; + const runtime = unusedRuntime(() => { + runtimeCalls += 1; + }); + const authenticate = () => { + authenticationCalls += 1; + return Effect.succeed(principal); + }; - for (const headers of requestHeaders) { - const effect = runGovernedActionHttp({ - endpointHeaders: { idempotencyKey: 'not-reached', traceId: 'not-reached' }, - internalProblem: () => internalProblem, - invalidCorrelationProblem: () => invalidProblem, - mapError: () => internalProblem, - payload: {}, - principal: { authenticate }, - registration, - requestHeaders: { authorization: authorization(), ...headers }, - }).pipe(Effect.provideService(ActionRuntime, runtime)); + for (const headers of requestHeaders) { + const effect = runGovernedActionHttp({ + endpointHeaders: { + idempotencyKey: 'not-reached', + traceId: 'not-reached', + }, + internalProblem: () => internalProblem, + invalidCorrelationProblem: () => invalidProblem, + mapError: () => internalProblem, + payload: {}, + principal: { authenticate }, + registration, + requestHeaders: { authorization: authorization(), ...headers }, + }).pipe(Effect.provideService(ActionRuntime, runtime)); - expect(yield* Effect.flip(effect)).toBe(invalidProblem); - } + expect(yield* Effect.flip(effect)).toBe(invalidProblem); + } - expect(authenticationCalls).toBe(0); - expect(runtimeCalls).toBe(0); - }), + expect(authenticationCalls).toBe(0); + expect(runtimeCalls).toBe(0); + }), ); it.effect('principal authentication failure prevents Action runtime execution', () => @@ -98,7 +99,10 @@ it.effect('principal authentication failure prevents Action runtime execution', const authenticationProblem = { _tag: 'AuthenticationProblem' as const }; let runtimeCalls = 0; const effect = runGovernedActionHttp({ - endpointHeaders: { idempotencyKey: 'not-reached', traceId: 'not-reached' }, + endpointHeaders: { + idempotencyKey: 'not-reached', + traceId: 'not-reached', + }, internalProblem: () => internalProblem, invalidCorrelationProblem: () => invalidProblem, mapError: () => internalProblem, @@ -131,7 +135,9 @@ it.effect('synchronous endpoint callback defects are sanitized before the Action throw new Error('private invalid-problem constructor defect'); }, principal: { authenticate: () => Effect.succeed(principal) }, - requestHeaders: { authorization: authorization('Bearer private-token') }, + requestHeaders: { + authorization: authorization('Bearer private-token'), + }, }, { invalidCorrelationProblem: () => invalidProblem, @@ -153,7 +159,10 @@ it.effect('synchronous endpoint callback defects are sanitized before the Action for (const fixture of callbackDefects) { const effect = runGovernedActionHttp({ - endpointHeaders: { idempotencyKey: 'not-reached', traceId: 'not-reached' }, + endpointHeaders: { + idempotencyKey: 'not-reached', + traceId: 'not-reached', + }, internalProblem: () => internalProblem, invalidCorrelationProblem: fixture.invalidCorrelationProblem, mapError: () => internalProblem, diff --git a/app/packages/core-runtime/tests/unit/action-identity.test.ts b/app/packages/core-runtime/tests/unit/action-identity.test.ts index 485dc9d7b..900100fc1 100644 --- a/app/packages/core-runtime/tests/unit/action-identity.test.ts +++ b/app/packages/core-runtime/tests/unit/action-identity.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { bindManagedApiKeyAction, bindSelfApiKeyAction, @@ -38,13 +39,13 @@ it.effect('identity administration and support starts declare independent tenant const originalPrincipalId = '00000000-0000-4000-8000-000000000003'; const managedPermissions = [ bindManagedApiKeyAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect(bindManagedApiKeyAction.descriptor.payloadSchema)({ + yield* Schema.decodeEffect(bindManagedApiKeyAction.descriptor.payloadSchema)({ principalId, providerSubjectId: 'provider-key-id', }), ), changePrincipalStatusAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect(changePrincipalStatusAction.descriptor.payloadSchema)({ + yield* Schema.decodeEffect(changePrincipalStatusAction.descriptor.payloadSchema)({ expectedStatus: 'active', newStatus: 'disabled', principalId, @@ -52,15 +53,13 @@ it.effect('identity administration and support starts declare independent tenant }), ), createNonHumanPrincipalAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect(createNonHumanPrincipalAction.descriptor.payloadSchema)({ + yield* Schema.decodeEffect(createNonHumanPrincipalAction.descriptor.payloadSchema)({ displayName: 'Inventory service', kind: 'service', }), ), setManagedApiKeyBindingStatusAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect( - setManagedApiKeyBindingStatusAction.descriptor.payloadSchema, - )({ + yield* Schema.decodeEffect(setManagedApiKeyBindingStatusAction.descriptor.payloadSchema)({ authBindingId, expectedStatus: 'active', newStatus: 'disabled', @@ -80,9 +79,7 @@ it.effect('identity administration and support starts declare independent tenant }; expect( recordSupportImpersonationAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect( - recordSupportImpersonationAction.descriptor.payloadSchema, - )({ + yield* Schema.decodeEffect(recordSupportImpersonationAction.descriptor.payloadSchema)({ ...supportPayload, checkpoint: 'requested', }), @@ -90,9 +87,7 @@ it.effect('identity administration and support starts declare independent tenant ).toBe('impersonate'); expect( recordSupportImpersonationAction.descriptor.tenantPermission?.( - yield* Schema.decodeUnknownEffect( - recordSupportImpersonationAction.descriptor.payloadSchema, - )({ + yield* Schema.decodeEffect(recordSupportImpersonationAction.descriptor.payloadSchema)({ ...supportPayload, checkpoint: 'stopped', sessionRef: 'better-auth-session:safe-session-reference', @@ -134,9 +129,7 @@ it.effect('support checkpoints forbid unsafe or misplaced session references', ( Effect.gen(function* identityScenario4() { const originalPrincipalId = '00000000-0000-4000-8000-000000000001'; const targetPrincipalId = '00000000-0000-4000-8000-000000000002'; - const decode = Schema.decodeUnknownEffect( - recordSupportImpersonationAction.descriptor.payloadSchema, - ); + const decode = Schema.decodeUnknownEffect(recordSupportImpersonationAction.descriptor.payloadSchema); expect( yield* decode({ diff --git a/app/packages/core-runtime/tests/unit/action-permission.test.ts b/app/packages/core-runtime/tests/unit/action-permission.test.ts index 4fc559e11..d63e5c8b3 100644 --- a/app/packages/core-runtime/tests/unit/action-permission.test.ts +++ b/app/packages/core-runtime/tests/unit/action-permission.test.ts @@ -1,11 +1,12 @@ -import { expect, it } from 'effect-rstest'; import { v1 } from '@authzed/authzed-node'; import { Effect, Schema } from 'effect'; -import { - SPICEDB_ROOT_ENV_PATH, - loadSpiceDbConfig, - parseSpiceDbConfig, -} from '../../src/permissions/config.ts'; +import { expect, it } from 'effect-rstest'; + +import { ActionPermissionCheckError } from '../../src/actions/errors.ts'; +import type { SpiceDbPermissionClientError } from '../../src/permissions/client.ts'; +import { spiceDbPermissionClientError } from '../../src/permissions/client.ts'; +import { SpiceDbConfigError } from '../../src/permissions/config-error.ts'; +import { SPICEDB_ROOT_ENV_PATH, loadSpiceDbConfig, parseSpiceDbConfig } from '../../src/permissions/config.ts'; import { SPICEDB_ACTION_OBJECT_TYPE, SPICEDB_CHECK_TIMEOUT_MS, @@ -17,10 +18,6 @@ import { makeActionPermissionService, toSpiceDbActionObjectId, } from '../../src/permissions/service.ts'; -import type { SpiceDbPermissionClientError } from '../../src/permissions/client.ts'; -import { spiceDbPermissionClientError } from '../../src/permissions/client.ts'; -import { ActionPermissionCheckError } from '../../src/actions/errors.ts'; -import { SpiceDbConfigError } from '../../src/permissions/config-error.ts'; import type { PermissionCheckClient } from '../../src/permissions/service.ts'; const input = { @@ -60,7 +57,10 @@ it.effect('loads the root SpiceDB environment independently of the invocation di }), ); process.chdir('/'); - const configuration = yield* loadSpiceDbConfig({ environment: {}, envPath: rootExamplePath }); + const configuration = yield* loadSpiceDbConfig({ + environment: {}, + envPath: rootExamplePath, + }); expect(SPICEDB_ROOT_ENV_PATH.endsWith('/app/.env')).toBe(true); expect(configuration).toEqual({ @@ -71,58 +71,57 @@ it.effect('loads the root SpiceDB environment independently of the invocation di }), ); -it.effect( - 'requires complete configuration and explicit secure or localhost-insecure transport', - () => - Effect.gen(function* requiresCompleteConfigurationAndExplicitSecureOrLocalhostinsecure() { - const validSecure = yield* parseSpiceDbConfig({ - SPICEDB_ENDPOINT: 'spicedb.internal.example:443', - SPICEDB_INSECURE: 'false', - SPICEDB_PRESHARED_KEY: 'test-key', - }); - const failures = yield* Effect.all( - [ - {}, - { - SPICEDB_ENDPOINT: 'localhost:50051', - SPICEDB_PRESHARED_KEY: 'test-key', - }, - { - SPICEDB_ENDPOINT: 'spicedb.internal.example:50051', - SPICEDB_INSECURE: 'true', - SPICEDB_PRESHARED_KEY: 'test-key', - }, - { - SPICEDB_ENDPOINT: 'https://spicedb.internal.example/path', - SPICEDB_INSECURE: 'false', - SPICEDB_PRESHARED_KEY: 'test-key', - }, - { - SPICEDB_ENDPOINT: 'spicedb.internal.example:443?credential=test-key', - SPICEDB_INSECURE: 'false', - SPICEDB_PRESHARED_KEY: 'test-key', - }, - { - SPICEDB_ENDPOINT: 'localhost:50051#fragment', - SPICEDB_INSECURE: 'true', - SPICEDB_PRESHARED_KEY: 'test-key', - }, - { - SPICEDB_ENDPOINT: 'localhost:50051', - SPICEDB_INSECURE: 'true', - SPICEDB_PRESHARED_KEY: ' ', - }, - ].map((environment) => Effect.flip(parseSpiceDbConfig(environment))), - ); +it.effect('requires complete configuration and explicit secure or localhost-insecure transport', () => + Effect.gen(function* requiresCompleteConfigurationAndExplicitSecureOrLocalhostinsecure() { + const validSecure = yield* parseSpiceDbConfig({ + SPICEDB_ENDPOINT: 'spicedb.internal.example:443', + SPICEDB_INSECURE: 'false', + SPICEDB_PRESHARED_KEY: 'test-key', + }); + const failures = yield* Effect.forEach( + [ + {}, + { + SPICEDB_ENDPOINT: 'localhost:50051', + SPICEDB_PRESHARED_KEY: 'test-key', + }, + { + SPICEDB_ENDPOINT: 'spicedb.internal.example:50051', + SPICEDB_INSECURE: 'true', + SPICEDB_PRESHARED_KEY: 'test-key', + }, + { + SPICEDB_ENDPOINT: 'https://spicedb.internal.example/path', + SPICEDB_INSECURE: 'false', + SPICEDB_PRESHARED_KEY: 'test-key', + }, + { + SPICEDB_ENDPOINT: 'spicedb.internal.example:443?credential=test-key', + SPICEDB_INSECURE: 'false', + SPICEDB_PRESHARED_KEY: 'test-key', + }, + { + SPICEDB_ENDPOINT: 'localhost:50051#fragment', + SPICEDB_INSECURE: 'true', + SPICEDB_PRESHARED_KEY: 'test-key', + }, + { + SPICEDB_ENDPOINT: 'localhost:50051', + SPICEDB_INSECURE: 'true', + SPICEDB_PRESHARED_KEY: ' ', + }, + ], + (environment) => Effect.flip(parseSpiceDbConfig(environment)), + ); - expect(validSecure).toEqual({ - endpoint: 'spicedb.internal.example:443', - insecureLocal: false, - preSharedKey: 'test-key', - }); - expect(failures.every(Schema.is(SpiceDbConfigError))).toBe(true); - expect(failures.some((failure) => failure.reason.includes('test-key'))).toBe(false); - }), + expect(validSecure).toEqual({ + endpoint: 'spicedb.internal.example:443', + insecureLocal: false, + preSharedKey: 'test-key', + }); + expect(failures.every(Schema.is(SpiceDbConfigError))).toBe(true); + expect(failures.some((failure) => failure.reason.includes('test-key'))).toBe(false); + }), ); it.effect('allows insecure transport only for the exact Zerops stage private endpoint', () => @@ -133,7 +132,7 @@ it.effect('allows insecure transport only for the exact Zerops stage private end SPICEDB_PRESHARED_KEY: 'test-key', ULTRAMODERN_DEPLOYMENT_ENVIRONMENT: 'stage', }); - const rejected = yield* Effect.all( + const rejected = yield* Effect.forEach( [ { SPICEDB_ENDPOINT: 'spicedb:50051', @@ -152,7 +151,8 @@ it.effect('allows insecure transport only for the exact Zerops stage private end SPICEDB_PRESHARED_KEY: 'test-key', ULTRAMODERN_DEPLOYMENT_ENVIRONMENT: 'production', }, - ].map((environment) => Effect.flip(parseSpiceDbConfig(environment))), + ], + (environment) => Effect.flip(parseSpiceDbConfig(environment)), ); expect(stage).toEqual({ @@ -165,128 +165,120 @@ it.effect('allows insecure transport only for the exact Zerops stage private end }), ); -it.effect( - 'losslessly maps Action keys and exact principal identities using fully consistent requests', - () => - Effect.gen(function* losslesslyMapsActionKeysAndExactPrincipalIdentities() { - const requests: v1.CheckPermissionRequest[] = []; - const service = makeActionPermissionService( - makeClient([response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION)], requests), - ); +it.effect('losslessly maps Action keys and exact principal identities using fully consistent requests', () => + Effect.gen(function* losslesslyMapsActionKeysAndExactPrincipalIdentities() { + const requests: v1.CheckPermissionRequest[] = []; + const service = makeActionPermissionService( + makeClient([response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION)], requests), + ); - const decision = yield* service.checkActionPermission(input); + const decision = yield* service.checkActionPermission(input); - expect(decision).toBe('allowed'); - expect(requests.length).toBe(1); - expect(requests[0]?.resource).toEqual({ - objectId: toSpiceDbActionObjectId(input.actionKey), - objectType: SPICEDB_ACTION_OBJECT_TYPE, - }); - expect(toSpiceDbActionObjectId(input.actionKey)).toBe('ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU'); - expect(toSpiceDbActionObjectId('inventory.stock.reserve')).not.toBe( - toSpiceDbActionObjectId('inventory-stock-reserve'), - ); - expect(requests[0]?.subject?.object).toEqual({ - objectId: input.principalId, - objectType: SPICEDB_PRINCIPAL_OBJECT_TYPE, + expect(decision).toBe('allowed'); + expect(requests.length).toBe(1); + expect(requests[0]?.resource).toEqual({ + objectId: toSpiceDbActionObjectId(input.actionKey), + objectType: SPICEDB_ACTION_OBJECT_TYPE, + }); + expect(toSpiceDbActionObjectId(input.actionKey)).toBe('ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU'); + expect(toSpiceDbActionObjectId('inventory.stock.reserve')).not.toBe( + toSpiceDbActionObjectId('inventory-stock-reserve'), + ); + expect(requests[0]?.subject?.object).toEqual({ + objectId: input.principalId, + objectType: SPICEDB_PRINCIPAL_OBJECT_TYPE, + }); + expect(requests[0]?.permission).toBe(SPICEDB_EXECUTE_PERMISSION); + for (const request of requests) { + expect(request.consistency?.requirement).toEqual({ + fullyConsistent: true, + oneofKind: 'fullyConsistent', }); - expect(requests[0]?.permission).toBe(SPICEDB_EXECUTE_PERMISSION); - for (const request of requests) { - expect(request.consistency?.requirement).toEqual({ - fullyConsistent: true, - oneofKind: 'fullyConsistent', - }); - } - }), + } + }), ); -it.effect( - 'classifies fully consistent execute permission as allowed or denied with one check', - () => - Effect.gen(function* classifiesFullyConsistentExecutePermissionAsAllowedOr() { - const deniedRequests: v1.CheckPermissionRequest[] = []; - const allowed = makeActionPermissionService( - makeClient([response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION)]), - ); - const denied = makeActionPermissionService( - makeClient( - [response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION)], - deniedRequests, - ), - ); +it.effect('classifies fully consistent execute permission as allowed or denied with one check', () => + Effect.gen(function* classifiesFullyConsistentExecutePermissionAsAllowedOr() { + const deniedRequests: v1.CheckPermissionRequest[] = []; + const allowed = makeActionPermissionService( + makeClient([response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION)]), + ); + const denied = makeActionPermissionService( + makeClient([response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION)], deniedRequests), + ); - expect(yield* allowed.checkActionPermission(input)).toBe('allowed'); - expect(yield* denied.checkActionPermission(input)).toBe('denied'); - expect(deniedRequests.length).toBe(1); - }), + expect(yield* allowed.checkActionPermission(input)).toBe('allowed'); + expect(yield* denied.checkActionPermission(input)).toBe('denied'); + expect(deniedRequests.length).toBe(1); + }), ); -it.effect( - 'report-only compatibility distinguishes missing policy from an explicit restriction', - () => - Effect.gen(function* reportonlyCompatibilityDistinguishesMissingPolicyFromAnExplicit() { - const nowEpochMs = Date.parse('2026-09-10T00:00:00.000Z'); - const events: unknown[] = []; - const rollout = { - activatedAtEpochMs: nowEpochMs - 1000, - compatibilityEntrypoints: new Set([input.actionKey]), - expiresAtEpochMs: nowEpochMs + 1000, - inventoryHash: 'inventory', - mode: 'report_only' as const, - sourceRevision: 'revision', - }; - const missingRequests: v1.CheckPermissionRequest[] = []; - const missing = makeActionPermissionService( - makeClient( - [ - response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), - response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), - ], - missingRequests, - ), - { - emit: (event) => { - events.push(event); - }, - nowEpochMs: () => nowEpochMs, - rollout, +it.effect('report-only compatibility distinguishes missing policy from an explicit restriction', () => + Effect.gen(function* reportonlyCompatibilityDistinguishesMissingPolicyFromAnExplicit() { + const nowEpochMs = Date.parse('2026-09-10T00:00:00.000Z'); + const events: unknown[] = []; + const rollout = { + activatedAtEpochMs: nowEpochMs - 1000, + compatibilityEntrypoints: new Set([input.actionKey]), + expiresAtEpochMs: nowEpochMs + 1000, + inventoryHash: 'inventory', + mode: 'report_only' as const, + sourceRevision: 'revision', + }; + const missingRequests: v1.CheckPermissionRequest[] = []; + const missing = makeActionPermissionService( + makeClient( + [ + response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), + response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), + ], + missingRequests, + ), + { + emit: (event) => { + events.push(event); }, - ); - expect(yield* missing.checkActionPermission(input)).toBe('allowed'); - expect(missingRequests.map(({ permission }) => permission)).toEqual([ - SPICEDB_EXECUTE_PERMISSION, - SPICEDB_RESTRICTION_PERMISSION, - ]); - expect(events.length).toBe(1); + nowEpochMs: () => nowEpochMs, + rollout, + }, + ); + expect(yield* missing.checkActionPermission(input)).toBe('allowed'); + expect(missingRequests.map(({ permission }) => permission)).toEqual([ + SPICEDB_EXECUTE_PERMISSION, + SPICEDB_RESTRICTION_PERMISSION, + ]); + expect(events.length).toBe(1); - const restricted = makeActionPermissionService( - makeClient([ - response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), - response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION), - ]), - { emit: () => expect.unreachable(), nowEpochMs: () => nowEpochMs, rollout }, - ); - expect(yield* restricted.checkActionPermission(input)).toBe('denied'); - }), + const restricted = makeActionPermissionService( + makeClient([ + response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), + response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION), + ]), + { + emit: () => expect.unreachable(), + nowEpochMs: () => nowEpochMs, + rollout, + }, + ); + expect(yield* restricted.checkActionPermission(input)).toBe('denied'); + }), ); it.effect('fails closed for conditional, unspecified, malformed, and client failures', () => Effect.gen(function* failsClosedForConditionalUnspecifiedMalformedAndClient() { - const failures = yield* Effect.all( + const failures = yield* Effect.forEach( [ makeClient([response(v1.CheckPermissionResponse_Permissionship.CONDITIONAL_PERMISSION)]), makeClient([response(v1.CheckPermissionResponse_Permissionship.UNSPECIFIED)]), makeClient([Effect.fail(spiceDbPermissionClientError())]), makeClient([ Effect.fail( - spiceDbPermissionClientError( - new Error('ontos-local-development-key unavailable at internal host'), - ), + spiceDbPermissionClientError(new Error('ontos-local-development-key unavailable at internal host')), ), ]), - ].map((client) => - Effect.flip(makeActionPermissionService(client).checkActionPermission(input)), - ), + ], + (client) => Effect.flip(makeActionPermissionService(client).checkActionPermission(input)), ); for (const failure of failures) { @@ -298,37 +290,34 @@ it.effect('fails closed for conditional, unspecified, malformed, and client fail }), ); -it.effect( - 'constructs the live client with a bounded deadline and finalizes it with the scope', - () => - Effect.gen(function* constructsTheLiveClientWithABoundedDeadline() { - let finalized = false; - let observedTimeout = 0; - const configuration = { - endpoint: 'localhost:50051', - insecureLocal: true, - preSharedKey: 'test-key', - } as const; +it.effect('constructs the live client with a bounded deadline and finalizes it with the scope', () => + Effect.gen(function* constructsTheLiveClientWithABoundedDeadline() { + let finalized = false; + let observedTimeout = 0; + const configuration = { + endpoint: 'localhost:50051', + insecureLocal: true, + preSharedKey: 'test-key', + } as const; - yield* Effect.scoped( - makeActionPermissionLive( - (_configuration, timeoutMilliseconds) => { - observedTimeout = timeoutMilliseconds; - return { - checkPermission: () => - response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), - close: () => { - finalized = true; - }, - }; - }, - () => Effect.succeed(configuration), - ).pipe(Effect.flatMap((service) => service.checkActionPermission(input))), - ); + yield* Effect.scoped( + makeActionPermissionLive( + (_configuration, timeoutMilliseconds) => { + observedTimeout = timeoutMilliseconds; + return { + checkPermission: () => response(v1.CheckPermissionResponse_Permissionship.NO_PERMISSION), + close: () => { + finalized = true; + }, + }; + }, + () => Effect.succeed(configuration), + ).pipe(Effect.flatMap((service) => service.checkActionPermission(input))), + ); - expect(observedTimeout).toBe(SPICEDB_CHECK_TIMEOUT_MS); - expect(finalized).toBe(true); - }), + expect(observedTimeout).toBe(SPICEDB_CHECK_TIMEOUT_MS); + expect(finalized).toBe(true); + }), ); it.effect('turns missing live configuration into a fail-closed permission service', () => @@ -356,8 +345,7 @@ it.effect('finalizes an acquired client even when its scoped use fails', () => const failure = yield* Effect.flip( Effect.scoped( acquirePermissionClientResource(() => ({ - checkPermission: () => - Effect.fail(spiceDbPermissionClientError(new Error('unavailable'))), + checkPermission: () => Effect.fail(spiceDbPermissionClientError(new Error('unavailable'))), close: () => { finalized = true; }, diff --git a/app/packages/core-runtime/tests/unit/action-policy.test.ts b/app/packages/core-runtime/tests/unit/action-policy.test.ts index 1c9e6b557..f0da455aa 100644 --- a/app/packages/core-runtime/tests/unit/action-policy.test.ts +++ b/app/packages/core-runtime/tests/unit/action-policy.test.ts @@ -1,11 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Predicate } from 'effect'; -import { - defineGlobalPolicy, - defineMicroverticalPolicy, - denyPolicy, - isActionPolicy, -} from '../../src/actions/policy.ts'; +import { expect, it } from 'effect-rstest'; + +import { defineGlobalPolicy, defineMicroverticalPolicy, denyPolicy, isActionPolicy } from '../../src/actions/policy.ts'; import type { ActionPolicyEvaluatorInput } from '../../src/actions/policy.ts'; const input = { @@ -39,7 +35,10 @@ it('defines immutable global and owner-local Policy references', () => { policyKey: 'inventory.stock.available.v1', }); - expect({ policyKey: globalPolicy.policyKey, scope: globalPolicy.scope }).toEqual({ + expect({ + policyKey: globalPolicy.policyKey, + scope: globalPolicy.scope, + }).toEqual({ policyKey: 'global.tenant-active.v1', scope: 'global', }); @@ -70,10 +69,7 @@ it.effect( policyKey: 'global.allowed.v1', }); const denied = defineMicroverticalPolicy({ - evaluate: () => - Effect.fail( - denyPolicy('stock_unavailable', 'Requested stock is unavailable — retry later'), - ), + evaluate: () => Effect.fail(denyPolicy('stock_unavailable', 'Requested stock is unavailable — retry later')), owningModuleKey: 'inventory.stock', policyKey: 'inventory.stock.available.v1', }); @@ -91,9 +87,7 @@ it.effect( ); it('rejects empty stable identifiers and denial messages', () => { - expect(() => defineGlobalPolicy({ evaluate: () => Effect.void, policyKey: ' ' })).toThrow( - TypeError, - ); + expect(() => defineGlobalPolicy({ evaluate: () => Effect.void, policyKey: ' ' })).toThrow(TypeError); expect(() => denyPolicy('', 'Safe message')).toThrow(TypeError); expect(() => denyPolicy('stable_code', '')).toThrow(TypeError); }); diff --git a/app/packages/core-runtime/tests/unit/action-public-surface.test.ts b/app/packages/core-runtime/tests/unit/action-public-surface.test.ts index a7ced6522..63355065f 100644 --- a/app/packages/core-runtime/tests/unit/action-public-surface.test.ts +++ b/app/packages/core-runtime/tests/unit/action-public-surface.test.ts @@ -1,8 +1,6 @@ import { expect, it } from 'effect-rstest'; -import { - computeActionRequestHash, - computeCanonicalValueHash, -} from '../../src/actions/repository.ts'; + +import { computeActionRequestHash, computeCanonicalValueHash } from '../../src/actions/repository.ts'; import type { ResolvedReadPermissionTarget } from '../../src/index.ts'; import * as publicSurface from '../../src/index.ts'; @@ -71,9 +69,7 @@ it('rejects cyclic values instead of producing an unstable request hash', () => it('canonical hashing distinguishes literal objects from internal value types', () => { expect(computeCanonicalValueHash()).not.toBe(computeCanonicalValueHash({ $undefined: true })); - expect(computeCanonicalValueHash(Number.NaN)).not.toBe( - computeCanonicalValueHash({ $number: 'NaN' }), - ); + expect(computeCanonicalValueHash(Number.NaN)).not.toBe(computeCanonicalValueHash({ $number: 'NaN' })); expect(computeCanonicalValueHash(-0)).not.toBe(computeCanonicalValueHash(0)); }); diff --git a/app/packages/core-runtime/tests/unit/action-runtime.test.ts b/app/packages/core-runtime/tests/unit/action-runtime.test.ts index 3c99a6040..09b860196 100644 --- a/app/packages/core-runtime/tests/unit/action-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/action-runtime.test.ts @@ -1,32 +1,14 @@ +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Option, Predicate, Schema, Struct } from 'effect'; import { expect, it } from 'effect-rstest'; -import { - Cause, - DateTime, - Deferred, - Effect, - Exit, - Fiber, - Option, - Predicate, - Schema, - Struct, -} from 'effect'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; -import { - defineAction, - defineActionResourcePermission, - getActionHandler, -} from '../../src/actions/definition.ts'; + +import { defineAction, defineActionResourcePermission, getActionHandler } from '../../src/actions/definition.ts'; import { ActionInvocationPersistenceError, ActionPermissionCheckError, ActionTransactionError, } from '../../src/actions/errors.ts'; -import { - defineGlobalPolicy, - defineMicroverticalPolicy, - denyPolicy, -} from '../../src/actions/policy.ts'; +import { defineGlobalPolicy, defineMicroverticalPolicy, denyPolicy } from '../../src/actions/policy.ts'; import type { ActionInvocationRecord, ActionRepositoryService, @@ -50,23 +32,14 @@ import { CoreDatabase } from '../../src/db/client.ts'; import { recordSupportImpersonationAction } from '../../src/modules/actions/record-support-impersonation.action.ts'; import { makeModuleEntrypointGateway } from '../../src/modules/module-entrypoint-gateway.ts'; import type { ModuleEntrypointDescriptor } from '../../src/modules/module-entrypoint.ts'; -import { - defineSystemModuleEntrypoint, - defineTenantModuleEntrypoint, -} from '../../src/modules/module-entrypoint.ts'; +import { defineSystemModuleEntrypoint, defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { ModuleStateCheckUnavailableError, ModuleStateDeniedError, } from '../../src/modules/module-state-gate-errors.ts'; -import { - checkModuleEntrypoint, - makeModuleStateSnapshot, -} from '../../src/modules/module-state-gate.ts'; +import { checkModuleEntrypoint, makeModuleStateSnapshot } from '../../src/modules/module-state-gate.ts'; import type { TenantModuleState } from '../../src/modules/tenant-module-state-service.ts'; -import type { - ActionPermissionDecision, - CheckActionPermissionInput, -} from '../../src/permissions/service.ts'; +import type { ActionPermissionDecision, CheckActionPermissionInput } from '../../src/permissions/service.ts'; import { testOperationalScopeResolver } from '../fixtures/operational-scope.ts'; import { makeTestDatabase } from '../support/database.ts'; @@ -97,10 +70,8 @@ const expectSameJson = (actual: RetainedCauseError, expected: RetainedCauseError const completionTime = () => DateTime.toDateUtc(DateTime.makeUnsafe(0)); -const forEachSequential = ( - items: readonly Item[], - run: (item: Item) => Effect.Effect, -) => Effect.forEach(items, run, { discard: true }); +const forEachSequential = (items: readonly Item[], run: (item: Item) => Effect.Effect) => + Effect.forEach(items, run, { discard: true }); const unusedPrincipalManagementOperation = () => Effect.die('The ambient PrincipalManagementRepository must not be used by the Action runtime'); @@ -250,14 +221,19 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} let installedLegalEntityId: string = principal.legalEntityId; const commitTransaction = () => Effect.gen(function* commitTransactionEffect() { - const defaultCommitCodes = { 'commit-definite': '40001', uncertain: '08007' }; + const defaultCommitCodes = { + 'commit-definite': '40001', + uncertain: '08007', + }; const defaultCode = options.transactionMode === 'uncertain' || options.transactionMode === 'commit-definite' ? defaultCommitCodes[options.transactionMode] : undefined; const code = options.commitFailureCode ?? defaultCode; if (code !== undefined) { - return yield* new SqlError({ reason: new ConnectionError({ cause: { code } }) }); + return yield* new SqlError({ + reason: new ConnectionError({ cause: { code } }), + }); } if (options.commit !== undefined) { return yield* options.commit; @@ -277,7 +253,9 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} transactionCount += 1; if (options.transactionMode === 'definite-failure') { return yield* new SqlError({ - reason: new ConnectionError({ cause: new Error('transaction unavailable') }), + reason: new ConnectionError({ + cause: new Error('transaction unavailable'), + }), }); } } @@ -285,7 +263,12 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} return yield* commitTransaction(); } if (text.includes('current_setting')) { - return [{ legal_entity_id: installedLegalEntityId, tenant_id: installedTenantId }]; + return [ + { + legal_entity_id: installedLegalEntityId, + tenant_id: installedTenantId, + }, + ]; } if (text.startsWith('select')) { return [{ authBindingId: principal.authBindingId }]; @@ -327,9 +310,7 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} ); } const availableState: TenantModuleState = - options.moduleState === undefined || options.moduleState === 'missing' - ? 'active' - : options.moduleState; + options.moduleState === undefined || options.moduleState === 'missing' ? 'active' : options.moduleState; return Effect.succeed( makeModuleStateSnapshot( tenantId, @@ -364,56 +345,55 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} return Effect.void; }, } as const; - const runtime = makeActionRuntime( - database, - repository, - permission, - testOperationalScopeResolver, - { - contextAccess: { - legalEntities: (input) => { - legalEntityChecks.push(input); - return Effect.succeed( - input.legalEntityIds.map((key) => ({ - decision: options.legalEntityPermissionDecision ?? ('allowed' as const), - key, - })), - ); - }, - modules: () => Effect.succeed([]), - resources: (input) => { - resourceChecks.push(input); - return Effect.succeed( - input.resources.map(({ moduleId, resourceId, resourceType }) => ({ - decision: options.resourcePermissionDecision ?? ('allowed' as const), - key: `${moduleId}:${resourceType}:${resourceId}`, - })), - ); - }, - tenants: (input) => { - tenantChecks.push(input); - return Effect.succeed( - input.tenantIds.map((key) => ({ - decision: options.tenantPermissionDecision ?? ('allowed' as const), - key, - })), - ); - }, + const runtime = makeActionRuntime(database, repository, permission, testOperationalScopeResolver, { + contextAccess: { + legalEntities: (input) => { + legalEntityChecks.push(input); + return Effect.succeed( + input.legalEntityIds.map((key) => ({ + decision: options.legalEntityPermissionDecision ?? ('allowed' as const), + key, + })), + ); }, - moduleEntrypointGateway: makeModuleEntrypointGateway(moduleStateGate), - moduleStateGate, - onStage: (stage) => { - stages.push(stage); + modules: () => Effect.succeed([]), + resources: (input) => { + resourceChecks.push(input); + return Effect.succeed( + input.resources.map(({ moduleId, resourceId, resourceType }) => ({ + decision: options.resourcePermissionDecision ?? ('allowed' as const), + key: `${moduleId}:${resourceType}:${resourceId}`, + })), + ); }, - resolveHandler: (action) => { - handlerResolutionCount += 1; - return getActionHandler(action); + tenants: (input) => { + tenantChecks.push(input); + return Effect.succeed( + input.tenantIds.map((key) => ({ + decision: options.tenantPermissionDecision ?? ('allowed' as const), + key, + })), + ); }, }, - ); + moduleEntrypointGateway: makeModuleEntrypointGateway(moduleStateGate), + moduleStateGate, + onStage: (stage) => { + stages.push(stage); + }, + resolveHandler: (action) => { + handlerResolutionCount += 1; + return getActionHandler(action); + }, + }); return { - counts: () => ({ createCount, lockCount, transactionCount, transitionCount }), + counts: () => ({ + createCount, + lockCount, + transactionCount, + transitionCount, + }), finalized, flushed, gateCounts: () => ({ @@ -435,7 +415,9 @@ const makeHarness = Effect.fn(function* makeHarness(options: HarnessOptions = {} const makeRepositoryFailures = Effect.fn(function* testProgram1() { const cause = new SqlError({ - reason: new ConnectionError({ cause: new Error('private repository defect') }), + reason: new ConnectionError({ + cause: new Error('private repository defect'), + }), }); const executor = yield* makeTestDatabase(() => Effect.fail(cause)); const repository = makeActionRepository(); @@ -469,18 +451,12 @@ it.effect( 'repository constructors retain original causes across Effect Cause propagation', Effect.fn(function* testProgram2() { const { cause, persistenceFailure, transactionFailure } = yield* makeRepositoryFailures(); - const propagatedTransaction = yield* Effect.flip( - Effect.failCause(Cause.fail(transactionFailure)), - ); - const propagatedPersistence = yield* Effect.flip( - Effect.failCause(Cause.fail(persistenceFailure)), - ); + const propagatedTransaction = yield* Effect.flip(Effect.failCause(Cause.fail(transactionFailure))); + const propagatedPersistence = yield* Effect.flip(Effect.failCause(Cause.fail(persistenceFailure))); expect(propagatedTransaction).toBe(transactionFailure); expect(propagatedPersistence).toBe(persistenceFailure); expect(getActionTransactionFailureCause(propagatedTransaction)).toEqual(Cause.die(cause)); - expect(getActionInvocationPersistenceFailureCause(propagatedPersistence)).toEqual( - Cause.die(cause), - ); + expect(getActionInvocationPersistenceFailureCause(propagatedPersistence)).toEqual(Cause.die(cause)); }), ); @@ -509,21 +485,14 @@ it.effect( expect(Reflect.ownKeys(persistenceFailure)).toEqual(Reflect.ownKeys(publicPersistence)); expectSameJson(transactionFailure, publicTransaction); expectSameJson(persistenceFailure, publicPersistence); - const encodedTransactionFailure = - yield* Schema.encodeEffect(ActionTransactionError)(transactionFailure); - expect(Schema.is(Schema.toEncoded(ActionTransactionError))(encodedTransactionFailure)).toBe( - true, - ); + const encodedTransactionFailure = yield* Schema.encodeEffect(ActionTransactionError)(transactionFailure); + expect(Schema.is(Schema.toEncoded(ActionTransactionError))(encodedTransactionFailure)).toBe(true); expect(Struct.omit(encodedTransactionFailure, ['_tag'])).toEqual({ code: transactionFailure.code, reason: transactionFailure.reason, }); - const encodedPersistenceFailure = yield* Schema.encodeEffect(ActionInvocationPersistenceError)( - persistenceFailure, - ); - expect( - Schema.is(Schema.toEncoded(ActionInvocationPersistenceError))(encodedPersistenceFailure), - ).toBe(true); + const encodedPersistenceFailure = yield* Schema.encodeEffect(ActionInvocationPersistenceError)(persistenceFailure); + expect(Schema.is(Schema.toEncoded(ActionInvocationPersistenceError))(encodedPersistenceFailure)).toBe(true); expect(Struct.omit(encodedPersistenceFailure, ['_tag'])).toEqual({ code: persistenceFailure.code, reason: persistenceFailure.reason, @@ -555,7 +524,10 @@ it('repository cause readers reject foreign objects carrying the former cause pr const registration = () => defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.change', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -564,7 +536,10 @@ const registration = () => }, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'shell.counter.change', moduleKey: 'core.shell', role: 'action', @@ -654,7 +629,10 @@ it.effect( }); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'shell.temporal.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'shell.temporal.v1', + }, actionKey: 'shell.temporal.change', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -708,9 +686,7 @@ it.effect( ]); expect(DateTime.formatIso(result.occurredAt)).toBe(occurredAt); expect(Option.isNone(result.note)).toBe(true); - expect(harness.flushed[0]?.resultHash).toBe( - computeCanonicalValueHash({ note: null, occurredAt }), - ); + expect(harness.flushed[0]?.resultHash).toBe(computeCanonicalValueHash({ note: null, occurredAt })); }), ); @@ -719,15 +695,13 @@ it.effect( Effect.fn(function* testProgram6() { const recoveryPrincipal = yield* supportRecoveryPrincipalContextResolverFromRepository({ load: () => - Effect.succeed( - Option.some({ - bindingPrincipalId: principal.principalId, - bindingTenantId: principal.tenantId, - principalKind: 'human' as const, - principalTenantId: principal.tenantId, - tenantId: principal.tenantId, - }), - ), + Effect.succeedSome({ + bindingPrincipalId: principal.principalId, + bindingTenantId: principal.tenantId, + principalKind: 'human' as const, + principalTenantId: principal.tenantId, + tenantId: principal.tenantId, + }), }).resolveStoppedImpersonation({ originalAuthBindingId: principal.authBindingId, originalPrincipalId: principal.principalId, @@ -755,7 +729,10 @@ it.effect( .pipe(providePrincipalManagementRepository); expect(result).toEqual({ checkpoint: 'stopped', recorded: true }); - expect(harness.permissionCounts()).toEqual({ permissionCheckCount: 1, rejectionCount: 0 }); + expect(harness.permissionCounts()).toEqual({ + permissionCheckCount: 1, + rejectionCount: 0, + }); const deniedHarness = yield* makeHarness({ permissionDecision: 'denied' }); const denied = yield* Effect.flip( @@ -814,24 +791,19 @@ it.effect( 'fails business Actions closed before invocation, permission, Policy, or handler access', Effect.fn(function* testProgram7() { yield* forEachSequential( - ( - [ - 'inactive', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - 'missing', - ] as const - ).map((state, index) => [index, state] as const), + (['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived', 'missing'] as const).map( + (state, index) => [index, state] as const, + ), Effect.fn(function* testProgram8([index, state]) { let handlerCalls = 0; let policyCalls = 0; const harness = yield* makeHarness({ moduleState: state }); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: `inventory.stock.reserve-state-${index}`, auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -901,7 +873,10 @@ it.effect( Effect.fn(function* testProgram9() { const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: 'inventory.stock.reserve-locked', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -977,9 +952,7 @@ it.effect( }); expect(result).toEqual({ total: 2 }); - expect( - harness.stages.indexOf('permission_checked') < harness.stages.indexOf('policy_boundary'), - ).toBe(true); + expect(harness.stages.indexOf('permission_checked') < harness.stages.indexOf('policy_boundary')).toBe(true); expect(harness.counts().transitionCount).toBe(1); expect(harness.counts().transactionCount).toBe(1); }), @@ -990,7 +963,10 @@ it.effect( Effect.fn(function* testProgram11() { const tenantAuthorizedRegistration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'identity.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'identity.read.v1', + }, actionKey: 'core.identity.tenant-authorized', auditProfile: 'sensitive', domainErrorSchema: Schema.Never, @@ -1070,7 +1046,10 @@ it.effect( const actionKey = `party.registry.permission-${index}`; const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'party.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'party.read.v1', + }, actionKey, auditProfile: 'sensitive', domainErrorSchema: Schema.Never, @@ -1124,7 +1103,10 @@ it.effect( Effect.fn(function* testProgram15() { const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'identity.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'identity.read.v1', + }, actionKey: 'core.identity.rotate-managed-key', auditProfile: 'sensitive', domainErrorSchema: Schema.Never, @@ -1239,7 +1221,9 @@ it.effect( targetResourceType: 'legal_entity', }; - const denied = yield* makeHarness({ legalEntityPermissionDecision: 'denied' }); + const denied = yield* makeHarness({ + legalEntityPermissionDecision: 'denied', + }); const failure = yield* Effect.flip( denied.runtime.runAction({ payload: undefined, @@ -1267,7 +1251,9 @@ it.effect( }); expect(denied.stages.at(-1)).toBe('permission_checked'); - const unavailable = yield* makeHarness({ legalEntityPermissionDecision: 'unavailable' }); + const unavailable = yield* makeHarness({ + legalEntityPermissionDecision: 'unavailable', + }); const unavailableFailure = yield* Effect.flip( unavailable.runtime.runAction({ payload: undefined, @@ -1298,9 +1284,7 @@ it.effect( correlationId: 'correlation-legal-entity-denied', idempotencyKey: 'legal-entity-allowed', }); - expect( - allowed.stages.indexOf('permission_checked') < allowed.stages.indexOf('policy_boundary'), - ).toBe(true); + expect(allowed.stages.indexOf('permission_checked') < allowed.stages.indexOf('policy_boundary')).toBe(true); }), ); @@ -1412,7 +1396,9 @@ it.effect( targetResourceType: 'counterparty', }); - const unavailable = yield* makeHarness({ resourcePermissionDecision: 'unavailable' }); + const unavailable = yield* makeHarness({ + resourcePermissionDecision: 'unavailable', + }); const unavailableFailure = yield* Effect.flip( unavailable.runtime.runAction({ payload: { counterpartyId: 'counterparty-1' }, @@ -1437,7 +1423,10 @@ it.effect( const harness = yield* makeHarness({ permissionDecision: 'denied' }); const deniedRegistration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.denied', auditProfile: 'sensitive', domainErrorSchema: Schema.Never, @@ -1610,7 +1599,10 @@ it.effect( }); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'inventory.stock.policy-allowed', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -1676,9 +1668,7 @@ it.effect( defineGlobalPolicy<{ readonly amount: number }>({ evaluate: () => { observed.push('denied'); - return Effect.fail( - denyPolicy('counter_locked', 'Counter changes are locked — try later'), - ); + return Effect.fail(denyPolicy('counter_locked', 'Counter changes are locked — try later')); }, policyKey: 'global.counter-locked.v1', }), @@ -1692,7 +1682,10 @@ it.effect( ] as const; const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.policy-denied', auditProfile: 'sensitive', domainErrorSchema: Schema.Never, @@ -1770,10 +1763,7 @@ it.effect( it.effect( 'sanitizes Policy defects and interrupts without finalizing', Effect.fn(function* testProgram23() { - const evaluators = [ - () => Effect.die('secret evaluator defect'), - () => Effect.interrupt, - ] as const; + const evaluators = [() => Effect.die('secret evaluator defect'), () => Effect.interrupt] as const; yield* forEachSequential( evaluators.map((evaluate, index) => [index, evaluate] as const), @@ -1852,7 +1842,10 @@ it.effect( }); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.policy-persistence-failure', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -1937,7 +1930,10 @@ it.effect( }); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.fresh-policy', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -2033,9 +2029,7 @@ it.effect( expect(Predicate.isTagged(invalidPrincipal, 'ActionTrustedContextValidationError')).toBe(true); expect(Predicate.isTagged(missingKey, 'ActionIdempotencyKeyRequired')).toBe(true); - expect(Predicate.isTagged(forgedSystemPrincipal, 'ActionTrustedContextValidationError')).toBe( - true, - ); + expect(Predicate.isTagged(forgedSystemPrincipal, 'ActionTrustedContextValidationError')).toBe(true); expect(harness.counts().createCount).toBe(0); }), ); @@ -2061,7 +2055,10 @@ it.effect( }); const rejected = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.reject', auditProfile: 'standard', domainErrorSchema: DomainRejected, @@ -2122,7 +2119,10 @@ it.effect( const defectHarness = yield* makeHarness(); const defective = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.defect', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -2159,7 +2159,10 @@ it.effect( const resultHarness = yield* makeHarness(); const invalidResult = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.invalid-result', auditProfile: 'standard', domainErrorSchema: Schema.Never, @@ -2214,12 +2217,9 @@ it.effect( reason: Schema.String, }); type DeclaredDomainErrorSelf = typeof DeclaredDomainErrorContract.Type; - const DeclaredDomainError = Schema.TaggedError()( - 'DeclaredDomainError', - { - reason: Schema.String, - }, - ); + const DeclaredDomainError = Schema.TaggedError()('DeclaredDomainError', { + reason: Schema.String, + }); const undeclaredDomainError = new DeclaredDomainError({ reason: 'secret undeclared failure', }); @@ -2229,7 +2229,10 @@ it.effect( const harness = yield* makeHarness(); const action = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'counter.read.v1', + }, actionKey: 'shell.counter.undeclared-error', auditProfile: 'standard', domainErrorSchema: DeclaredDomainError, @@ -2307,7 +2310,9 @@ it.effect( }), ); - const definite = yield* makeHarness({ transactionMode: 'definite-failure' }); + const definite = yield* makeHarness({ + transactionMode: 'definite-failure', + }); const definiteError = yield* Effect.flip( definite.runtime.runAction({ payload: { amount: 1 }, @@ -2327,7 +2332,9 @@ it.effect( }), ); - const definiteCommit = yield* makeHarness({ transactionMode: 'commit-definite' }); + const definiteCommit = yield* makeHarness({ + transactionMode: 'commit-definite', + }); const definiteCommitError = yield* Effect.flip( definiteCommit.runtime.runAction({ payload: { amount: 1 }, @@ -2338,20 +2345,19 @@ it.effect( ); const acknowledgementFailureCodes = ['ETIMEDOUT', 'ECONNABORTED', 'ENETRESET', '08007']; - const acknowledgementErrors = yield* Effect.all( - acknowledgementFailureCodes.map( - Effect.fn(function* testProgram35(code) { - const harness = yield* makeHarness({ commitFailureCode: code }); - return yield* Effect.flip( - harness.runtime.runAction({ - payload: { amount: 1 }, - principal, - registration: registration(), - transport: transport(`uncertain-${code}`), - }), - ); - }), - ), + const acknowledgementErrors = yield* Effect.forEach( + acknowledgementFailureCodes, + Effect.fn(function* testProgram35(code) { + const harness = yield* makeHarness({ commitFailureCode: code }); + return yield* Effect.flip( + harness.runtime.runAction({ + payload: { amount: 1 }, + principal, + registration: registration(), + transport: transport(`uncertain-${code}`), + }), + ); + }), { concurrency: 'unbounded' }, ); @@ -2383,9 +2389,7 @@ it.effect( const commitStarted = Deferred.makeUnsafe(); const commitSettlement = Deferred.makeUnsafe(); const harness = yield* makeHarness({ - commit: Deferred.succeed(commitStarted, null).pipe( - Effect.andThen(Deferred.await(commitSettlement)), - ), + commit: Deferred.succeed(commitStarted, null).pipe(Effect.andThen(Deferred.await(commitSettlement))), }); const actionFiber = yield* harness.runtime @@ -2427,7 +2431,10 @@ it.effect( status: 'running', }, }); - const openResolution = yield* open.runtime.resolveActionCommit({ invocationId, principal }); + const openResolution = yield* open.runtime.resolveActionCommit({ + invocationId, + principal, + }); const committed = yield* makeHarness({ createRecord: { @@ -2437,9 +2444,7 @@ it.effect( status: 'succeeded', }, }); - const committedResolution = yield* Effect.flip( - committed.runtime.resolveActionCommit({ invocationId, principal }), - ); + const committedResolution = yield* Effect.flip(committed.runtime.resolveActionCommit({ invocationId, principal })); const unavailable = yield* makeHarness({ createRecord: { @@ -2502,7 +2507,10 @@ it.effect( const microvertical = yield* makeHarness(); const moduleRegistration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'stock.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'stock.read.v1', + }, actionKey: 'inventory.stock.reserve', auditProfile: 'standard', domainErrorSchema: Schema.Never, diff --git a/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts b/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts index 86ff33f02..05803ecbb 100644 --- a/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts +++ b/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { defineAction } from '../../src/actions/definition.ts'; import { defineGlobalPolicy, denyPolicy } from '../../src/actions/policy.ts'; import { ACTION_RUNTIME_STAGES } from '../../src/actions/runtime.ts'; @@ -16,14 +17,22 @@ const principal = { const lifecycleAction = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'test.counter.read.v1' }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'test.counter.read.v1', + }, actionKey: 'test.counter.increment', auditProfile: 'standard', domainErrorSchema: Schema.Never, - domainEvents: { 'test.counter.incremented.v1': Schema.Struct({ amount: Schema.Finite }) }, + domainEvents: { + 'test.counter.incremented.v1': Schema.Struct({ amount: Schema.Finite }), + }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'test.counter.increment', moduleKey: 'test.counter', role: 'action', @@ -59,7 +68,10 @@ const request = { payload: { amount: 2 }, principal, registration: lifecycleAction, - transport: { correlationId: 'action-harness-test', idempotencyKey: 'increment-once' }, + transport: { + correlationId: 'action-harness-test', + idempotencyKey: 'increment-once', + }, } as const; it.effect( @@ -140,8 +152,7 @@ it.effect( schemaVersion: '1', }, (payload, context) => context.services.increment(payload.amount), - (): Effect.Effect => - Effect.die('production owner services must not run in this test'), + (): Effect.Effect => Effect.die('production owner services must not run in this test'), ); let calls = 0; const harness = yield* makeActionTestHarness({ @@ -161,7 +172,10 @@ it.effect( payload: { amount: 4 }, principal, registration: serviceAction, - transport: { correlationId: 'service-test', idempotencyKey: 'service-once' }, + transport: { + correlationId: 'service-test', + idempotencyKey: 'service-once', + }, }); expect(result).toBe(5); @@ -192,37 +206,33 @@ it.effect( }), ); -it.effect( - 'persists policy denials separately from permission denials before handler execution', - () => - Effect.gen(function* policyDenialSnapshot() { - const registration = defineAction( - { - ...lifecycleAction.descriptor, - policies: [ - defineGlobalPolicy({ - evaluate: () => Effect.fail(denyPolicy('counter_locked', 'Counter is locked')), - policyKey: 'global.counter-locked.v1', - }), - ], - }, - () => Effect.die('A denied policy must not execute the handler'), - ); - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - }); - yield* harness.runtime.runAction({ ...request, registration }).pipe(Effect.flip); - const snapshot = harness.snapshot(); - expect(snapshot.policyDenials.length).toBe(1); - expect(snapshot.permissionDenials.length).toBe(0); - expect(snapshot.invocations[0]?.status).toBe('rejected'); - expect(snapshot.invocations[0]?.completedAt?.getTime()).toBe(0); - expect(snapshot.policyDenials[0]?.actionInvocationId).toBe( - snapshot.invocations[0]?.actionInvocationId, - ); - expect(snapshot.transactionCount).toBe(0); - expect(snapshot.committed.length).toBe(0); - expect(snapshot.stages.includes('handler_executed')).toBe(false); - }), +it.effect('persists policy denials separately from permission denials before handler execution', () => + Effect.gen(function* policyDenialSnapshot() { + const registration = defineAction( + { + ...lifecycleAction.descriptor, + policies: [ + defineGlobalPolicy({ + evaluate: () => Effect.fail(denyPolicy('counter_locked', 'Counter is locked')), + policyKey: 'global.counter-locked.v1', + }), + ], + }, + () => Effect.die('A denied policy must not execute the handler'), + ); + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + }); + yield* harness.runtime.runAction({ ...request, registration }).pipe(Effect.flip); + const snapshot = harness.snapshot(); + expect(snapshot.policyDenials.length).toBe(1); + expect(snapshot.permissionDenials.length).toBe(0); + expect(snapshot.invocations[0]?.status).toBe('rejected'); + expect(snapshot.invocations[0]?.completedAt?.getTime()).toBe(0); + expect(snapshot.policyDenials[0]?.actionInvocationId).toBe(snapshot.invocations[0]?.actionInvocationId); + expect(snapshot.transactionCount).toBe(0); + expect(snapshot.committed.length).toBe(0); + expect(snapshot.stages.includes('handler_executed')).toBe(false); + }), ); diff --git a/app/packages/core-runtime/tests/unit/application-composition.test.ts b/app/packages/core-runtime/tests/unit/application-composition.test.ts index acc4e235d..b6cb4cb9e 100644 --- a/app/packages/core-runtime/tests/unit/application-composition.test.ts +++ b/app/packages/core-runtime/tests/unit/application-composition.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { canonicalizeApplicationComposition, ApplicationCompositionSchema, @@ -83,7 +84,11 @@ const evidence = () => ({ federationExposes: ['./Navigation', './PageContacts'], mfBoundaryId: 'contacts', moduleId: 'contacts.core', - publicContract: { id: 'contacts.core', sha256: sha256('c'), version: '2' }, + publicContract: { + id: 'contacts.core', + sha256: sha256('c'), + version: '2', + }, sha256: sha256('a'), }, }, @@ -113,11 +118,11 @@ const federationManifest = (observations: Evidence) => it.effect('defaults the validation error code without changing its encoded contract', () => Effect.gen(function* encodeValidationError() { - const error = new ApplicationCompositionValidationError({ reason: 'Invalid candidate' }); + const error = new ApplicationCompositionValidationError({ + reason: 'Invalid candidate', + }); const encodedError = yield* Schema.encodeEffect(ApplicationCompositionValidationError)(error); - expect(Schema.is(Schema.toEncoded(ApplicationCompositionValidationError))(encodedError)).toBe( - true, - ); + expect(Schema.is(Schema.toEncoded(ApplicationCompositionValidationError))(encodedError)).toBe(true); expect(Struct.omit(encodedError, ['_tag'])).toEqual({ code: 'application_composition_invalid', reason: 'Invalid candidate', @@ -125,10 +130,7 @@ it.effect('defaults the validation error code without changing its encoded contr }), ); -const addModuleCopy = ( - input: Candidate, - overrides: Partial>, -): number => { +const addModuleCopy = (input: Candidate, overrides: Partial>): number => { const module = onlyModule(input); return input.modules.push({ ...structuredClone(module), @@ -177,18 +179,17 @@ it.effect( version, id, })); - reorderedModule.sharedSingletons = reorderedModule.sharedSingletons.map( - ({ packageName, version }) => ({ version, packageName }), - ); + reorderedModule.sharedSingletons = reorderedModule.sharedSingletons.map(({ packageName, version }) => ({ + version, + packageName, + })); reorderedModule.contract = { url: reorderedModule.contract.url, sha256: reorderedModule.contract.sha256, }; /* oxlint-enable perfectionist/sort-objects */ expect( - canonicalizeApplicationComposition( - yield* validateApplicationCompositionCandidate(reordered, evidence()), - ), + canonicalizeApplicationComposition(yield* validateApplicationCompositionCandidate(reordered, evidence())), ).toBe(canonicalizeApplicationComposition(composition)); expect( yield* Schema.decodeEffect(Schema.fromJsonString(ApplicationCompositionSchema))( @@ -220,7 +221,10 @@ it.effect( }; for (const environment of [{}, { environment: 'stage' }, { environment: 'production' }]) { yield* assertInvalid( - validateApplicationCompositionCandidate(input, { ...observed, ...environment }), + validateApplicationCompositionCandidate(input, { + ...observed, + ...environment, + }), /HTTPS outside development/u, ); } @@ -235,7 +239,10 @@ it.effect( const input = candidate(); onlyModule(input).contract.url = 'http://contacts.example/manifest.json'; yield* assertInvalid( - validateApplicationCompositionCandidate(input, { ...evidence(), environment: 'development' }), + validateApplicationCompositionCandidate(input, { + ...evidence(), + environment: 'development', + }), /supported .* schema/u, ); }), @@ -248,10 +255,7 @@ it.effect( mutate: (input: Candidate, observations: Evidence) => number | readonly string[] | string, reason: RegExp, ][] = [ - [ - (input) => (onlyModule(input).federation.remoteName = 'anotherRemote'), - /observed deployment contract/u, - ], + [(input) => (onlyModule(input).federation.remoteName = 'anotherRemote'), /observed deployment contract/u], [ (_input, observations) => (observations.contracts.contacts.mfBoundaryId = 'anotherRemote'), /observed deployment contract/u, @@ -260,32 +264,17 @@ it.effect( (_input, observations) => (federationManifest(observations).remoteName = 'anotherRemote'), /Module Federation manifest/u, ], - [ - (_input, observations) => (observations.contracts.contacts.contractUrl = 'invalid-url'), - /observation schema/u, - ], + [(_input, observations) => (observations.contracts.contacts.contractUrl = 'invalid-url'), /observation schema/u], [(input) => onlyModule(input).dependencies.push('billing.core'), /dependency billing\.core/u], [(input) => onlyModule(input).dependencies.push('contacts.core'), /dependency cycle/u], - [ - (input) => onlyModule(input).dependencies.push('contacts.core', 'contacts.core'), - /duplicate dependency/u, - ], - [ - (input) => addModuleCopy(input, { moduleId: 'inventory.stock' }), - /duplicate Shell contribution/u, - ], - [ - (input) => addModuleCopy(input, { allowedContributions: [] }), - /duplicate module ID contacts\.core/u, - ], + [(input) => onlyModule(input).dependencies.push('contacts.core', 'contacts.core'), /duplicate dependency/u], + [(input) => addModuleCopy(input, { moduleId: 'inventory.stock' }), /duplicate Shell contribution/u], + [(input) => addModuleCopy(input, { allowedContributions: [] }), /duplicate module ID contacts\.core/u], [ (input) => (onlyModule(input).allowedContributions = ['contacts.core.page.contacts']), /observed deployment contract/u, ], - [ - (input) => (onlyModule(input).federation.exposes = ['./Navigation']), - /observed deployment contract/u, - ], + [(input) => (onlyModule(input).federation.exposes = ['./Navigation']), /observed deployment contract/u], [ (input) => { const module = onlyModule(input); @@ -353,19 +342,19 @@ it.effect( /Core capability core\.authorization/u, ], [ - (input) => input.shell.sharedSingletons.push({ packageName: 'react', version: '18.3.1' }), + (input) => + input.shell.sharedSingletons.push({ + packageName: 'react', + version: '18.3.1', + }), /shared singleton react/u, ], [(input) => (onlyModule(input).federation.execution = 'server'), /supported .* schema/u], [ - (input) => - (onlyModule(input).contract.url = 'https://contacts.example/manifest.json?tag=live'), + (input) => (onlyModule(input).contract.url = 'https://contacts.example/manifest.json?tag=live'), /supported .* schema/u, ], - [ - (_input, observations) => (federationManifest(observations).exposes = []), - /Module Federation manifest/u, - ], + [(_input, observations) => (federationManifest(observations).exposes = []), /Module Federation manifest/u], [ (_input, observations) => { const singleton = required(federationManifest(observations).sharedSingletons[0]); diff --git a/app/packages/core-runtime/tests/unit/catalog-contract.test.ts b/app/packages/core-runtime/tests/unit/catalog-contract.test.ts index adf9786d1..febb6b4d6 100644 --- a/app/packages/core-runtime/tests/unit/catalog-contract.test.ts +++ b/app/packages/core-runtime/tests/unit/catalog-contract.test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import { compareApplicationCatalog, expectedCoreTableCatalog } from '../../src/db/catalog.ts'; import type { CatalogEntry } from '../../src/db/catalog.ts'; diff --git a/app/packages/core-runtime/tests/unit/commit-recovery-metadata.test.ts b/app/packages/core-runtime/tests/unit/commit-recovery-metadata.test.ts index b2b347905..085b99966 100644 --- a/app/packages/core-runtime/tests/unit/commit-recovery-metadata.test.ts +++ b/app/packages/core-runtime/tests/unit/commit-recovery-metadata.test.ts @@ -1,8 +1,7 @@ -import { expect, it } from 'effect-rstest'; - +import { Effect, Schema, Predicate, Struct } from 'effect'; /* oxlint-disable sonarjs/no-undefined-assignment -- Existing compatibility boundary; expires: 2026-12-31. */ +import { expect, it } from 'effect-rstest'; -import { Effect, Schema, Predicate, Struct } from 'effect'; import { defineAction } from '../../src/actions/definition.ts'; import { ActionAlreadyCommitted } from '../../src/actions/errors.ts'; import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; @@ -33,7 +32,10 @@ it.effect( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'test.recovery.execute', moduleKey: 'test.recovery', role: 'action', @@ -52,12 +54,17 @@ it.effect( return { total: payload.amount * executions }; }), ); - const harness = yield* makeActionTestHarness({ actionPermission: 'allowed' }); + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + }); const request = { payload: { amount: 2 }, principal, registration, - transport: { correlationId: 'commit-recovery-test', idempotencyKey: 'commit-once' }, + transport: { + correlationId: 'commit-recovery-test', + idempotencyKey: 'commit-once', + }, } as const; expect(yield* harness.runtime.runAction(request)).toEqual({ total: 2 }); @@ -67,9 +74,7 @@ it.effect( throw new Error('Missing invocationId'); } const replay = yield* harness.runtime.runAction(request).pipe(Effect.flip); - const recovered = yield* harness.runtime - .resolveActionCommit({ invocationId, principal }) - .pipe(Effect.flip); + const recovered = yield* harness.runtime.resolveActionCommit({ invocationId, principal }).pipe(Effect.flip); for (const outcome of [replay, recovered]) { expect(Predicate.isTagged(outcome, 'ActionAlreadyCommitted')).toBe(true); @@ -91,7 +96,7 @@ it.effect('committed error schema requires and preserves the recovery invocation invocationId: '40000000-0000-4000-8000-000000000001', reason: 'This idempotency key already committed successfully', } as const; - const decoded = yield* Schema.decodeUnknownEffect(ActionAlreadyCommitted)(encoded); + const decoded = yield* Schema.decodeEffect(ActionAlreadyCommitted)(encoded); const reencoded = yield* decoded.pipe(Schema.encodeEffect(ActionAlreadyCommitted)); expect(Schema.is(Schema.toEncoded(ActionAlreadyCommitted))(reencoded)).toBe(true); expect(Struct.omit(reencoded, ['_tag'])).toEqual(Struct.omit(encoded, ['_tag'])); @@ -107,84 +112,94 @@ it.effect('committed error schema requires and preserves the recovery invocation }), ); -it.effect( - 'lost commit acknowledgement recovers the committed invocation and faults only once', - () => - Effect.gen(function* migratedTest() { - let executions = 0; - const registration = defineAction( - { - accessEvidencePolicy: { - captureMode: 'metadata_only', - policyKey: 'test.recovery.read.v1', - }, - actionKey: 'test.recovery.acknowledgement', - auditProfile: 'minimal', - domainErrorSchema: Schema.Never, - domainEvents: {}, - entrypoint: defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'test.recovery.acknowledgement', - moduleKey: 'test.recovery', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'optional', - owningModuleKey: 'test.recovery', - payloadSchema: Schema.Void, - policies: [], - resultSchema: Schema.Finite, - schemaVersion: '1', +it.effect('lost commit acknowledgement recovers the committed invocation and faults only once', () => + Effect.gen(function* migratedTest() { + let executions = 0; + const registration = defineAction( + { + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'test.recovery.read.v1', }, - () => - Effect.sync(() => { - executions += 1; - return executions; - }), - ); - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - commitAcknowledgement: 'indeterminate-once', - }); - const request = { - payload: undefined, - principal, - registration, - transport: { correlationId: 'lost-acknowledgement', idempotencyKey: 'commit-once' }, - } as const; + actionKey: 'test.recovery.acknowledgement', + auditProfile: 'minimal', + domainErrorSchema: Schema.Never, + domainEvents: {}, + entrypoint: defineTenantModuleEntrypoint({ + access: 'write', + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, + entrypointKey: 'test.recovery.acknowledgement', + moduleKey: 'test.recovery', + role: 'action', + }), + idempotency: 'required', + legalEntityScope: 'optional', + owningModuleKey: 'test.recovery', + payloadSchema: Schema.Void, + policies: [], + resultSchema: Schema.Finite, + schemaVersion: '1', + }, + () => + Effect.sync(() => { + executions += 1; + return executions; + }), + ); + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + commitAcknowledgement: 'indeterminate-once', + }); + const request = { + payload: undefined, + principal, + registration, + transport: { + correlationId: 'lost-acknowledgement', + idempotencyKey: 'commit-once', + }, + } as const; - const uncertain = yield* harness.runtime.runAction(request).pipe(Effect.flip); - expect(Predicate.isTagged(uncertain, 'ActionCommitIndeterminate')).toBe(true); - expect('invocationId' in uncertain).toBe(true); - if (!('invocationId' in uncertain)) { + const uncertain = yield* harness.runtime.runAction(request).pipe(Effect.flip); + expect(Predicate.isTagged(uncertain, 'ActionCommitIndeterminate')).toBe(true); + expect('invocationId' in uncertain).toBe(true); + if (!('invocationId' in uncertain)) { + throw new Error('Missing invocation identifier'); + } + expect(uncertain.invocationId).toBe(harness.snapshot().invocations[0]?.actionInvocationId); + expect(harness.snapshot().invocations[0]?.status).toBe('succeeded'); + + const recovered = yield* harness.runtime + .resolveActionCommit({ + invocationId: uncertain.invocationId, + principal, + }) + .pipe(Effect.flip); + const replay = yield* harness.runtime.runAction(request).pipe(Effect.flip); + for (const outcome of [recovered, replay]) { + expect(Predicate.isTagged(outcome, 'ActionAlreadyCommitted')).toBe(true); + expect('invocationId' in outcome).toBe(true); + if (!('invocationId' in outcome)) { throw new Error('Missing invocation identifier'); } - expect(uncertain.invocationId).toBe(harness.snapshot().invocations[0]?.actionInvocationId); - expect(harness.snapshot().invocations[0]?.status).toBe('succeeded'); + expect(outcome.invocationId).toBe(uncertain.invocationId); + } + expect(executions).toBe(1); + expect(harness.snapshot().committed.length).toBe(1); + expect(harness.snapshot().transactionCount).toBe(1); - const recovered = yield* harness.runtime - .resolveActionCommit({ invocationId: uncertain.invocationId, principal }) - .pipe(Effect.flip); - const replay = yield* harness.runtime.runAction(request).pipe(Effect.flip); - for (const outcome of [recovered, replay]) { - expect(Predicate.isTagged(outcome, 'ActionAlreadyCommitted')).toBe(true); - expect('invocationId' in outcome).toBe(true); - if (!('invocationId' in outcome)) { - throw new Error('Missing invocation identifier'); - } - expect(outcome.invocationId).toBe(uncertain.invocationId); - } - expect(executions).toBe(1); - expect(harness.snapshot().committed.length).toBe(1); - expect(harness.snapshot().transactionCount).toBe(1); - - expect( - yield* harness.runtime.runAction({ - ...request, - transport: { correlationId: 'acknowledged-next', idempotencyKey: 'next-invocation' }, - }), - ).toBe(2); - expect(harness.snapshot().committed.length).toBe(2); - }), + expect( + yield* harness.runtime.runAction({ + ...request, + transport: { + correlationId: 'acknowledged-next', + idempotencyKey: 'next-invocation', + }, + }), + ).toBe(2); + expect(harness.snapshot().committed.length).toBe(2); + }), ); diff --git a/app/packages/core-runtime/tests/unit/config.test.ts b/app/packages/core-runtime/tests/unit/config.test.ts index 0465cd1ef..c43738440 100644 --- a/app/packages/core-runtime/tests/unit/config.test.ts +++ b/app/packages/core-runtime/tests/unit/config.test.ts @@ -1,6 +1,6 @@ +import { Effect, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Predicate } from 'effect'; import { acquirePoolResource } from '../../src/db/client.ts'; import { ROOT_ENV_PATH, @@ -26,9 +26,7 @@ it.effect('loads the root environment independently of the invocation directory' }); expect(ROOT_ENV_PATH.endsWith('/app/.env')).toBe(true); - expect(configuration.connectionString).toBe( - 'postgresql://ontos_runtime:ontos_runtime@localhost:5433/ontos', - ); + expect(configuration.connectionString).toBe('postgresql://ontos_runtime:ontos_runtime@localhost:5433/ontos'); }), ); diff --git a/app/packages/core-runtime/tests/unit/context-access.test.ts b/app/packages/core-runtime/tests/unit/context-access.test.ts index e74cbc15e..c3d4cb89c 100644 --- a/app/packages/core-runtime/tests/unit/context-access.test.ts +++ b/app/packages/core-runtime/tests/unit/context-access.test.ts @@ -1,7 +1,9 @@ -import { expect, it } from 'effect-rstest'; - import { v1 } from '@authzed/authzed-node'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { spiceDbPermissionClientError } from '../../src/permissions/client.ts'; +import type { SpiceDbPermissionClient } from '../../src/permissions/client.ts'; import { LEGAL_ENTITY_PERMISSION_KEYS, TENANT_PERMISSION_KEYS, @@ -10,8 +12,6 @@ import { toModuleAccessObjectId, toResourceAccessObjectId, } from '../../src/permissions/context-access.ts'; -import { spiceDbPermissionClientError } from '../../src/permissions/client.ts'; -import type { SpiceDbPermissionClient } from '../../src/permissions/client.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; const legalEntityId = '20000000-0000-4000-8000-000000000001'; @@ -27,8 +27,7 @@ const responseFor = ( request: item, response: { item: v1.CheckBulkPermissionsResponseItem.create({ - permissionship: - permissionships[index] ?? v1.CheckPermissionResponse_Permissionship.UNSPECIFIED, + permissionship: permissionships[index] ?? v1.CheckPermissionResponse_Permissionship.UNSPECIFIED, }), oneofKind: 'item', }, @@ -36,50 +35,46 @@ const responseFor = ( ), }); -const makeClient = ( - handle: SpiceDbPermissionClient['checkBulkPermissions'], -): SpiceDbPermissionClient => ({ +const makeClient = (handle: SpiceDbPermissionClient['checkBulkPermissions']): SpiceDbPermissionClient => ({ checkBulkPermissions: handle, checkPermission: () => Effect.die(new Error('Action check must not run')), close: () => {}, }); -it.effect( - 'uses one fully consistent batch and correlates allowed and denied module decisions', - () => - Effect.gen(function* correlatesModuleDecisions() { - const requests: v1.CheckBulkPermissionsRequest[] = []; - const access = makeContextAccess( - makeClient((request) => - Effect.sync(() => { - requests.push(request); - return responseFor(request, [ - v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, - v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, - ]); - }), - ), - ); +it.effect('uses one fully consistent batch and correlates allowed and denied module decisions', () => + Effect.gen(function* correlatesModuleDecisions() { + const requests: v1.CheckBulkPermissionsRequest[] = []; + const access = makeContextAccess( + makeClient((request) => + Effect.sync(() => { + requests.push(request); + return responseFor(request, [ + v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, + v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, + ]); + }), + ), + ); - const result = yield* access.modules({ - legalEntityId, - moduleIds: ['property.registry', 'billing.core'], - principalId, - tenantId, - }); - expect(result).toEqual([ - { decision: 'allowed', key: 'property.registry' }, - { decision: 'denied', key: 'billing.core' }, - ]); - expect(requests.length).toBe(1); - expect(requests[0]?.consistency?.requirement).toEqual({ - fullyConsistent: true, - oneofKind: 'fullyConsistent', - }); - expect(requests[0]?.items[0]?.resource?.objectType).toBe('module_access'); - expect(requests[0]?.items[0]?.permission).toBe('access'); - expect(requests[0]?.items[0]?.subject?.object?.objectId).toBe(principalId); - }), + const result = yield* access.modules({ + legalEntityId, + moduleIds: ['property.registry', 'billing.core'], + principalId, + tenantId, + }); + expect(result).toEqual([ + { decision: 'allowed', key: 'property.registry' }, + { decision: 'denied', key: 'billing.core' }, + ]); + expect(requests.length).toBe(1); + expect(requests[0]?.consistency?.requirement).toEqual({ + fullyConsistent: true, + oneofKind: 'fullyConsistent', + }); + expect(requests[0]?.items[0]?.resource?.objectType).toBe('module_access'); + expect(requests[0]?.items[0]?.permission).toBe('access'); + expect(requests[0]?.items[0]?.subject?.object?.objectId).toBe(principalId); + }), ); it.effect('checks resource writes independently from resource reads', () => @@ -130,16 +125,13 @@ it.effect('forwards every closed tenant permission key without widening it', () Effect.gen(function* forwardsTenantPermissionKeys() { const { observed, service } = makeAllowedPermissionRecorder(); - yield* Effect.all( - TENANT_PERMISSION_KEYS.map((permission) => + yield* Effect.forEach( + TENANT_PERMISSION_KEYS, + (permission) => service .tenants({ permission, principalId, tenantIds: [tenantId] }) - .pipe( - Effect.map((result) => - expect(result).toEqual([{ decision: 'allowed', key: tenantId }]), - ), - ), - ), + .pipe(Effect.map((result) => expect(result).toEqual([{ decision: 'allowed', key: tenantId }]))), + { concurrency: 1 }, ); expect(observed).toEqual(TENANT_PERMISSION_KEYS); }), @@ -149,16 +141,18 @@ it.effect('forwards every closed Legal Entity permission key without widening it Effect.gen(function* forwardsLegalEntityPermissionKeys() { const { observed, service } = makeAllowedPermissionRecorder(); - yield* Effect.all( - LEGAL_ENTITY_PERMISSION_KEYS.map((permission) => + yield* Effect.forEach( + LEGAL_ENTITY_PERMISSION_KEYS, + (permission) => service - .legalEntities({ legalEntityIds: [legalEntityId], permission, principalId, tenantId }) - .pipe( - Effect.map((result) => - expect(result).toEqual([{ decision: 'allowed', key: legalEntityId }]), - ), - ), - ), + .legalEntities({ + legalEntityIds: [legalEntityId], + permission, + principalId, + tenantId, + }) + .pipe(Effect.map((result) => expect(result).toEqual([{ decision: 'allowed', key: legalEntityId }]))), + { concurrency: 1 }, ); expect(observed).toEqual(LEGAL_ENTITY_PERMISSION_KEYS); }), @@ -225,59 +219,46 @@ it.effect('supports empty batches and exact resource filtering', () => }), ); -it.effect( - 'classifies client, partial, duplicate, malformed, and conditional results as unavailable', - () => - Effect.gen(function* classifiesUnavailableResults() { - const input = { legalEntityIds: [legalEntityId], principalId, tenantId }; - const failures = [ - makeClient(() => - Effect.fail(spiceDbPermissionClientError(new Error('secret SpiceDB diagnostic'))), - ), - makeClient(() => Effect.succeed(v1.CheckBulkPermissionsResponse.create({ pairs: [] }))), - makeClient((request) => - Effect.succeed( - responseFor(request, [ - v1.CheckPermissionResponse_Permissionship.CONDITIONAL_PERMISSION, - ]), - ), - ), - makeClient((request) => - Effect.sync(() => { - const response = responseFor(request, [ - v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, - ]); - const [pair] = response.pairs; - return v1.CheckBulkPermissionsResponse.create({ - pairs: pair === undefined ? [] : [{ response: pair.response }], - }); - }), - ), - ]; - const [failingClient] = failures; - expect(failingClient).toBeDefined(); - if (failingClient === undefined) { - throw new Error('Missing failingClient'); - } - yield* Effect.all( - failures.map((client) => - makeContextAccess(client) - .legalEntities(input) - .pipe( - Effect.map((result) => - expect(result).toEqual([{ decision: 'unavailable', key: legalEntityId }]), - ), - ), - ), - ); - expect( - yield* makeContextAccess(failingClient).legalEntities({ - ...input, - legalEntityIds: [legalEntityId, legalEntityId], +it.effect('classifies client, partial, duplicate, malformed, and conditional results as unavailable', () => + Effect.gen(function* classifiesUnavailableResults() { + const input = { legalEntityIds: [legalEntityId], principalId, tenantId }; + const failures = [ + makeClient(() => Effect.fail(spiceDbPermissionClientError(new Error('secret SpiceDB diagnostic')))), + makeClient(() => Effect.succeed(v1.CheckBulkPermissionsResponse.create({ pairs: [] }))), + makeClient((request) => + Effect.succeed(responseFor(request, [v1.CheckPermissionResponse_Permissionship.CONDITIONAL_PERMISSION])), + ), + makeClient((request) => + Effect.sync(() => { + const response = responseFor(request, [v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION]); + const [pair] = response.pairs; + return v1.CheckBulkPermissionsResponse.create({ + pairs: pair === undefined ? [] : [{ response: pair.response }], + }); }), - ).toEqual([ - { decision: 'unavailable', key: legalEntityId }, - { decision: 'unavailable', key: legalEntityId }, - ]); - }), + ), + ]; + const [failingClient] = failures; + expect(failingClient).toBeDefined(); + if (failingClient === undefined) { + throw new Error('Missing failingClient'); + } + yield* Effect.forEach( + failures, + (client) => + makeContextAccess(client) + .legalEntities(input) + .pipe(Effect.map((result) => expect(result).toEqual([{ decision: 'unavailable', key: legalEntityId }]))), + { concurrency: 1 }, + ); + expect( + yield* makeContextAccess(failingClient).legalEntities({ + ...input, + legalEntityIds: [legalEntityId, legalEntityId], + }), + ).toEqual([ + { decision: 'unavailable', key: legalEntityId }, + { decision: 'unavailable', key: legalEntityId }, + ]); + }), ); diff --git a/app/packages/core-runtime/tests/unit/database-driver-failure.test.ts b/app/packages/core-runtime/tests/unit/database-driver-failure.test.ts index 057ac470e..a01484641 100644 --- a/app/packages/core-runtime/tests/unit/database-driver-failure.test.ts +++ b/app/packages/core-runtime/tests/unit/database-driver-failure.test.ts @@ -1,6 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; import { Cause, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { SqlError, UniqueViolation } from 'effect/unstable/sql/SqlError'; import { @@ -35,7 +35,10 @@ it('finds PostgreSQL metadata through Error and plain-object cause wrappers', () const failure = new Error('outer wrapper', { cause: { cause: { - cause: { code: '23505', constraint: 'principal_auth_bindings_provider_subject_uk' }, + cause: { + code: '23505', + constraint: 'principal_auth_bindings_provider_subject_uk', + }, }, }, }); @@ -49,7 +52,10 @@ it('finds PostgreSQL metadata through Error and plain-object cause wrappers', () it('ignores a non-string constraint while retaining a valid code', () => { expect( Option.getOrThrow( - findPostgresFailure({ code: '23505', constraint: { private: 'diagnostic object' } }), + findPostgresFailure({ + code: '23505', + constraint: { private: 'diagnostic object' }, + }), ), ).toEqual({ code: '23505' }); }); @@ -103,10 +109,7 @@ it('supports owner-local matching without changing default root precedence', () }); expect( Option.getOrThrow( - findPostgresFailure( - failure, - ({ code, constraint }) => code === '23505' && constraint === 'owner_constraint', - ), + findPostgresFailure(failure, ({ code, constraint }) => code === '23505' && constraint === 'owner_constraint'), ), ).toEqual({ code: '23505', constraint: 'owner_constraint' }); }); @@ -120,7 +123,9 @@ it('terminates on cyclic cause graphs with a first match or no match', () => { first.cause = second; second.cause = first; - expect(Option.getOrThrow(findPostgresFailure(matched))).toEqual({ code: '23505' }); + expect(Option.getOrThrow(findPostgresFailure(matched))).toEqual({ + code: '23505', + }); expect(Option.isNone(findPostgresFailure(first))).toBe(true); }); @@ -151,8 +156,7 @@ it('distinguishes commit ambiguity from definite transaction failures', () => { Predicate.isTagged(administrativeShutdown.value, 'DatabaseCommitAcknowledgementAmbiguous'), ).toBe(true); expect( - Option.isSome(serializationFailure) && - Predicate.isTagged(serializationFailure.value, 'DatabaseTransactionFailure'), + Option.isSome(serializationFailure) && Predicate.isTagged(serializationFailure.value, 'DatabaseTransactionFailure'), ).toBe(true); expect(isDatabaseCommitAcknowledgementAmbiguous({ code: '40001' })).toBe(false); expect(isDatabaseCommitAcknowledgementAmbiguous({ code: '57014' })).toBe(false); @@ -229,13 +233,18 @@ it('terminates safely when a cause chain contains a cycle', () => { it('decodes native Drizzle and Effect SQL causes without exposing query data', () => { const constraint = 'principal_auth_bindings_provider_subject_uk'; const driver = { code: '23505', constraint, detail: 'private detail' }; - const sqlError = new SqlError({ reason: new UniqueViolation({ cause: driver, constraint }) }); + const sqlError = new SqlError({ + reason: new UniqueViolation({ cause: driver, constraint }), + }); const failure = new EffectDrizzleQueryError({ cause: Cause.fail(sqlError), params: ['private parameter'], query: 'private SQL', }); - expect(Option.getOrThrow(findPostgresFailure(failure))).toEqual({ code: '23505', constraint }); + expect(Option.getOrThrow(findPostgresFailure(failure))).toEqual({ + code: '23505', + constraint, + }); expect(Option.getOrThrow(findPostgresFailure(Cause.die(sqlError)))).toEqual({ code: '23505', constraint, diff --git a/app/packages/core-runtime/tests/unit/entrypoint-classification.test.ts b/app/packages/core-runtime/tests/unit/entrypoint-classification.test.ts index dbea342eb..c0d5263e4 100644 --- a/app/packages/core-runtime/tests/unit/entrypoint-classification.test.ts +++ b/app/packages/core-runtime/tests/unit/entrypoint-classification.test.ts @@ -1,14 +1,11 @@ +import { Schema } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Schema } from 'effect'; import { EntrypointAuthorizationSchema, decodeEntrypointAuthorization, } from '../../src/authorization/entrypoint-classification.ts'; -import { - defineSystemModuleEntrypoint, - defineTenantModuleEntrypoint, -} from '../../src/modules/module-entrypoint.ts'; +import { defineSystemModuleEntrypoint, defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; it('decodes every closed authorization classification', () => { const classifications = [ diff --git a/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts b/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts index edc88a584..3058c9f44 100644 --- a/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts +++ b/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts @@ -1,11 +1,9 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; -class FixtureDeletionError extends Schema.TaggedError()( - 'FixtureDeletionError', - {}, -) {} +class FixtureDeletionError extends Schema.TaggedError()('FixtureDeletionError', {}) {} it.effect('purges fixture rows sequentially in child-before-parent order', () => Effect.gen(function* verifyDeletionOrder() { diff --git a/app/packages/core-runtime/tests/unit/governed-read-http.test.ts b/app/packages/core-runtime/tests/unit/governed-read-http.test.ts index 4f2dc05cc..ddc35bff7 100644 --- a/app/packages/core-runtime/tests/unit/governed-read-http.test.ts +++ b/app/packages/core-runtime/tests/unit/governed-read-http.test.ts @@ -1,6 +1,10 @@ +import { Cause, Effect, Exit, Logger, Redacted, Schema } from 'effect'; // @effect-diagnostics strictEffectProvide:off -- Test-owned logger capture entrypoint; expires: 2026-12-31. import { expect, it } from 'effect-rstest'; +import { Headers, HttpServerRequest } from 'effect/unstable/http'; + import { TrustedPrincipalContextSchema } from '../../src/actions/principal-context.ts'; +import { classifyReadCoreError, makeGovernedReadHttpHandler } from '../../src/http/governed-read.ts'; import { defineSystemModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { ModuleStateCheckUnavailableError } from '../../src/modules/module-state-check-unavailable-error.ts'; import { ModuleStateDeniedError } from '../../src/modules/module-state-denied-error.ts'; @@ -8,6 +12,8 @@ import { OperationAuthenticationRequired } from '../../src/operations/operation- import { OperationContextDenied } from '../../src/operations/operation-context-denied.ts'; import { OperationContextInvalid } from '../../src/operations/operation-context-invalid.ts'; import { OperationContextUnavailable } from '../../src/operations/operation-context-unavailable.ts'; +import { defineRead } from '../../src/reads/definition.ts'; +import type { ReadCoreError } from '../../src/reads/errors.ts'; import { ReadEvidencePersistenceError } from '../../src/reads/read-evidence-persistence-error.ts'; import { ReadEvidenceValidationError } from '../../src/reads/read-evidence-validation-error.ts'; import { ReadHandlerExecutionError } from '../../src/reads/read-handler-execution-error.ts'; @@ -19,21 +25,13 @@ import { ReadPermissionUnavailable } from '../../src/reads/read-permission-unava import { ReadPolicyDenied } from '../../src/reads/read-policy-denied.ts'; import { ReadPolicyEvaluationError } from '../../src/reads/read-policy-evaluation-error.ts'; import { ReadResultValidationError } from '../../src/reads/read-result-validation-error.ts'; -import { - classifyReadCoreError, - makeGovernedReadHttpHandler, -} from '../../src/http/governed-read.ts'; -import { defineRead } from '../../src/reads/definition.ts'; import { ReadRuntime } from '../../src/reads/runtime.ts'; -import type { ReadCoreError } from '../../src/reads/errors.ts'; import type { ReadRuntimeService } from '../../src/reads/runtime.ts'; -import { Cause, Effect, Exit, Logger, Redacted, Schema } from 'effect'; -import { Headers, HttpServerRequest } from 'effect/unstable/http'; -const problem = ( - kind: Kind, - status: Status, -) => ({ kind, status }); +const problem = (kind: Kind, status: Status) => ({ + kind, + status, +}); const problems = { authentication: () => problem('authentication', 401), @@ -54,50 +52,62 @@ const capturedLoggerLayer = (entries: string[]) => ]); const reason = 'safe reason'; -const coreFailures: readonly [ - ReadCoreError, - ReturnType<(typeof problems)[keyof typeof problems]>, -][] = [ +const coreFailures: readonly [ReadCoreError, ReturnType<(typeof problems)[keyof typeof problems]>][] = [ [ - new ModuleStateCheckUnavailableError({ code: 'module_state_check_unavailable', reason }), + new ModuleStateCheckUnavailableError({ + code: 'module_state_check_unavailable', + reason, + }), problems.unavailable(), ], [new ModuleStateDeniedError({ code: 'module_state_denied', reason }), problems.forbidden()], [ - new OperationAuthenticationRequired({ code: 'operation_authentication_required', reason }), + new OperationAuthenticationRequired({ + code: 'operation_authentication_required', + reason, + }), problems.authentication(), ], [new OperationContextDenied({ code: 'operation_context_denied', reason }), problems.forbidden()], + [new OperationContextInvalid({ code: 'operation_context_invalid', reason }), problems.forbidden()], [ - new OperationContextInvalid({ code: 'operation_context_invalid', reason }), - problems.forbidden(), - ], - [ - new OperationContextUnavailable({ code: 'operation_context_unavailable', reason }), + new OperationContextUnavailable({ + code: 'operation_context_unavailable', + reason, + }), problems.unavailable(), ], [ - new ReadEvidencePersistenceError({ code: 'read_evidence_persistence_failed', reason }), + new ReadEvidencePersistenceError({ + code: 'read_evidence_persistence_failed', + reason, + }), problems.unavailable(), ], [new ReadEvidenceValidationError({ code: 'read_evidence_invalid', reason }), problems.internal()], [ - new ReadHandlerExecutionError({ code: 'read_handler_execution_failed', reason }), + new ReadHandlerExecutionError({ + code: 'read_handler_execution_failed', + reason, + }), problems.internal(), ], [new ReadHandlerNotFound({ code: 'read_handler_not_found', reason }), problems.notFound()], - [ - new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), - problems.unavailable(), - ], + [new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), problems.unavailable()], [new ReadInputValidationError({ code: 'read_input_invalid', reason }), problems.invalid()], [new ReadPermissionDenied({ code: 'read_permission_denied', reason }), problems.forbidden()], [ - new ReadPermissionUnavailable({ code: 'read_permission_unavailable', reason }), + new ReadPermissionUnavailable({ + code: 'read_permission_unavailable', + reason, + }), problems.unavailable(), ], [ - new ReadPolicyEvaluationError({ code: 'read_policy_evaluation_failed', reason }), + new ReadPolicyEvaluationError({ + code: 'read_policy_evaluation_failed', + reason, + }), problems.unavailable(), ], [new ReadResultValidationError({ code: 'read_result_invalid', reason }), problems.internal()], @@ -133,7 +143,10 @@ const registration = defineRead( accessKind: 'detail', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.shell.governed-http-test', moduleKey: 'core.shell', role: 'api', @@ -151,12 +164,16 @@ const registration = defineRead( resultSchema: Schema.Struct({ ok: Schema.Literal(true) }), schemaVersion: '1', }, - () => Effect.succeed({ evidence: { resultCount: 1 }, result: { ok: true as const } }), + () => + Effect.succeed({ + evidence: { resultCount: 1 }, + result: { ok: true as const }, + }), () => Effect.succeed({}), () => ({ kind: 'module', moduleId: 'core.shell' }), ); -const principal = Schema.decodeUnknownSync(TrustedPrincipalContextSchema)({ +const principal = Schema.decodeSync(TrustedPrincipalContextSchema)({ authBindingId: '00000000-0000-4000-8000-000000000002', authContextRef: 'better-auth-session:governed-http-test', authMethod: 'session', @@ -233,7 +250,9 @@ it.effect('sanitizes synchronous defects across correlation validation and authe problems, registration, }), - headers: Headers.fromInput({ 'x-correlation-id': 'synchronous-defect' }), + headers: Headers.fromInput({ + 'x-correlation-id': 'synchronous-defect', + }), }, ]; const exits = yield* Effect.forEach( @@ -250,54 +269,57 @@ it.effect('sanitizes synchronous defects across correlation validation and authe if (Exit.isFailure(exit)) { const publicFailure = Cause.squash(exit.cause); expect(publicFailure).toEqual(problems.internal()); - expect( - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(publicFailure), - ).not.toMatch(/private/u); + expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(publicFailure)).not.toMatch( + /private/u, + ); } } }), ); -it.effect( - 'passes only payload, trusted principal, registration, and correlation to ReadRuntime', - () => - Effect.gen(function* forwardTrustedReadInputs() { - const payload = { query: 'fixture' }; - observed.length = 0; - const handler = makeGovernedReadHttpHandler({ - authenticatePrincipal: (authorization) => { - expect(Redacted.value(authorization)).toBe('Bearer private'); - return Effect.succeed(principal); +it.effect('passes only payload, trusted principal, registration, and correlation to ReadRuntime', () => + Effect.gen(function* forwardTrustedReadInputs() { + const payload = { query: 'fixture' }; + observed.length = 0; + const handler = makeGovernedReadHttpHandler({ + authenticatePrincipal: (authorization) => { + expect(Redacted.value(authorization)).toBe('Bearer private'); + return Effect.succeed(principal); + }, + problems, + registration, + }); + const assertDecodedPayloadInput = () => + handler({ + payload: { + // @ts-expect-error The HTTP framework must pass the schema-decoded payload shape. + query: 123, }, - problems, - registration, + request: { headers: Headers.empty }, }); - const assertDecodedPayloadInput = () => - // @ts-expect-error The HTTP framework must pass the schema-decoded payload shape. - handler({ payload: { query: 123 }, request: { headers: Headers.empty } }); - void assertDecodedPayloadInput; - const result = yield* handler({ - payload, - request: { - headers: Headers.fromInput({ - authorization: 'Bearer private', - 'x-correlation-id': 'correlation-test', - }), - }, - }).pipe( - Effect.provideService(ReadRuntime, readRuntime), - Effect.provideService(HttpServerRequest.HttpServerRequest, requestService), - ); - expect(result).toEqual({ ok: true }); - expect(observed).toEqual([ - { - input: payload, - principal, - registration, - transport: { correlationId: 'correlation-test' }, - }, - ]); - }), + void assertDecodedPayloadInput; + const result = yield* handler({ + payload, + request: { + headers: Headers.fromInput({ + authorization: 'Bearer private', + 'x-correlation-id': 'correlation-test', + }), + }, + }).pipe( + Effect.provideService(ReadRuntime, readRuntime), + Effect.provideService(HttpServerRequest.HttpServerRequest, requestService), + ); + expect(result).toEqual({ ok: true }); + expect(observed).toEqual([ + { + input: payload, + principal, + registration, + transport: { correlationId: 'correlation-test' }, + }, + ]); + }), ); it.effect('sanitizes unexpected defects at the complete governed handler boundary', () => @@ -317,7 +339,9 @@ it.effect('sanitizes unexpected defects at the complete governed handler boundar handler({ payload: { query: 'fixture' }, request: { - headers: Headers.fromInput({ 'x-correlation-id': 'correlation-defect' }), + headers: Headers.fromInput({ + 'x-correlation-id': 'correlation-defect', + }), }, }), ).pipe( @@ -329,9 +353,9 @@ it.effect('sanitizes unexpected defects at the complete governed handler boundar if (Exit.isFailure(exit)) { const publicFailure = Cause.squash(exit.cause); expect(publicFailure).toEqual(problems.internal()); - expect( - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(publicFailure), - ).not.toMatch(/private database connection detail/u); + expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(publicFailure)).not.toMatch( + /private database connection detail/u, + ); } expect(logEntries.length).toBe(1); expect(logEntries.join('\n')).not.toMatch(/private database connection detail/u); diff --git a/app/packages/core-runtime/tests/unit/http-principal-authentication.test.ts b/app/packages/core-runtime/tests/unit/http-principal-authentication.test.ts index aa524e367..242d611db 100644 --- a/app/packages/core-runtime/tests/unit/http-principal-authentication.test.ts +++ b/app/packages/core-runtime/tests/unit/http-principal-authentication.test.ts @@ -1,7 +1,7 @@ -import { expect, it } from 'effect-rstest'; // @effect-diagnostics strictEffectProvide:off -- Test-owned HTTP application entrypoint; expires: 2026-12-31. import { NodeHttpServer } from '@effect/platform-node'; import { Effect, Match, Redacted, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient, HttpClient, @@ -9,6 +9,7 @@ import { HttpServerRequest, HttpServerResponse, } from 'effect/unstable/http'; + import { OperationPrincipalVerificationErrorSchema, makeMicroverticalHttpPrincipalAuthentication, @@ -22,10 +23,8 @@ const principal = { tenantId: 'a3000000-0000-4000-8000-000000000001', }; -const verificationFailure = ( - _tag: (typeof OperationPrincipalVerificationErrorSchema.Type)['_tag'], -) => - Schema.decodeUnknownEffect(OperationPrincipalVerificationErrorSchema)({ +const verificationFailure = (_tag: (typeof OperationPrincipalVerificationErrorSchema.Type)['_tag']) => + Schema.decodeEffect(OperationPrincipalVerificationErrorSchema)({ _tag, reason: 'Private verifier diagnostic', }).pipe(Effect.orDie); @@ -51,15 +50,12 @@ const ProblemResponseSchema = Schema.Struct({ }); const SuccessResponseSchema = Schema.Struct({ principal: Schema.Unknown }); -const problemResponse = ( - problem: ReturnType, -) => +const problemResponse = (problem: ReturnType) => HttpServerResponse.jsonUnsafe(problem, { status: problem.status }).pipe( HttpServerResponse.setHeader('content-type', 'application/problem+json'), ); -const respondWithProblem = ( - error: ReturnType, -) => Effect.succeed(problemResponse(error)); +const respondWithProblem = (error: ReturnType) => + Effect.succeed(problemResponse(error)); it.live( 'mounted HTTP authentication maps verifier classes, challenges unusable credentials, and stops before private logic', @@ -85,10 +81,10 @@ it.live( }); return Effect.gen(function* mountedAuthenticationHandler() { - const server = yield* NodeHttpServer.make( - () => process.getBuiltinModule('http').createServer(), - { host: '127.0.0.1', port: 0 }, - ); + const server = yield* NodeHttpServer.make(() => process.getBuiltinModule('http').createServer(), { + host: '127.0.0.1', + port: 0, + }); const application = HttpServerRequest.HttpServerRequest.use((request) => authenticate(Redacted.make(request.headers['authorization']), { authentication: authenticationProblem, @@ -97,7 +93,9 @@ it.live( Effect.flatMap((trustedPrincipal) => Effect.sync(() => { privateOperationReached += 1; - return HttpServerResponse.jsonUnsafe({ principal: trustedPrincipal }); + return HttpServerResponse.jsonUnsafe({ + principal: trustedPrincipal, + }); }), ), Effect.catch(respondWithProblem), @@ -121,19 +119,13 @@ it.live( const request = authorization === undefined ? HttpClientRequest.get(url) - : HttpClientRequest.get(url).pipe( - HttpClientRequest.setHeader('authorization', authorization), - ); + : HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader('authorization', authorization)); const response = yield* client.execute(request); expect(response.status).toBe(expectedStatus); expect(response.headers['content-type']).toBe('application/problem+json'); - expect(response.headers['www-authenticate']).toBe( - expectedStatus === 401 ? 'Bearer' : undefined, - ); + expect(response.headers['www-authenticate']).toBe(expectedStatus === 401 ? 'Bearer' : undefined); const rawBody = yield* response.json; - expect(rawBody).toEqual( - expectedStatus === 401 ? authenticationProblem() : unavailableProblem(), - ); + expect(rawBody).toEqual(expectedStatus === 401 ? authenticationProblem() : unavailableProblem()); const body = yield* Schema.decodeUnknownEffect(ProblemResponseSchema)(rawBody); expect( Predicate.isTagged( @@ -146,14 +138,10 @@ it.live( expect(privateOperationReached).toBe(0); const success = yield* client.execute( - HttpClientRequest.get(url).pipe( - HttpClientRequest.setHeader('authorization', 'Bearer valid'), - ), + HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader('authorization', 'Bearer valid')), ); expect(success.status).toBe(200); - const successBody = yield* success.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(SuccessResponseSchema)), - ); + const successBody = yield* success.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(SuccessResponseSchema))); expect(successBody.principal).toEqual(principal); expect(privateOperationReached).toBe(1); }).pipe(Effect.provide(FetchHttpClient.layer)); diff --git a/app/packages/core-runtime/tests/unit/legal-entity-context.test.ts b/app/packages/core-runtime/tests/unit/legal-entity-context.test.ts index 165fbf01c..037f6f2c8 100644 --- a/app/packages/core-runtime/tests/unit/legal-entity-context.test.ts +++ b/app/packages/core-runtime/tests/unit/legal-entity-context.test.ts @@ -1,6 +1,5 @@ -import { expect, it } from 'effect-rstest'; - import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; import type { LegalEntityContextRecord } from '../../src/auth/legal-entity-context.ts'; @@ -71,17 +70,14 @@ it.effect('lists zero, one, and many active legal entities in deterministic safe it.effect('validates exactly one active selection and rejects missing or inactive selections', () => Effect.gen(function* validatesLegalEntitySelection() { - expect( - yield* classifySelectedLegalEntity([activeRecord], tenantId, activeRecord.legalEntityId), - ).toEqual({ legalEntityId: activeRecord.legalEntityId, legalName: activeRecord.legalName }); + expect(yield* classifySelectedLegalEntity([activeRecord], tenantId, activeRecord.legalEntityId)).toEqual({ + legalEntityId: activeRecord.legalEntityId, + legalName: activeRecord.legalName, + }); expect( Predicate.isTagged( yield* Effect.flip( - classifySelectedLegalEntity( - [activeRecord], - tenantId, - '20000000-0000-4000-8000-000000000099', - ), + classifySelectedLegalEntity([activeRecord], tenantId, '20000000-0000-4000-8000-000000000099'), ), 'LegalEntityContextMissingError', ), @@ -89,11 +85,7 @@ it.effect('validates exactly one active selection and rejects missing or inactiv expect( Predicate.isTagged( yield* Effect.flip( - classifySelectedLegalEntity( - [{ ...activeRecord, status: 'suspended' }], - tenantId, - activeRecord.legalEntityId, - ), + classifySelectedLegalEntity([{ ...activeRecord, status: 'suspended' }], tenantId, activeRecord.legalEntityId), ), 'LegalEntityContextInactiveError', ), @@ -107,7 +99,12 @@ it.effect('rejects cross-tenant, malformed, and duplicate records', () => Predicate.isTagged( yield* Effect.flip( classifyActiveLegalEntities( - [{ ...activeRecord, tenantId: '10000000-0000-4000-8000-000000000002' }], + [ + { + ...activeRecord, + tenantId: '10000000-0000-4000-8000-000000000002', + }, + ], tenantId, ), ), @@ -116,17 +113,13 @@ it.effect('rejects cross-tenant, malformed, and duplicate records', () => ).toBe(true); expect( Predicate.isTagged( - yield* Effect.flip( - classifyActiveLegalEntities([{ ...activeRecord, legalName: '' }], tenantId), - ), + yield* Effect.flip(classifyActiveLegalEntities([{ ...activeRecord, legalName: '' }], tenantId)), 'LegalEntityContextInvalidError', ), ).toBe(true); expect( Predicate.isTagged( - yield* Effect.flip( - classifyActiveLegalEntities([activeRecord, { ...activeRecord }], tenantId), - ), + yield* Effect.flip(classifyActiveLegalEntities([activeRecord, { ...activeRecord }], tenantId)), 'LegalEntityContextAmbiguousError', ), ).toBe(true); @@ -139,15 +132,17 @@ it.effect('types database failures as sanitized legal-entity context unavailabil executor: yield* makeTestDatabase(() => Effect.fail( new SqlError({ - reason: new ConnectionError({ cause: new Error('secret database diagnostic') }), + reason: new ConnectionError({ + cause: new Error('secret database diagnostic'), + }), }), ), ), }); const error = yield* Effect.flip(context.listActiveForTenant(tenantId)); expect(Predicate.isTagged(error, 'LegalEntityContextUnavailableError')).toBe(true); - expect( - Predicate.isTagged(error, 'LegalEntityContextUnavailableError') ? error.reason : undefined, - ).not.toMatch(/secret database diagnostic/u); + expect(Predicate.isTagged(error, 'LegalEntityContextUnavailableError') ? error.reason : undefined).not.toMatch( + /secret database diagnostic/u, + ); }), ); diff --git a/app/packages/core-runtime/tests/unit/module-catalog.test.ts b/app/packages/core-runtime/tests/unit/module-catalog.test.ts index 18bf00ff2..2fb8a9341 100644 --- a/app/packages/core-runtime/tests/unit/module-catalog.test.ts +++ b/app/packages/core-runtime/tests/unit/module-catalog.test.ts @@ -1,10 +1,9 @@ -import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { expect, it } from 'effect-rstest'; -import { - buildInstalledModuleCatalog, - resolveInstalledModuleCatalog, -} from '../../src/modules/catalog.ts'; + +import { buildInstalledModuleCatalog, resolveInstalledModuleCatalog } from '../../src/modules/catalog.ts'; import type { OntosOutboxSubscriptionContract } from '../../src/modules/manifest.ts'; +import { validateOutboxWorkerSubscriptions } from '../../src/outbox/definition.ts'; +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; const contract = ( appId: string, @@ -32,9 +31,7 @@ it('builds immutable deterministic dual indexes for distinct deployment and modu expect(catalog.deploymentAppIds).toEqual(['documents-center', 'property-registry']); expect(catalog.moduleIds).toEqual(['documents.center', 'property.registry']); - expect(catalog.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe( - 'property.registry', - ); + expect(catalog.getByDeploymentAppId('property-registry')?.manifest.module.id).toBe('property.registry'); expect(catalog.getByModuleId('property.registry')?.deployment.appId).toBe('property-registry'); expect(Object.isFrozen(catalog)).toBe(true); expect(Object.isFrozen(catalog.contracts)).toBe(true); @@ -63,6 +60,9 @@ it('accepts a valid owner-local subscription whose producer is not installed', ( }, ]); expect(catalog.outboxSubscriptions).toEqual([subscription]); + expect(() => validateOutboxWorkerSubscriptions(catalog.outboxSubscriptions)).not.toThrow(); + expect(Object.isFrozen(catalog.outboxSubscriptions[0]?.entrypoint)).toBe(true); + expect(Object.isFrozen(subscription.entrypoint)).toBe(false); }); it('rejects contradictory or incomplete Outbox subscription snapshots', () => { @@ -186,7 +186,10 @@ it('rejects unsupported contract versions without weakening catalog safety', () expect(() => buildInstalledModuleCatalog([ { - contract: { ...contract('property-registry', 'property.registry'), schemaVersion: '0' }, + contract: { + ...contract('property-registry', 'property.registry'), + schemaVersion: '0', + }, expectedAppId: 'property-registry', }, ]), @@ -217,8 +220,16 @@ it('resolves healthy, incompatible, and unreachable deployments independently', expect(catalog.moduleIds).toEqual(['documents.center']); expect(catalog.deploymentStatuses).toEqual([ { appId: 'disabled-center', status: 'disabled' }, - { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, + { + appId: 'documents-center', + moduleId: 'documents.center', + status: 'available', + }, + { + appId: 'property-registry', + reason: 'incompatible', + status: 'unavailable', + }, { appId: 'reporting-center', reason: 'timeout', status: 'unavailable' }, { appId: 'revoked-center', status: 'revoked' }, ]); @@ -234,9 +245,21 @@ for (const scenario of [ moduleIds: ['reporting.center'], name: 'excludes every contradictory claimant while preserving unrelated deployments', statuses: [ - { appId: 'documents-center', reason: 'incompatible', status: 'unavailable' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, - { appId: 'reporting-center', moduleId: 'reporting.center', status: 'available' }, + { + appId: 'documents-center', + reason: 'incompatible', + status: 'unavailable', + }, + { + appId: 'property-registry', + reason: 'incompatible', + status: 'unavailable', + }, + { + appId: 'reporting-center', + moduleId: 'reporting.center', + status: 'available', + }, ], }, { @@ -248,8 +271,16 @@ for (const scenario of [ moduleIds: ['documents.center'], name: 'rejects duplicate deployment identities from tolerant candidate promotion', statuses: [ - { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, + { + appId: 'documents-center', + moduleId: 'documents.center', + status: 'available', + }, + { + appId: 'property-registry', + reason: 'incompatible', + status: 'unavailable', + }, ], }, ] as const) { @@ -284,7 +315,11 @@ it('keeps authoritative revocation ahead of a stale fetched candidate', () => { expect(catalog.moduleIds).toEqual(['documents.center']); expect(catalog.deploymentStatuses).toEqual([ - { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, + { + appId: 'documents-center', + moduleId: 'documents.center', + status: 'available', + }, { appId: 'property-registry', status: 'revoked' }, ]); }); diff --git a/app/packages/core-runtime/tests/unit/module-manifest.test.ts b/app/packages/core-runtime/tests/unit/module-manifest.test.ts index 19f0446eb..c8e4a436c 100644 --- a/app/packages/core-runtime/tests/unit/module-manifest.test.ts +++ b/app/packages/core-runtime/tests/unit/module-manifest.test.ts @@ -1,8 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { defineAction } from '../../src/actions/definition.ts'; -import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { ONTOS_MODULE_CONTRACT_SCHEMA_VERSION, decodeOntosModuleDeploymentContract, @@ -10,6 +10,7 @@ import { validateOntosModuleExecutableReferences, validateOntosModuleManifestFields, } from '../../src/modules/manifest.ts'; +import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { defineVerticalRuntimeRegistration, extractVerticalRuntimeSafeDescriptors, @@ -23,14 +24,20 @@ const UnitId = Schema.String.pipe(Schema.brand('UnitId')); const createAction = (owner = 'property.registry') => defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: `${owner}.read.v1` }, + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: `${owner}.read.v1`, + }, actionKey: `${owner}.create-unit`, auditProfile: 'standard', domainErrorSchema: Schema.Never, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: `${owner}.create-unit`, moduleKey: owner, role: 'action', @@ -51,15 +58,7 @@ const emptyManifestInput = () => ({ defaultState: 'inactive' as const, preservesHistoryWhenInactive: true, scope: 'tenant' as const, - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ] as const, + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const, }, module: { description: 'Property capability', @@ -105,120 +104,121 @@ it('defines a valid empty manifest, preserves literals, and freezes its public s ).toThrow(); }); -it.effect( - 'accepts populated typed surfaces and keeps executable values out of safe descriptors', - () => - Effect.gen(function* verifySafeDescriptors() { - const action = createAction(); - const apiValue = HttpApi.make('PropertyApi').add( - HttpApiGroup.make('property').add(HttpApiEndpoint.get('listUnits', '/units')), - ); - const parameterizedApiValue = HttpApi.make('PropertyDetailApi').add( - HttpApiGroup.make('propertyDetail').add( - HttpApiEndpoint.get('getUnit', '/units/:unitId', { - headers: {}, - params: { unitId: UnitId }, - query: {}, - }), - ), - ); - const manifest = defineOntosModuleManifest({ - ...emptyManifestInput(), - publicSurface: { - actions: [action], - api: { PropertyClient: apiValue, PropertyDetail: parameterizedApiValue }, - components: { PropertyUnitCard: componentValue }, - events: [ - { - key: 'property.unit-created', - owningModuleId: 'property.registry', - payloadSchema: Schema.Struct({ unitId: UnitId }), - referencesResourceTypes: ['property.unit'], - tense: 'past', - visibility: 'public_module_event', - }, - ], - reports: [ - { - accessFiltering: 'legal_entity_scope', - dimensions: ['legal_entity'], - key: 'property.unit-inventory', - label: 'Unit inventory', - owningModuleId: 'property.registry', - resourceTypes: ['property.unit'], - }, - ], - resourceTypes: [ - { - capabilities: { - graphVisible: true, - linkable: true, - mediaAttachable: true, - searchable: true, - timelineVisible: true, - }, - description: 'A physical unit', - key: 'property.unit', - label: 'Unit', - owningModuleId: 'property.registry', - }, - ], - search: [ - { - accessFiltering: 'legal_entity_scope', - key: 'property.unit-search', - owningModuleId: 'property.registry', - resourceType: 'property.unit', - }, - ], - shellContributions: emptyManifestInput().publicSurface.shellContributions, - }, - }); - const registration = defineVerticalRuntimeRegistration({ +it.effect('accepts populated typed surfaces and keeps executable values out of safe descriptors', () => + Effect.gen(function* verifySafeDescriptors() { + const action = createAction(); + const apiValue = HttpApi.make('PropertyApi').add( + HttpApiGroup.make('property').add(HttpApiEndpoint.get('listUnits', '/units')), + ); + const parameterizedApiValue = HttpApi.make('PropertyDetailApi').add( + HttpApiGroup.make('propertyDetail').add( + HttpApiEndpoint.get('getUnit', '/units/:unitId', { + headers: {}, + params: { unitId: UnitId }, + query: {}, + }), + ), + ); + const manifest = defineOntosModuleManifest({ + ...emptyManifestInput(), + publicSurface: { actions: [action], - entrypoints: { - api: { resource: () => Promise.resolve(apiValue) }, - components: { - dashboard: () => Promise.resolve(componentValue), - }, - pages: {}, - reports: {}, - search: {}, + api: { + PropertyClient: apiValue, + PropertyDetail: parameterizedApiValue, }, - manifest, - outboxWorkers: [], - }); - const descriptors = extractVerticalRuntimeSafeDescriptors(registration); - - expect(manifest.publicSurface.actions[0]).toBe(action); - expect(manifest.publicSurface.api.PropertyClient).toBe(apiValue); - expect(manifest.publicSurface.api.PropertyDetail).toBe(parameterizedApiValue); - expect(manifest.publicSurface.components.PropertyUnitCard).toBe(componentValue); - expect(Object.keys(registration)).toEqual(['moduleId']); - expect(getVerticalRuntimeActions(registration)[0]).toBe(action); - const loadDashboard = getVerticalRuntimeEntrypoints(registration).components['dashboard']; - expect(loadDashboard).toBeDefined(); - if (loadDashboard === undefined) { - throw new Error('Expected assertion to hold'); - } - expect(yield* Effect.promise(loadDashboard)).toBe(componentValue); - expect(descriptors).toEqual({ - actions: [ + components: { PropertyUnitCard: componentValue }, + events: [ { - actionKey: 'property.registry.create-unit', - auditProfile: 'standard', - entrypoint: action.descriptor.entrypoint, - idempotency: 'required', - legalEntityScope: 'optional', + key: 'property.unit-created', owningModuleId: 'property.registry', - schemaVersion: '1', + payloadSchema: Schema.Struct({ unitId: UnitId }), + referencesResourceTypes: ['property.unit'], + tense: 'past', + visibility: 'public_module_event', + }, + ], + reports: [ + { + accessFiltering: 'legal_entity_scope', + dimensions: ['legal_entity'], + key: 'property.unit-inventory', + label: 'Unit inventory', + owningModuleId: 'property.registry', + resourceTypes: ['property.unit'], + }, + ], + resourceTypes: [ + { + capabilities: { + graphVisible: true, + linkable: true, + mediaAttachable: true, + searchable: true, + timelineVisible: true, + }, + description: 'A physical unit', + key: 'property.unit', + label: 'Unit', + owningModuleId: 'property.registry', + }, + ], + search: [ + { + accessFiltering: 'legal_entity_scope', + key: 'property.unit-search', + owningModuleId: 'property.registry', + resourceType: 'property.unit', }, ], - moduleId: 'property.registry', - outboxSubscriptions: [], shellContributions: emptyManifestInput().publicSurface.shellContributions, - }); - }), + }, + }); + const registration = defineVerticalRuntimeRegistration({ + actions: [action], + entrypoints: { + api: { resource: () => Promise.resolve(apiValue) }, + components: { + dashboard: () => Promise.resolve(componentValue), + }, + pages: {}, + reports: {}, + search: {}, + }, + manifest, + outboxWorkers: [], + }); + const descriptors = extractVerticalRuntimeSafeDescriptors(registration); + + expect(manifest.publicSurface.actions[0]).toBe(action); + expect(manifest.publicSurface.api.PropertyClient).toBe(apiValue); + expect(manifest.publicSurface.api.PropertyDetail).toBe(parameterizedApiValue); + expect(manifest.publicSurface.components.PropertyUnitCard).toBe(componentValue); + expect(Object.keys(registration)).toEqual(['moduleId']); + expect(getVerticalRuntimeActions(registration)[0]).toBe(action); + const loadDashboard = getVerticalRuntimeEntrypoints(registration).components['dashboard']; + expect(loadDashboard).toBeDefined(); + if (loadDashboard === undefined) { + throw new Error('Expected assertion to hold'); + } + expect(yield* Effect.promise(loadDashboard)).toBe(componentValue); + expect(descriptors).toEqual({ + actions: [ + { + actionKey: 'property.registry.create-unit', + auditProfile: 'standard', + entrypoint: action.descriptor.entrypoint, + idempotency: 'required', + legalEntityScope: 'optional', + owningModuleId: 'property.registry', + schemaVersion: '1', + }, + ], + moduleId: 'property.registry', + outboxSubscriptions: [], + shellContributions: emptyManifestInput().publicSurface.shellContributions, + }); + }), ); it('rejects invalid identities, private fields, duplicates, cross-owner values, and undeclared references', () => { @@ -232,16 +232,12 @@ it('rejects invalid identities, private fields, duplicates, cross-owner values, ...emptyManifestInput(), privateRoutes: [], }; - expect(() => - validateOntosModuleManifestFields(privateRoutesInput, privateRoutesInput.publicSurface), - ).toThrow(); + expect(() => validateOntosModuleManifestFields(privateRoutesInput, privateRoutesInput.publicSurface)).toThrow(); const dependenciesInput = { ...emptyManifestInput(), dependencies: { core: [], externalSystems: [], modules: [] }, }; - expect(() => - validateOntosModuleManifestFields(dependenciesInput, dependenciesInput.publicSurface), - ).toThrow(); + expect(() => validateOntosModuleManifestFields(dependenciesInput, dependenciesInput.publicSurface)).toThrow(); expect(() => defineOntosModuleManifest({ ...emptyManifestInput(), @@ -296,15 +292,15 @@ it('rejects invalid identities, private fields, duplicates, cross-owner values, 'property.registry', ), ).toThrow(/real values created by defineAction/u); - expect(() => - validateOntosModuleExecutableReferences([], [42], [], [], 'property.registry'), - ).toThrow(/real Effect HttpApi/u); - expect(() => - validateOntosModuleExecutableReferences([], [], ['not-a-component'], [], 'property.registry'), - ).toThrow(/callable component/u); - expect(() => - validateOntosModuleExecutableReferences([], [], [], [{}], 'property.registry'), - ).toThrow(/Effect Schema value/u); + expect(() => validateOntosModuleExecutableReferences([], [42], [], [], 'property.registry')).toThrow( + /real Effect HttpApi/u, + ); + expect(() => validateOntosModuleExecutableReferences([], [], ['not-a-component'], [], 'property.registry')).toThrow( + /callable component/u, + ); + expect(() => validateOntosModuleExecutableReferences([], [], [], [{}], 'property.registry')).toThrow( + /Effect Schema value/u, + ); }); it('deployment contract decoding is exact and versioned', () => { @@ -330,7 +326,10 @@ it('deployment contract decoding is exact and versioned', () => { expect(decodeOntosModuleDeploymentContract(contract)).toEqual(contract); expect(contract.schemaVersion).toBe('2'); expect(() => - decodeOntosModuleDeploymentContract({ ...contract, sourcePath: './private.ts' }), + decodeOntosModuleDeploymentContract({ + ...contract, + sourcePath: './private.ts', + }), ).toThrow(); expect(() => decodeOntosModuleDeploymentContract({ @@ -342,7 +341,5 @@ it('deployment contract decoding is exact and versioned', () => { }), ).toThrow(); expect(() => decodeOntosModuleDeploymentContract({ ...contract, schemaVersion: '0' })).toThrow(); - expect(() => - decodeOntosModuleDeploymentContract({ ...contract, schemaVersion: '999' }), - ).toThrow(); + expect(() => decodeOntosModuleDeploymentContract({ ...contract, schemaVersion: '999' })).toThrow(); }); diff --git a/app/packages/core-runtime/tests/unit/module-state-gate.test.ts b/app/packages/core-runtime/tests/unit/module-state-gate.test.ts index 0bbca04bf..d91c6f250 100644 --- a/app/packages/core-runtime/tests/unit/module-state-gate.test.ts +++ b/app/packages/core-runtime/tests/unit/module-state-gate.test.ts @@ -1,12 +1,15 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Tracer, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import type { TrustedPrincipalContext } from '../../src/actions/context.ts'; +import { makeModuleEntrypointGateway } from '../../src/modules/module-entrypoint-gateway.ts'; import { MODULE_ENTRYPOINT_ACCESSES, decodeTenantModuleEntrypoint, defineSystemModuleEntrypoint, defineTenantModuleEntrypoint, } from '../../src/modules/module-entrypoint.ts'; -import { makeModuleEntrypointGateway } from '../../src/modules/module-entrypoint-gateway.ts'; +import type { ModuleEntrypointAccess } from '../../src/modules/module-entrypoint.ts'; import { checkModuleEntrypoint, decideModuleStateAccess, @@ -16,8 +19,6 @@ import { tenantStatesAllowingAccess, } from '../../src/modules/module-state-gate.ts'; import { TENANT_MODULE_STATES } from '../../src/modules/tenant-module-state-service.ts'; -import type { TrustedPrincipalContext } from '../../src/actions/context.ts'; -import type { ModuleEntrypointAccess } from '../../src/modules/module-entrypoint.ts'; import type { TenantModuleState, TenantModuleStateServiceContract, @@ -37,7 +38,10 @@ const makeRecordingTracer = (spans: Tracer.Span[]): Tracer.Tracer => span(options) { const attributes = new Map(); const links = [...options.links]; - let status: Tracer.SpanStatus = { _tag: 'Started', startTime: options.startTime }; + let status: Tracer.SpanStatus = { + _tag: 'Started', + startTime: options.startTime, + }; const span: Tracer.Span = { _tag: 'Span', addLinks: (newLinks) => { @@ -49,7 +53,12 @@ const makeRecordingTracer = (spans: Tracer.Span[]): Tracer.Tracer => }, attributes, end: (endTime, exit) => { - status = { _tag: 'Ended', endTime, exit, startTime: options.startTime }; + status = { + _tag: 'Ended', + endTime, + exit, + startTime: options.startTime, + }; }, event: () => { // This recording tracer's assertions inspect spans, attributes, links, and status only. @@ -70,9 +79,8 @@ const makeRecordingTracer = (spans: Tracer.Span[]): Tracer.Tracer => }, }); -const accessSet = ( - ...accesses: readonly ModuleEntrypointAccess[] -): ReadonlySet => new Set(accesses); +const accessSet = (...accesses: readonly ModuleEntrypointAccess[]): ReadonlySet => + new Set(accesses); const expectedAllowed = { active: accessSet('background', 'historical_read', 'read', 'write'), archived: accessSet('historical_read'), @@ -102,14 +110,20 @@ it('encodes the exhaustive state/access matrix once, including missing state', ( it('constructs frozen tenant and explicit system entrypoints and rejects forged combinations', () => { const tenant = defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', }); const system = defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.modules.change-state', moduleKey: 'core.modules', role: 'action', @@ -128,7 +142,10 @@ it('constructs frozen tenant and explicit system entrypoints and rejects forged expect(() => defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'Invalid', moduleKey: 'inventory.stock', role: 'action', @@ -157,81 +174,92 @@ it('constructs frozen tenant and explicit system entrypoints and rejects forged } }); -it.effect( - 'deduplicates one batch, reuses an immutable snapshot, and fails undeclared keys closed', - () => - Effect.gen(function* reuseSnapshot() { - let reads = 0; - let observedKeys: readonly string[] = []; - const service: TenantModuleStateServiceContract = { - getTenantModuleStates: (_tenantId, moduleKeys) => { - reads += 1; - observedKeys = moduleKeys; - return Effect.succeed( - moduleKeys.map((moduleKey) => ({ - moduleKey, - state: moduleKey === 'billing.invoice' ? ('read_only' as const) : ('active' as const), - })), - ); - }, - listActiveTenantModules: () => Effect.succeed([]), - listTenantModuleStates: () => Effect.succeed([]), - }; - const descriptors = [ - defineTenantModuleEntrypoint({ - access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: 'inventory.stock.page', - moduleKey: 'inventory.stock', - role: 'page', - }), - defineTenantModuleEntrypoint({ - access: 'historical_read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: 'inventory.stock.report', - moduleKey: 'inventory.stock', - role: 'report', - }), - defineTenantModuleEntrypoint({ - access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: 'billing.invoice.search', - moduleKey: 'billing.invoice', - role: 'search', - }), - ] as const; - const snapshot = yield* prepareModuleStateSnapshot(service, 'tenant-1', descriptors); - expect(observedKeys).toEqual(['billing.invoice', 'inventory.stock']); - expect(reads).toBe(1); - expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.isFrozen(snapshot.entrypointKeys)).toBe(true); - expect(Object.isFrozen(snapshot.moduleKeys)).toBe(true); - yield* checkModuleEntrypoint(snapshot, descriptors[0]); - yield* checkModuleEntrypoint(snapshot, descriptors[0]); - expect(reads).toBe(1); - - const undeclared = defineTenantModuleEntrypoint({ +it.effect('deduplicates one batch, reuses an immutable snapshot, and fails undeclared keys closed', () => + Effect.gen(function* reuseSnapshot() { + let reads = 0; + let observedKeys: readonly string[] = []; + const service: TenantModuleStateServiceContract = { + getTenantModuleStates: (_tenantId, moduleKeys) => { + reads += 1; + observedKeys = moduleKeys; + return Effect.succeed( + moduleKeys.map((moduleKey) => ({ + moduleKey, + state: moduleKey === 'billing.invoice' ? ('read_only' as const) : ('active' as const), + })), + ); + }, + listActiveTenantModules: () => Effect.succeed([]), + listTenantModuleStates: () => Effect.succeed([]), + }; + const descriptors = [ + defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, - entrypointKey: 'people.directory.page', - moduleKey: 'people.directory', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: 'inventory.stock.page', + moduleKey: 'inventory.stock', role: 'page', - }); - const failure = yield* Effect.flip(checkModuleEntrypoint(snapshot, undeclared)); - expect(Predicate.isTagged(failure, 'ModuleStateCheckUnavailableError')).toBe(true); - const undeclaredSameModule = defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'inventory.stock.undeclared-action', + }), + defineTenantModuleEntrypoint({ + access: 'historical_read', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: 'inventory.stock.report', moduleKey: 'inventory.stock', - role: 'action', - }); - const sameModuleFailure = yield* Effect.flip( - checkModuleEntrypoint(snapshot, undeclaredSameModule), - ); - expect(Predicate.isTagged(sameModuleFailure, 'ModuleStateCheckUnavailableError')).toBe(true); - expect(reads).toBe(1); - }), + role: 'report', + }), + defineTenantModuleEntrypoint({ + access: 'read', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: 'billing.invoice.search', + moduleKey: 'billing.invoice', + role: 'search', + }), + ] as const; + const snapshot = yield* prepareModuleStateSnapshot(service, 'tenant-1', descriptors); + expect(observedKeys).toEqual(['billing.invoice', 'inventory.stock']); + expect(reads).toBe(1); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.entrypointKeys)).toBe(true); + expect(Object.isFrozen(snapshot.moduleKeys)).toBe(true); + yield* checkModuleEntrypoint(snapshot, descriptors[0]); + yield* checkModuleEntrypoint(snapshot, descriptors[0]); + expect(reads).toBe(1); + + const undeclared = defineTenantModuleEntrypoint({ + access: 'read', + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, + entrypointKey: 'people.directory.page', + moduleKey: 'people.directory', + role: 'page', + }); + const failure = yield* Effect.flip(checkModuleEntrypoint(snapshot, undeclared)); + expect(Predicate.isTagged(failure, 'ModuleStateCheckUnavailableError')).toBe(true); + const undeclaredSameModule = defineTenantModuleEntrypoint({ + access: 'write', + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, + entrypointKey: 'inventory.stock.undeclared-action', + moduleKey: 'inventory.stock', + role: 'action', + }); + const sameModuleFailure = yield* Effect.flip(checkModuleEntrypoint(snapshot, undeclaredSameModule)); + expect(Predicate.isTagged(sameModuleFailure, 'ModuleStateCheckUnavailableError')).toBe(true); + expect(reads).toBe(1); + }), ); it.effect('records safe acquisition and evaluation telemetry including snapshot reuse', () => @@ -240,14 +268,16 @@ it.effect('records safe acquisition and evaluation telemetry including snapshot const tracer = makeRecordingTracer(spans); const descriptor = defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'inventory.stock.page', moduleKey: 'inventory.stock', role: 'page', }); const service: TenantModuleStateServiceContract = { - getTenantModuleStates: () => - Effect.succeed([{ moduleKey: 'inventory.stock', state: 'active' }]), + getTenantModuleStates: () => Effect.succeed([{ moduleKey: 'inventory.stock', state: 'active' }]), listActiveTenantModules: () => Effect.succeed([]), listTenantModuleStates: () => Effect.succeed([]), }; @@ -294,7 +324,10 @@ it.effect('empty and system-only compositions perform zero reads', () => }; const system = defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.audit.page', moduleKey: 'core.audit', role: 'page', @@ -312,7 +345,10 @@ it.effect('the gateway rejects missing trusted principal context before state ac let reads = 0; const descriptor = defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'inventory.stock.page', moduleKey: 'inventory.stock', role: 'page', @@ -325,9 +361,7 @@ it.effect('the gateway rejects missing trusted principal context before state ac listActiveTenantModules: () => Effect.succeed([]), listTenantModuleStates: () => Effect.succeed([]), }); - const failure = yield* Effect.flip( - makeModuleEntrypointGateway(gate).prepareSnapshotInput({}, [descriptor]), - ); + const failure = yield* Effect.flip(makeModuleEntrypointGateway(gate).prepareSnapshotInput({}, [descriptor])); expect(Predicate.isTagged(failure, 'ModuleStateCheckUnavailableError')).toBe(true); expect(reads).toBe(0); }), @@ -359,63 +393,90 @@ it.effect('gates every future entrypoint category before its fake implementation const allowed = [ defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.active.page', moduleKey: 'module.active', role: 'page', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.active.component', moduleKey: 'module.active', role: 'public_component', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.read-only.api', moduleKey: 'module.read-only', role: 'api', }), defineTenantModuleEntrypoint({ access: 'historical_read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.suspended.api-history', moduleKey: 'module.suspended', role: 'api', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.deprecated.search', moduleKey: 'module.deprecated', role: 'search', }), defineTenantModuleEntrypoint({ access: 'historical_read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.inactive.search-history', moduleKey: 'module.inactive', role: 'search', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.deprecated.report', moduleKey: 'module.deprecated', role: 'report', }), defineTenantModuleEntrypoint({ access: 'historical_read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.archived.report-history', moduleKey: 'module.archived', role: 'report', }), defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.audit.page', moduleKey: 'core.audit', role: 'page', @@ -424,28 +485,40 @@ it.effect('gates every future entrypoint category before its fake implementation const denied = [ defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.read-only.api-write', moduleKey: 'module.read-only', role: 'api', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.inactive.search', moduleKey: 'module.inactive', role: 'search', }), defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.archived.report', moduleKey: 'module.archived', role: 'report', }), defineTenantModuleEntrypoint({ access: 'historical_read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'module.missing.report-history', moduleKey: 'module.missing', role: 'report', @@ -466,11 +539,9 @@ it.effect('gates every future entrypoint category before its fake implementation snapshot, }); yield* Effect.forEach(allowed, run, { concurrency: 'unbounded' }); - const deniedFailures = yield* Effect.forEach( - denied, - (entrypoint) => Effect.flip(run(entrypoint)), - { concurrency: 'unbounded' }, - ); + const deniedFailures = yield* Effect.forEach(denied, (entrypoint) => Effect.flip(run(entrypoint)), { + concurrency: 'unbounded', + }); for (const failure of deniedFailures) { expect(Predicate.isTagged(failure, 'ModuleStateDeniedError')).toBe(true); } @@ -483,15 +554,17 @@ it.effect('gates every future entrypoint category before its fake implementation it.effect('the gateway never evaluates authorization or lazy implementation on denial', () => Effect.gen(function* denyBeforeLoading() { const gate = makeModuleStateGate({ - getTenantModuleStates: () => - Effect.succeed([{ moduleKey: 'inventory.stock', state: 'read_only' }]), + getTenantModuleStates: () => Effect.succeed([{ moduleKey: 'inventory.stock', state: 'read_only' }]), listActiveTenantModules: () => Effect.succeed([]), listTenantModuleStates: () => Effect.succeed([]), }); const gateway = makeModuleEntrypointGateway(gate); const descriptor = defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'inventory.stock.reserve', moduleKey: 'inventory.stock', role: 'action', @@ -527,7 +600,10 @@ it.effect('a missing row is a definite denial rather than an unavailable read', Effect.gen(function* denyMissingRow() { const descriptor = defineTenantModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'inventory.stock.page', moduleKey: 'inventory.stock', role: 'page', diff --git a/app/packages/core-runtime/tests/unit/native-transaction-context.test.ts b/app/packages/core-runtime/tests/unit/native-transaction-context.test.ts index 1bb0ca200..4e742fdae 100644 --- a/app/packages/core-runtime/tests/unit/native-transaction-context.test.ts +++ b/app/packages/core-runtime/tests/unit/native-transaction-context.test.ts @@ -1,17 +1,7 @@ +import { Clock, Config, ConfigProvider, Context, Effect, Layer, Logger, Option, References, Tracer } from 'effect'; import { expect, it } from 'effect-rstest'; -import { - Clock, - Config, - ConfigProvider, - Context, - Effect, - Layer, - Logger, - Option, - References, - Tracer, -} from 'effect'; import { TestClock } from 'effect/testing'; + import { makeTestDatabase } from '../support/database.ts'; it.effect('preserves a caller Context.Reference override instead of its default', () => @@ -23,9 +13,7 @@ it.effect('preserves a caller Context.Reference override instead of its default' defaultValue: () => fallback, }); expect(yield* reference).toBe(fallback); - const actual = yield* executor - .transaction(() => reference) - .pipe(Effect.provideService(reference, override)); + const actual = yield* executor.transaction(() => reference).pipe(Effect.provideService(reference, override)); expect(actual).toBe(override); expect(yield* reference).toBe(fallback); }), @@ -66,9 +54,7 @@ it.effect('preserves a caller TestClock inside the transaction', () => const actual = yield* Effect.gen(function* virtualClockProgram() { const clock = yield* TestClock.make(); yield* clock.setTime(1234); - return yield* executor - .transaction(() => Clock.currentTimeMillis) - .pipe(Effect.provideService(Clock.Clock, clock)); + return yield* executor.transaction(() => Clock.currentTimeMillis).pipe(Effect.provideService(Clock.Clock, clock)); }).pipe(Effect.scoped); expect(actual).toBe(1234); }), @@ -77,7 +63,9 @@ it.effect('preserves a caller TestClock inside the transaction', () => it.effect('loads configuration from the caller provider inside the transaction', () => Effect.gen(function* preserveConfig() { const executor = yield* makeTestDatabase(() => Effect.succeed([])); - const provider = ConfigProvider.fromUnknown({ NATIVE_CONTEXT_TEST_VALUE: 'caller-config' }); + const provider = ConfigProvider.fromUnknown({ + NATIVE_CONTEXT_TEST_VALUE: 'caller-config', + }); const actual = yield* executor .transaction(() => Config.string('NATIVE_CONTEXT_TEST_VALUE')) .pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider)); @@ -115,14 +103,12 @@ const spanPreservation = Effect.gen(function* preserveSpans() { expect(actual.child.parent.value).toBe(actual.parent); }); -it.layer( - Layer.succeed(Tracer.Tracer, Tracer.make({ span: (options) => new Tracer.NativeSpan(options) })), -)('native transaction tracing', (tracingIt) => { - tracingIt.effect( - 'preserves the caller span and parents transaction child spans to it', - () => spanPreservation, - ); -}); +it.layer(Layer.succeed(Tracer.Tracer, Tracer.make({ span: (options) => new Tracer.NativeSpan(options) })))( + 'native transaction tracing', + (tracingIt) => { + tracingIt.effect('preserves the caller span and parents transaction child spans to it', () => spanPreservation); + }, +); it.effect('emits transaction logs with caller annotations and logger', () => Effect.gen(function* preserveLogging() { @@ -134,7 +120,10 @@ it.effect('emits transaction logs with caller annotations and logger', () => yield* executor .transaction(() => Effect.logInfo('transaction-body')) .pipe( - Effect.annotateLogs({ operation: 'context-test', requestId: 'native-request' }), + Effect.annotateLogs({ + operation: 'context-test', + requestId: 'native-request', + }), Effect.provideService(Logger.CurrentLoggers, new Set([logger])), ); expect(records).toEqual([{ operation: 'context-test', requestId: 'native-request' }]); diff --git a/app/packages/core-runtime/tests/unit/native-transaction.test.ts b/app/packages/core-runtime/tests/unit/native-transaction.test.ts index 5dda7011b..c94884692 100644 --- a/app/packages/core-runtime/tests/unit/native-transaction.test.ts +++ b/app/packages/core-runtime/tests/unit/native-transaction.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { sql } from 'drizzle-orm'; import { Cause, Context, Deferred, Effect, Exit, Fiber } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; + import { makeTestDatabase } from '../support/database.ts'; const harness = Effect.fn(function* makeHarness( @@ -101,21 +102,15 @@ for (const phase of ['COMMIT', 'ROLLBACK']) { const failure = new SqlError({ reason: new ConnectionError({ cause: new Error(`${phase} failed`) }), }); - const h = yield* harness((statement) => - statement === phase ? Effect.fail(failure) : Effect.void, - ); + const h = yield* harness((statement) => (statement === phase ? Effect.fail(failure) : Effect.void)); const exit = yield* Effect.exit( - h.executor.transaction(() => - phase === 'COMMIT' ? Effect.succeed(42) : Effect.fail('body failure'), - ), + h.executor.transaction(() => (phase === 'COMMIT' ? Effect.succeed(42) : Effect.fail('body failure'))), ); expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { throw new Error('Expected assertion to hold'); } - expect( - exit.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === failure), - ).toBe(true); + expect(exit.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === failure)).toBe(true); expect(h.events).toEqual(['BEGIN', phase]); }), ); @@ -132,20 +127,14 @@ it.effect( const releaseRollback = yield* Deferred.make(); const h = yield* harness((statement) => statement === 'ROLLBACK' - ? Deferred.succeed(rollingBack, null).pipe( - Effect.andThen(Deferred.await(releaseRollback)), - ) + ? Deferred.succeed(rollingBack, null).pipe(Effect.andThen(Deferred.await(releaseRollback))) : Effect.void, ); const fiber = yield* h.executor .transaction(() => Deferred.succeed(started, null).pipe( Effect.andThen(Effect.never), - Effect.ensuring( - Deferred.succeed(finalizing, null).pipe( - Effect.andThen(Deferred.await(releaseFinalizer)), - ), - ), + Effect.ensuring(Deferred.succeed(finalizing, null).pipe(Effect.andThen(Deferred.await(releaseFinalizer)))), ), ) .pipe(Effect.forkChild); @@ -176,7 +165,9 @@ for (const phase of ['COMMIT', 'ROLLBACK']) { () => Effect.gen(function* preserveSettlementFailure() { const failure = new SqlError({ - reason: new ConnectionError({ cause: new Error(`${phase} rejected`) }), + reason: new ConnectionError({ + cause: new Error(`${phase} rejected`), + }), }); const started = yield* Deferred.make(); const release = yield* Deferred.make(); @@ -189,9 +180,7 @@ for (const phase of ['COMMIT', 'ROLLBACK']) { : Effect.void, ); const fiber = yield* h.executor - .transaction(() => - phase === 'COMMIT' ? Effect.succeed(42) : Effect.fail('domain failure'), - ) + .transaction(() => (phase === 'COMMIT' ? Effect.succeed(42) : Effect.fail('domain failure'))) .pipe(Effect.forkChild); yield* Deferred.await(started); const interrupt = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild); @@ -205,11 +194,7 @@ for (const phase of ['COMMIT', 'ROLLBACK']) { throw new Error('Expected assertion to hold'); } // Native settlement defects take precedence over pending interruption. - expect( - exit.cause.reasons.some( - (reason) => Cause.isDieReason(reason) && reason.defect === failure, - ), - ).toBe(true); + expect(exit.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === failure)).toBe(true); }), 2000, ); diff --git a/app/packages/core-runtime/tests/unit/operation-context.test.ts b/app/packages/core-runtime/tests/unit/operation-context.test.ts index 1fc57eae3..cd8287aed 100644 --- a/app/packages/core-runtime/tests/unit/operation-context.test.ts +++ b/app/packages/core-runtime/tests/unit/operation-context.test.ts @@ -1,5 +1,6 @@ +import { DateTime, Effect, Exit, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; -import { DateTime, Effect, Exit, Option, Schema } from 'effect'; + import { supportRecoveryPrincipalContextResolverFromRepository } from '../../src/auth/support-recovery-principal-context.ts'; import { decodeTrustedPrincipalContext, @@ -43,87 +44,88 @@ const access = (decision: 'allowed' | 'denied' | 'unavailable') => ({ modules: () => Effect.succeed([]), resources: () => Effect.succeed([]), }); -const InactiveContextError = Schema.Union([ - OperationAuthenticationRequired, - OperationContextDenied, -]); +const InactiveContextError = Schema.Union([OperationAuthenticationRequired, OperationContextDenied]); -it.effect( - 'classifies required, optional, forbidden, denied, unavailable, and valid scope before handlers', - () => - Effect.gen(function* scopeClassification() { - const repository = { load: () => Effect.succeed(active) }; - const allowed = makeOperationalScopeResolver(repository, access('allowed')); - const valid = yield* allowed.resolve({ +it.effect('classifies required, optional, forbidden, denied, unavailable, and valid scope before handlers', () => + Effect.gen(function* scopeClassification() { + const repository = { load: () => Effect.succeed(active) }; + const allowed = makeOperationalScopeResolver(repository, access('allowed')); + const valid = yield* allowed.resolve({ + correlationId: 'c-1', + legalEntityScope: 'required', + principal, + }); + const { legalEntityId: _legalEntityId, ...principalWithoutLegalEntity } = principal; + const missing = yield* Effect.flip( + allowed.resolve({ correlationId: 'c-1', legalEntityScope: 'required', + principal: principalWithoutLegalEntity, + }), + ); + const forbidden = yield* Effect.flip( + allowed.resolve({ + correlationId: 'c-1', + legalEntityScope: 'forbidden', + principal, + }), + ); + const denied = yield* Effect.flip( + makeOperationalScopeResolver(repository, access('denied')).resolve({ + correlationId: 'c-1', + legalEntityScope: 'optional', principal, - }); - const { legalEntityId: _legalEntityId, ...principalWithoutLegalEntity } = principal; - const missing = yield* Effect.flip( - allowed.resolve({ - correlationId: 'c-1', - legalEntityScope: 'required', - principal: principalWithoutLegalEntity, - }), - ); - const forbidden = yield* Effect.flip( - allowed.resolve({ correlationId: 'c-1', legalEntityScope: 'forbidden', principal }), - ); - const denied = yield* Effect.flip( - makeOperationalScopeResolver(repository, access('denied')).resolve({ - correlationId: 'c-1', - legalEntityScope: 'optional', - principal, - }), - ); - const unavailable = yield* Effect.flip( - makeOperationalScopeResolver(repository, access('unavailable')).resolve({ - correlationId: 'c-1', - legalEntityScope: 'optional', - principal, - }), - ); + }), + ); + const unavailable = yield* Effect.flip( + makeOperationalScopeResolver(repository, access('unavailable')).resolve({ + correlationId: 'c-1', + legalEntityScope: 'optional', + principal, + }), + ); - expect(Object.isFrozen(valid)).toBe(true); - expect(Schema.is(OperationContextDenied)(missing)).toBe(true); - expect(Schema.is(OperationContextInvalid)(forbidden)).toBe(true); - expect(Schema.is(OperationContextDenied)(denied)).toBe(true); - expect(Schema.is(OperationContextUnavailable)(unavailable)).toBe(true); - }), + expect(Object.isFrozen(valid)).toBe(true); + expect(Schema.is(OperationContextDenied)(missing)).toBe(true); + expect(Schema.is(OperationContextInvalid)(forbidden)).toBe(true); + expect(Schema.is(OperationContextDenied)(denied)).toBe(true); + expect(Schema.is(OperationContextUnavailable)(unavailable)).toBe(true); + }), ); -it.effect( - 'rejects stale tenant, principal, revoked auth binding, and cross-tenant entity records', - () => - Effect.gen(function* staleContextRecords() { - const records = [ - { ...active, tenantStatus: 'suspended' }, - { ...active, principalStatus: 'disabled' }, - { - ...active, - bindingRevokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), - }, - { ...active, bindingTenantId: '00000000-0000-4000-8000-000000000099' }, - { ...active, legalEntityTenantId: '00000000-0000-4000-8000-000000000099' }, - ]; - const errors = yield* Effect.forEach( - records, - (record) => { - const resolver = makeOperationalScopeResolver( - { load: () => Effect.succeed(record) }, - access('allowed'), - ); - return Effect.flip( - resolver.resolve({ correlationId: 'c-1', legalEntityScope: 'required', principal }), - ); - }, - { concurrency: 1 }, - ); - for (const error of errors) { - expect(Schema.is(InactiveContextError)(error)).toBe(true); - } - }), +it.effect('rejects stale tenant, principal, revoked auth binding, and cross-tenant entity records', () => + Effect.gen(function* staleContextRecords() { + const records = [ + { ...active, tenantStatus: 'suspended' }, + { ...active, principalStatus: 'disabled' }, + { + ...active, + bindingRevokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), + }, + { ...active, bindingTenantId: '00000000-0000-4000-8000-000000000099' }, + { + ...active, + legalEntityTenantId: '00000000-0000-4000-8000-000000000099', + }, + ]; + const errors = yield* Effect.forEach( + records, + (record) => { + const resolver = makeOperationalScopeResolver({ load: () => Effect.succeed(record) }, access('allowed')); + return Effect.flip( + resolver.resolve({ + correlationId: 'c-1', + legalEntityScope: 'required', + principal, + }), + ); + }, + { concurrency: 1 }, + ); + for (const error of errors) { + expect(Schema.is(InactiveContextError)(error)).toBe(true); + } + }), ); it.effect('preserves resolver-issued system provenance across operational scope construction', () => @@ -134,10 +136,12 @@ it.effect('preserves resolver-issued system provenance across operational scope kind: 'system' as const, principalStatus: 'active' as const, tenantStatus: 'active' as const, - }).pipe(Effect.map(Option.some)), + }).pipe(Effect.asSome), }).resolve({ principalId: principal.principalId, - registration: registerSystemWorkload({ jobKey: 'operation-scope-test' }), + registration: registerSystemWorkload({ + jobKey: 'operation-scope-test', + }), runReference: 'run-1', tenantId: principal.tenantId, }); @@ -172,49 +176,45 @@ it.effect('preserves resolver-issued system provenance across operational scope }), ); -it.effect( - 'permits only a resolver-branded support-stop recovery through inactive historical scope', - () => - Effect.gen(function* supportRecovery() { - const recoveryPrincipal = yield* supportRecoveryPrincipalContextResolverFromRepository({ +it.effect('permits only a resolver-branded support-stop recovery through inactive historical scope', () => + Effect.gen(function* supportRecovery() { + const recoveryPrincipal = yield* supportRecoveryPrincipalContextResolverFromRepository({ + load: () => + Effect.succeed({ + bindingPrincipalId: principal.principalId, + bindingTenantId: principal.tenantId, + principalKind: 'human' as const, + principalTenantId: principal.tenantId, + tenantId: principal.tenantId, + }).pipe(Effect.asSome), + }).resolveStoppedImpersonation({ + originalAuthBindingId: principal.authBindingId, + originalPrincipalId: principal.principalId, + originalSessionId: 'expired-original-session', + tenantId: principal.tenantId, + }); + const resolver = makeOperationalScopeResolver( + { load: () => Effect.succeed({ - bindingPrincipalId: principal.principalId, - bindingTenantId: principal.tenantId, - principalKind: 'human' as const, - principalTenantId: principal.tenantId, - tenantId: principal.tenantId, - }).pipe(Effect.map(Option.some)), - }).resolveStoppedImpersonation({ - originalAuthBindingId: principal.authBindingId, - originalPrincipalId: principal.principalId, - originalSessionId: 'expired-original-session', - tenantId: principal.tenantId, - }); - const resolver = makeOperationalScopeResolver( - { - load: () => - Effect.succeed({ - ...active, - bindingRevokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-08-09T00:00:00.000Z')), - bindingStatus: 'revoked', - principalStatus: 'disabled', - tenantStatus: 'suspended', - }), - }, - access('allowed'), - ); + ...active, + bindingRevokedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-08-09T00:00:00.000Z')), + bindingStatus: 'revoked', + principalStatus: 'disabled', + tenantStatus: 'suspended', + }), + }, + access('allowed'), + ); - const scope = yield* resolver.resolve({ - correlationId: 'support-recovery', - legalEntityScope: 'optional', - principal: recoveryPrincipal, - }); + const scope = yield* resolver.resolve({ + correlationId: 'support-recovery', + legalEntityScope: 'optional', + principal: recoveryPrincipal, + }); - expect( - isTrustedSupportRecoveryPrincipalContext(scope, recordSupportImpersonationAction), - ).toBe(true); - expect(isTrustedSupportRecoveryPrincipalContext(scope, {})).toBe(false); - expect(isTrustedSupportRecoveryPrincipalContext({ ...scope })).toBe(false); - }), + expect(isTrustedSupportRecoveryPrincipalContext(scope, recordSupportImpersonationAction)).toBe(true); + expect(isTrustedSupportRecoveryPrincipalContext(scope, {})).toBe(false); + expect(isTrustedSupportRecoveryPrincipalContext({ ...scope })).toBe(false); + }), ); diff --git a/app/packages/core-runtime/tests/unit/outbox-definition.test.ts b/app/packages/core-runtime/tests/unit/outbox-definition.test.ts index 316698d5f..edbdc9ce3 100644 --- a/app/packages/core-runtime/tests/unit/outbox-definition.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-definition.test.ts @@ -1,5 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { defineOutboxWorker, getOutboxWorkerHandler, @@ -7,7 +9,6 @@ import { validateOutboxWorkerRegistrations, validateOutboxWorkerSubscriptions, } from '../../src/outbox/definition.ts'; -import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { OutboxWorkerDescriptorError } from '../../src/outbox/errors.ts'; const MessageKey = Schema.String.pipe(Schema.brand('MessageKey')); @@ -67,7 +68,9 @@ it.effect('defines an exact immutable registration while keeping the handler opa expect(Object.isFrozen(worker.descriptor.retryPolicy)).toBe(true); expect('handler' in worker).toBe(false); expect(Object.keys(worker)).toEqual(['descriptor']); - const payload = yield* Schema.decodeUnknownEffect(payloadSchema)({ messageKey: 'message-1' }); + const payload = yield* Schema.decodeEffect(payloadSchema)({ + messageKey: 'message-1', + }); yield* getOutboxWorkerHandler(worker)(payload, { attemptNumber: 1, claimId: 'claim-1', @@ -135,9 +138,7 @@ it('rejects invalid identities, retry policies, and lease policies', () => { { ...valid, retryPolicy: { ...valid.retryPolicy, multiplier: 0 } }, ]; for (const descriptor of invalidDescriptors) { - expect(() => defineOutboxWorker(descriptor, () => Effect.void)).toThrow( - OutboxWorkerDescriptorError, - ); + expect(() => defineOutboxWorker(descriptor, () => Effect.void)).toThrow(OutboxWorkerDescriptorError); } }); it('rejects duplicate worker keys and calculates bounded exponential backoff', () => { diff --git a/app/packages/core-runtime/tests/unit/outbox-errors.test.ts b/app/packages/core-runtime/tests/unit/outbox-errors.test.ts index f6caf1a7b..084baa69b 100644 --- a/app/packages/core-runtime/tests/unit/outbox-errors.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-errors.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Predicate, Schema, Struct } from 'effect'; import type { Cause } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { OutboxClaimLostError, OutboxHandlerExecutionError, @@ -22,29 +23,33 @@ const errorSchemas = [ ]; const checkErrorContract = ( - schema: Schema.Codec< - Failure, - { readonly _tag: string; readonly code: string; readonly reason: string } - >, + schema: Schema.Codec, failure: Failure, - encoded: { readonly _tag: string; readonly code: string; readonly reason: string }, + encoded: { + readonly _tag: string; + readonly code: string; + readonly reason: string; + }, ): void => { it.effect(`${encoded._tag} preserves its schema and yieldable failure contract`, () => Effect.gen(function* errorContract() { expect(Schema.is(schema)(failure)).toBeTruthy(); expect(yield* Schema.encodeEffect(schema)(failure)).toEqual(encoded); - const decoded = yield* Schema.decodeUnknownEffect(schema)(encoded); + const decoded = yield* Schema.decodeEffect(schema)(encoded); expect(Schema.is(schema)(decoded)).toBeTruthy(); expect(yield* Schema.encodeEffect(schema)(decoded)).toEqual(encoded); for (const otherSchema of errorSchemas) { expect(Schema.is(otherSchema)(failure)).toBe(Object.is(otherSchema, schema)); expect(Schema.is(otherSchema)(decoded)).toBe(Object.is(otherSchema, schema)); } - expect(() => Schema.decodeUnknownSync(schema)({ ...encoded, _tag: 'WrongError' })).toThrow(); - expect(() => Schema.decodeUnknownSync(schema)({ ...encoded, code: 'wrong_code' })).toThrow(); + expect(() => Schema.decodeSync(schema)({ ...encoded, _tag: 'WrongError' })).toThrow(); + expect(() => Schema.decodeSync(schema)({ ...encoded, code: 'wrong_code' })).toThrow(); expect(() => Schema.decodeUnknownSync(schema)({ ...encoded, reason: 42 })).toThrow(); expect(() => - Schema.decodeUnknownSync(schema)({ _tag: encoded._tag, code: encoded.code }), + Schema.decodeUnknownSync(schema)({ + _tag: encoded._tag, + code: encoded.code, + }), ).toThrow(); const yielded = yield* Effect.flip( Effect.gen(function* yieldFailure() { @@ -78,19 +83,33 @@ checkErrorContract( ); checkErrorContract( OutboxPayloadDecodeError, - new OutboxPayloadDecodeError({ code: 'outbox_payload_invalid', reason: 'detail' }), - { _tag: 'OutboxPayloadDecodeError', code: 'outbox_payload_invalid', reason: 'detail' }, + new OutboxPayloadDecodeError({ + code: 'outbox_payload_invalid', + reason: 'detail', + }), + { + _tag: 'OutboxPayloadDecodeError', + code: 'outbox_payload_invalid', + reason: 'detail', + }, ); checkErrorContract( OutboxPersistenceError, - new OutboxPersistenceError({ code: 'outbox_persistence_failed', reason: 'detail' }), - { _tag: 'OutboxPersistenceError', code: 'outbox_persistence_failed', reason: 'detail' }, -); -checkErrorContract( - OutboxClaimLostError, - new OutboxClaimLostError({ code: 'outbox_claim_lost', reason: 'detail' }), - { _tag: 'OutboxClaimLostError', code: 'outbox_claim_lost', reason: 'detail' }, + new OutboxPersistenceError({ + code: 'outbox_persistence_failed', + reason: 'detail', + }), + { + _tag: 'OutboxPersistenceError', + code: 'outbox_persistence_failed', + reason: 'detail', + }, ); +checkErrorContract(OutboxClaimLostError, new OutboxClaimLostError({ code: 'outbox_claim_lost', reason: 'detail' }), { + _tag: 'OutboxClaimLostError', + code: 'outbox_claim_lost', + reason: 'detail', +}); checkErrorContract( OutboxHandlerExecutionError, new OutboxHandlerExecutionError({ @@ -105,8 +124,15 @@ checkErrorContract( ); checkErrorContract( OutboxPollerConfigError, - new OutboxPollerConfigError({ code: 'outbox_poller_config_invalid', reason: 'detail' }), - { _tag: 'OutboxPollerConfigError', code: 'outbox_poller_config_invalid', reason: 'detail' }, + new OutboxPollerConfigError({ + code: 'outbox_poller_config_invalid', + reason: 'detail', + }), + { + _tag: 'OutboxPollerConfigError', + code: 'outbox_poller_config_invalid', + reason: 'detail', + }, ); it('persistence errors keep the original cause private and immutable', () => { @@ -119,9 +145,7 @@ it('persistence errors keep the original cause private and immutable', () => { value: cause, writable: false, }); - expect(Object.getOwnPropertyDescriptor(failure, 'ontosOutboxPersistenceCause')?.value).toBe( - cause, - ); + expect(Object.getOwnPropertyDescriptor(failure, 'ontosOutboxPersistenceCause')?.value).toBe(cause); expect(Object.keys(failure).includes('ontosOutboxPersistenceCause')).toBe(false); expect(JSON.stringify(failure).includes('database credential')).toBe(false); const encoded = Schema.encodeSync(OutboxPersistenceError)(failure); @@ -130,18 +154,11 @@ it('persistence errors keep the original cause private and immutable', () => { code: 'outbox_persistence_failed', reason: 'The Outbox Worker persistence operation failed', }); - expect( - Object.hasOwn( - Schema.decodeUnknownSync(OutboxPersistenceError)(encoded), - 'ontosOutboxPersistenceCause', - ), - ).toBe(false); + expect(Object.hasOwn(Schema.decodeSync(OutboxPersistenceError)(encoded), 'ontosOutboxPersistenceCause')).toBe(false); }); it('sanitizer normalizes control whitespace, trims, truncates and falls back', () => { - expect(sanitizeOutboxErrorMessage(' \r\nfirst\r\n\tsecond\t third \n')).toBe( - 'first second third', - ); + expect(sanitizeOutboxErrorMessage(' \r\nfirst\r\n\tsecond\t third \n')).toBe('first second third'); expect(sanitizeOutboxErrorMessage(' plain detail ')).toBe('plain detail'); expect(sanitizeOutboxErrorMessage(` ${'x'.repeat(501)} `)).toBe('x'.repeat(500)); expect(sanitizeOutboxErrorMessage(' \r\n\t ')).toBe('Outbox Worker processing failed'); diff --git a/app/packages/core-runtime/tests/unit/outbox-health.test.ts b/app/packages/core-runtime/tests/unit/outbox-health.test.ts index b79f2c098..ed1272bab 100644 --- a/app/packages/core-runtime/tests/unit/outbox-health.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-health.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { ConfigProvider, Effect, Layer, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient, HttpClient } from 'effect/unstable/http'; + import { createOutboxWorkerHealth, serveOutboxWorkerHealth } from '../../src/outbox/health.ts'; import { runOutboxWorkerProcess } from '../../src/outbox/process.ts'; import { OutboxRuntime } from '../../src/outbox/runtime.ts'; @@ -13,86 +14,77 @@ it.live('production health binds all IPv4 interfaces for external-container prob }), ); -it.live( - 'readiness starts false, follows successful/failing cycles, expires, and closes on shutdown', - () => { - let now = 1000; - return Effect.gen(function* healthLifecycle() { - const services = yield* Layer.build(FetchHttpClient.layer); - const client = yield* Effect.provide(HttpClient.HttpClient, services); - const health = yield* createOutboxWorkerHealth({ - now: Effect.sync(() => now), - staleAfterMs: 100, - }); - const server = yield* serveOutboxWorkerHealth(health, { port: 0 }); - const ready = client.get(`http://127.0.0.1:${server.port}/ready`); - const startingResponse = yield* ready; - expect(startingResponse.status).toBe(503); - expect(yield* startingResponse.json).toEqual({ ready: false }); - expect((yield* client.get(`http://127.0.0.1:${server.port}/unknown`)).status).toBe(404); - yield* health.cycleSucceeded; - const readyResponse = yield* ready; - expect(readyResponse.status).toBe(200); - expect(yield* readyResponse.json).toEqual({ ready: true }); - now = 1101; - expect((yield* ready).status).toBe(503); - yield* health.cycleSucceeded; - yield* health.cycleFailed; - expect((yield* ready).status).toBe(503); - yield* health.cycleSucceeded; - yield* health.shuttingDown; - expect((yield* ready).status).toBe(503); +it.live('readiness starts false, follows successful/failing cycles, expires, and closes on shutdown', () => { + let now = 1000; + return Effect.gen(function* healthLifecycle() { + const services = yield* Layer.build(FetchHttpClient.layer); + const client = yield* Effect.provide(HttpClient.HttpClient, services); + const health = yield* createOutboxWorkerHealth({ + now: Effect.sync(() => now), + staleAfterMs: 100, }); - }, -); + const server = yield* serveOutboxWorkerHealth(health, { port: 0 }); + const ready = client.get(`http://127.0.0.1:${server.port}/ready`); + const startingResponse = yield* ready; + expect(startingResponse.status).toBe(503); + expect(yield* startingResponse.json).toEqual({ ready: false }); + expect((yield* client.get(`http://127.0.0.1:${server.port}/unknown`)).status).toBe(404); + yield* health.cycleSucceeded; + const readyResponse = yield* ready; + expect(readyResponse.status).toBe(200); + expect(yield* readyResponse.json).toEqual({ ready: true }); + now = 1101; + expect((yield* ready).status).toBe(503); + yield* health.cycleSucceeded; + yield* health.cycleFailed; + expect((yield* ready).status).toBe(503); + yield* health.cycleSucceeded; + yield* health.shuttingDown; + expect((yield* ready).status).toBe(503); + }); +}); -it.live( - 'closing the health scope marks it unavailable and releases its dynamically allocated port', - () => - Effect.gen(function* releasedPort() { - const health = yield* createOutboxWorkerHealth({ staleAfterMs: 5000 }); - yield* health.cycleSucceeded; - const server = yield* Effect.scoped(serveOutboxWorkerHealth(health, { port: 0 })); - expect(yield* health.isReady).toBe(false); - const rebound = yield* Effect.scoped(serveOutboxWorkerHealth(health, { port: server.port })); - expect(rebound.port).toBe(server.port); - }), +it.live('closing the health scope marks it unavailable and releases its dynamically allocated port', () => + Effect.gen(function* releasedPort() { + const health = yield* createOutboxWorkerHealth({ staleAfterMs: 5000 }); + yield* health.cycleSucceeded; + const server = yield* Effect.scoped(serveOutboxWorkerHealth(health, { port: 0 })); + expect(yield* health.isReady).toBe(false); + const rebound = yield* Effect.scoped(serveOutboxWorkerHealth(health, { port: server.port })); + expect(rebound.port).toBe(server.port); + }), ); it.live('a health port already in use produces a typed server startup failure', () => Effect.gen(function* occupiedPort() { const health = yield* createOutboxWorkerHealth({ staleAfterMs: 5000 }); const server = yield* serveOutboxWorkerHealth(health, { port: 0 }); - const failure = yield* Effect.flip( - Effect.scoped(serveOutboxWorkerHealth(health, { port: server.port })), - ); + const failure = yield* Effect.flip(Effect.scoped(serveOutboxWorkerHealth(health, { port: server.port }))); expect(Predicate.isTagged(failure, 'ServeError')).toBe(true); }), ); -it.effect( - 'invalid configured health ports fail startup with a typed configuration error before polling', - () => - Effect.gen(function* invalidPortConfiguration() { - for (const port of ['0', '65536', '4102.5', 'invalid']) { - const failure = yield* Effect.flip( - runOutboxWorkerProcess({ - claimOwnerPrefix: 'health-config-test', - health: true, - registrations: [], - subscriptions: [], - }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromUnknown({ OUTBOX_WORKER_HEALTH_PORT: port }), - ), - Effect.provideService(OutboxRuntime, { - matchMessages: () => Effect.die('Invalid configuration must prevent matching'), - runCycle: () => Effect.die('Invalid configuration must prevent polling'), - }), +it.effect('invalid configured health ports fail startup with a typed configuration error before polling', () => + Effect.gen(function* invalidPortConfiguration() { + for (const port of ['0', '65536', '4102.5', 'invalid']) { + const failure = yield* Effect.flip( + runOutboxWorkerProcess({ + claimOwnerPrefix: 'health-config-test', + health: true, + registrations: [], + subscriptions: [], + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ OUTBOX_WORKER_HEALTH_PORT: port }), ), - ); - expect(Predicate.isTagged(failure, 'ConfigError')).toBe(true); - } - }), + Effect.provideService(OutboxRuntime, { + matchMessages: () => Effect.die('Invalid configuration must prevent matching'), + runCycle: () => Effect.die('Invalid configuration must prevent polling'), + }), + ), + ); + expect(Predicate.isTagged(failure, 'ConfigError')).toBe(true); + } + }), ); diff --git a/app/packages/core-runtime/tests/unit/outbox-poller.test.ts b/app/packages/core-runtime/tests/unit/outbox-poller.test.ts index 6b70235a8..80f94257f 100644 --- a/app/packages/core-runtime/tests/unit/outbox-poller.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-poller.test.ts @@ -1,8 +1,9 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Fiber, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; -import { defineOutboxWorker } from '../../src/outbox/definition.ts'; + import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; +import { defineOutboxWorker } from '../../src/outbox/definition.ts'; import { OutboxPersistenceError, OutboxPollerConfigError } from '../../src/outbox/errors.ts'; import { parseOutboxPollingConfig, runOutboxPollingLoop } from '../../src/outbox/poller.ts'; import type { OutboxCycleRunner } from '../../src/outbox/poller.ts'; @@ -104,7 +105,11 @@ it.effect('runs immediately, survives a typed cycle failure, and continues polli }); const running = yield* runOutboxPollingLoop( { - config: { claimOwner: 'consumer:test', maxDeliveries: 10, pollIntervalMs: 10 }, + config: { + claimOwner: 'consumer:test', + maxDeliveries: 10, + pollIntervalMs: 10, + }, health: { cycleFailed: Effect.sync(() => healthTransitions.push('failed')), cycleSucceeded: Effect.sync(() => healthTransitions.push('ready')), diff --git a/app/packages/core-runtime/tests/unit/outbox-process.test.ts b/app/packages/core-runtime/tests/unit/outbox-process.test.ts index d9e77ddc4..11416fe50 100644 --- a/app/packages/core-runtime/tests/unit/outbox-process.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-process.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from '@effect/platform-node'; -import { expect, it } from 'effect-rstest'; import { Deferred, Effect, Fiber, Layer, Stream } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess } from 'effect/unstable/process'; const gracefulShutdown = (signal: 'SIGINT' | 'SIGTERM') => @@ -31,11 +31,7 @@ const gracefulShutdown = (signal: 'SIGINT' | 'SIGTERM') => Stream.runCollect, Effect.forkChild, ); - const errorsFiber = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.mkString, - Effect.forkChild, - ); + const errorsFiber = yield* child.stderr.pipe(Stream.decodeText(), Stream.mkString, Effect.forkChild); yield* Deferred.await(readyToStop); yield* child.kill({ killSignal: signal }); diff --git a/app/packages/core-runtime/tests/unit/outbox-runtime.test.ts b/app/packages/core-runtime/tests/unit/outbox-runtime.test.ts index 542d12af3..104632e20 100644 --- a/app/packages/core-runtime/tests/unit/outbox-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/outbox-runtime.test.ts @@ -1,14 +1,11 @@ -import { expect, it } from 'effect-rstest'; import { Context, Effect, Option, Schema } from 'effect'; -import { defineOutboxWorker } from '../../src/outbox/definition.ts'; +import { expect, it } from 'effect-rstest'; + import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; +import { defineOutboxWorker } from '../../src/outbox/definition.ts'; import type { OutboxWorkerHandler, OutboxWorkerRegistration } from '../../src/outbox/definition.ts'; import { OutboxClaimLostError, OutboxWorkerDescriptorError } from '../../src/outbox/errors.ts'; -import type { - OutboxClaim, - OutboxFailureStatus, - OutboxRepositoryService, -} from '../../src/outbox/repository.ts'; +import type { OutboxClaim, OutboxFailureStatus, OutboxRepositoryService } from '../../src/outbox/repository.ts'; import { makeOutboxRuntime } from '../../src/outbox/runtime.ts'; const TestHandlerFailureContract = Schema.TaggedStruct('TestHandlerFailure', { @@ -52,11 +49,7 @@ const claim = (attemptNumber = 1, payloadJson?: OutboxClaim['payloadJson']): Out }); const worker = ( - handler: OutboxWorkerHandler< - { readonly messageKey: typeof MessageKey.Type }, - HandlerError, - HandlerRequirements - >, + handler: OutboxWorkerHandler<{ readonly messageKey: typeof MessageKey.Type }, HandlerError, HandlerRequirements>, ) => defineOutboxWorker( { @@ -93,7 +86,10 @@ const repository = ( readonly claims?: readonly OutboxClaim[]; readonly completeError?: OutboxClaimLostError; readonly failureStatuses?: readonly OutboxFailureStatus[]; - readonly match?: { readonly deliveriesCreated: number; readonly messagesMatched: number }; + readonly match?: { + readonly deliveriesCreated: number; + readonly messagesMatched: number; + }; } = {}, ): ControlledRepository => { const claims = [...(options.claims ?? [])]; @@ -114,28 +110,19 @@ const repository = ( probe.failed.push({ claim: claimed, message }); return failureStatuses.shift() ?? 'pending'; }), - matchUnmatched: () => - Effect.succeed(options.match ?? { deliveriesCreated: 0, messagesMatched: 0 }), + matchUnmatched: () => Effect.succeed(options.match ?? { deliveriesCreated: 0, messagesMatched: 0 }), }, }; }; -type NoRequirementsWorker = OutboxWorkerRegistration< - Schema.ConstraintDecoder, - string, - string, - unknown ->; +type NoRequirementsWorker = OutboxWorkerRegistration, string, string, unknown>; interface WorkerInvocation { readonly context: Parameters>[1]; readonly payload: { readonly messageKey: string }; } -const run = ( - service: OutboxRepositoryService, - registration: NoRequirementsWorker = worker(() => Effect.void), -) => +const run = (service: OutboxRepositoryService, registration: NoRequirementsWorker = worker(() => Effect.void)) => makeOutboxRuntime(service).runCycle({ claimOwner: 'unit-runtime', registrations: [registration], @@ -144,7 +131,9 @@ const run = ( it.effect('owner-local cycles do not perform global matching', () => Effect.gen(function* ownerLocalCycle() { - const controlled = repository({ match: { deliveriesCreated: 0, messagesMatched: 2 } }); + const controlled = repository({ + match: { deliveriesCreated: 0, messagesMatched: 2 }, + }); expect(yield* run(controlled.service)).toEqual({ claimed: 0, @@ -162,7 +151,9 @@ it.effect('owner-local cycles do not perform global matching', () => it.effect('matches messages only through the explicit Core matcher snapshot', () => Effect.gen(function* explicitMatcherSnapshot() { - const controlled = repository({ match: { deliveriesCreated: 3, messagesMatched: 2 } }); + const controlled = repository({ + match: { deliveriesCreated: 3, messagesMatched: 2 }, + }); const registration = worker(() => Effect.void); const result = yield* makeOutboxRuntime(controlled.service).matchMessages({ subscriptions: [registration.descriptor], @@ -277,9 +268,7 @@ it.effect('runs a worker with Effect services provided by its owning MicroVertic workerKey: 'consumer.layered-logger', }, (_payload, context) => - TestWorkerDependency.pipe( - Effect.flatMap(({ record }) => Effect.sync(() => record(context.messageId))), - ), + TestWorkerDependency.pipe(Effect.flatMap(({ record }) => Effect.sync(() => record(context.messageId)))), ); const result = yield* makeOutboxRuntime(controlled.service) @@ -316,25 +305,27 @@ it.effect('records decode failures as retries without calling the handler or com expect(result.failed).toBe(1); expect(result.retried).toBe(1); expect(controlled.probe.completed).toEqual([]); - expect(controlled.probe.failed[0]?.message).toBe( - 'The Outbox Message payload does not match its published schema', - ); + expect(controlled.probe.failed[0]?.message).toBe('The Outbox Message payload does not match its published schema'); }), ); it.effect('classifies declared failures, defects, retry exhaustion, and never completes them', () => Effect.gen(function* failureClassification() { - const declared = repository({ claims: [claim()], failureStatuses: ['pending'] }); + const declared = repository({ + claims: [claim()], + failureStatuses: ['pending'], + }); const declaredResult = yield* run( declared.service, worker(() => Effect.fail(new TestHandlerFailure({ reason: 'secret typed detail' }))), ); expect(declaredResult.retried).toBe(1); - expect(declared.probe.failed[0]?.message).toBe( - 'The Outbox Worker handler returned a declared failure', - ); + expect(declared.probe.failed[0]?.message).toBe('The Outbox Worker handler returned a declared failure'); - const defect = repository({ claims: [claim(2)], failureStatuses: ['dead'] }); + const defect = repository({ + claims: [claim(2)], + failureStatuses: ['dead'], + }); const defectResult = yield* run( defect.service, worker(() => Effect.die(new Error('database password must not be stored'))), @@ -347,20 +338,18 @@ it.effect('classifies declared failures, defects, retry exhaustion, and never co }), ); -it.effect( - 'surfaces stale-claim finalization and leaves checkpoint responsibility with the repository', - () => - Effect.gen(function* staleClaimFinalization() { - const controlled = repository({ - claims: [claim()], - completeError: new OutboxClaimLostError({ - code: 'outbox_claim_lost', - reason: 'stale test claim', - }), - }); +it.effect('surfaces stale-claim finalization and leaves checkpoint responsibility with the repository', () => + Effect.gen(function* staleClaimFinalization() { + const controlled = repository({ + claims: [claim()], + completeError: new OutboxClaimLostError({ + code: 'outbox_claim_lost', + reason: 'stale test claim', + }), + }); - const error = yield* Effect.flip(run(controlled.service)); - expect(Schema.is(OutboxClaimLostError)(error)).toBe(true); - expect(controlled.probe.failed).toEqual([]); - }), + const error = yield* Effect.flip(run(controlled.service)); + expect(Schema.is(OutboxClaimLostError)(error)).toBe(true); + expect(controlled.probe.failed).toEqual([]); + }), ); diff --git a/app/packages/core-runtime/tests/unit/permission-client.test.ts b/app/packages/core-runtime/tests/unit/permission-client.test.ts index 8bdd41864..110456392 100644 --- a/app/packages/core-runtime/tests/unit/permission-client.test.ts +++ b/app/packages/core-runtime/tests/unit/permission-client.test.ts @@ -1,7 +1,8 @@ -import { expect, it, rstest } from 'effect-rstest'; import { v1 } from '@authzed/authzed-node'; import { Cause, Effect, Fiber, Predicate, Schema } from 'effect'; +import { expect, it, rstest } from 'effect-rstest'; import { TestClock } from 'effect/testing'; + import { SpiceDbPermissionClientError, createSpiceDbPermissionClient, @@ -66,21 +67,17 @@ it.effect('SDK rejections become typed permission failures without leaking diagn details: 'private transport diagnostic', metadata: rstest.fn<() => PermissionRpcError['metadata']>()(), }); - rstest - .spyOn(v1.PermissionsServiceClient.prototype, 'checkPermission') - .mockImplementation((request, metadata) => { - if (Predicate.isFunction(metadata)) { - metadata(cause); - } - return rstest.fn()(request, metadata); - }); + rstest.spyOn(v1.PermissionsServiceClient.prototype, 'checkPermission').mockImplementation((request, metadata) => { + if (Predicate.isFunction(metadata)) { + metadata(cause); + } + return rstest.fn()(request, metadata); + }); const client = yield* Effect.acquireRelease( Effect.sync(() => createSpiceDbPermissionClient(configuration, SPICEDB_CHECK_TIMEOUT_MS)), (acquiredClient) => Effect.sync(() => acquiredClient.close()), ); - const failure = yield* Effect.flip( - client.checkPermission(v1.CheckPermissionRequest.create({})), - ); + const failure = yield* Effect.flip(client.checkPermission(v1.CheckPermissionRequest.create({}))); expect(Schema.is(SpiceDbPermissionClientError)(failure)).toBe(true); expect(failure.reason.includes(cause.message)).toBe(false); expect(Object.getOwnPropertyDescriptor(failure, 'cause')?.value).toBe(cause); @@ -99,14 +96,12 @@ it.effect('an SDK call that never replies is bounded by the permission deadline' Effect.sync(() => createSpiceDbPermissionClient(configuration, SPICEDB_CHECK_TIMEOUT_MS)), (acquiredClient) => Effect.sync(() => acquiredClient.close()), ); - const fiber = yield* Effect.flip( - client.checkPermission(v1.CheckPermissionRequest.create({})), - ).pipe(Effect.forkChild); + const fiber = yield* Effect.flip(client.checkPermission(v1.CheckPermissionRequest.create({}))).pipe( + Effect.forkChild, + ); yield* TestClock.adjust(SPICEDB_CHECK_TIMEOUT_MS); const failure = yield* Fiber.join(fiber); expect(Schema.is(SpiceDbPermissionClientError)(failure)).toBe(true); - expect(Cause.isTimeoutError(Object.getOwnPropertyDescriptor(failure, 'cause')?.value)).toBe( - true, - ); + expect(Cause.isTimeoutError(Object.getOwnPropertyDescriptor(failure, 'cause')?.value)).toBe(true); }), ); diff --git a/app/packages/core-runtime/tests/unit/pool-configuration.test.ts b/app/packages/core-runtime/tests/unit/pool-configuration.test.ts index ce4281ab5..f32f777df 100644 --- a/app/packages/core-runtime/tests/unit/pool-configuration.test.ts +++ b/app/packages/core-runtime/tests/unit/pool-configuration.test.ts @@ -1,9 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Redacted, Predicate } from 'effect'; -import { - DEFAULT_DATABASE_POOL_DEADLINES, - configureDatabasePool, -} from '../../src/db/pool-configuration.ts'; +import { expect, it } from 'effect-rstest'; + +import { DEFAULT_DATABASE_POOL_DEADLINES, configureDatabasePool } from '../../src/db/pool-configuration.ts'; const runtimeUrl = 'postgresql://runtime:secret@localhost:5432/ontos'; @@ -12,9 +10,7 @@ it.effect('uses acquisition and statement deadlines without opting into a lock d const connectionString = Redacted.make(`${runtimeUrl}?sslmode=require`); const configuration = yield* configureDatabasePool(connectionString); - expect(configuration.connectionTimeoutMillis).toBe( - DEFAULT_DATABASE_POOL_DEADLINES.connectionTimeoutMillis, - ); + expect(configuration.connectionTimeoutMillis).toBe(DEFAULT_DATABASE_POOL_DEADLINES.connectionTimeoutMillis); expect(configuration.statement_timeout).toBe(DEFAULT_DATABASE_POOL_DEADLINES.statement_timeout); expect(Object.hasOwn(configuration, 'lock_timeout')).toBe(false); expect(configuration.connectionString).toBe(`${runtimeUrl}?sslmode=require`); @@ -58,13 +54,9 @@ it.effect('rejects URL deadline overrides with a typed configuration failure', ( it.effect('rejects invalid deadline values with a typed configuration failure', () => Effect.gen(function* verifyInvalidDeadline() { const connectionString = Redacted.make(runtimeUrl); - const error = yield* Effect.flip( - configureDatabasePool(connectionString, { statement_timeout: 0 }), - ); + const error = yield* Effect.flip(configureDatabasePool(connectionString, { statement_timeout: 0 })); expect(Predicate.isTagged(error, 'DatabaseConnectionError')).toBe(true); - expect(error.reason).toBe( - 'Database pool deadlines must be positive 32-bit millisecond integers', - ); + expect(error.reason).toBe('Database pool deadlines must be positive 32-bit millisecond integers'); }), ); diff --git a/app/packages/core-runtime/tests/unit/principal-management.test.ts b/app/packages/core-runtime/tests/unit/principal-management.test.ts index a043aa5d4..a89ef3aff 100644 --- a/app/packages/core-runtime/tests/unit/principal-management.test.ts +++ b/app/packages/core-runtime/tests/unit/principal-management.test.ts @@ -1,6 +1,6 @@ +import { Effect, Option, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Option, Predicate } from 'effect'; import type { PrincipalManagementPersistence, PrincipalManagementRepositoryService, @@ -18,8 +18,7 @@ const tenantId = '10000000-0000-4000-8000-000000000001'; const principalId = '20000000-0000-4000-8000-000000000001'; const authBindingId = '30000000-0000-4000-8000-000000000001'; -const unconfigured = (operation: string) => - Effect.die(`${operation} is not configured in this test`); +const unconfigured = (operation: string) => Effect.die(`${operation} is not configured in this test`); const repositoryDefaults: PrincipalManagementPersistence = { createPrincipal: () => unconfigured('createPrincipal'), insertApiKeyBinding: () => unconfigured('insertApiKeyBinding'), @@ -29,24 +28,23 @@ const repositoryDefaults: PrincipalManagementPersistence = { updateApiKeyBindingStatus: () => unconfigured('updateApiKeyBindingStatus'), updatePrincipalStatus: () => unconfigured('updatePrincipalStatus'), }; -const repository = ( - overrides: Partial, -): PrincipalManagementRepositoryService => - principalManagementRepositoryFromPersistence({ ...repositoryDefaults, ...overrides }); +const repository = (overrides: Partial): PrincipalManagementRepositoryService => + principalManagementRepositoryFromPersistence({ + ...repositoryDefaults, + ...overrides, + }); type OptionValue = Outcome extends Option.Option ? Value : never; -type PrincipalRecord = OptionValue< - Effect.Success> ->; -type ApiKeyBindingRecord = OptionValue< - Effect.Success> ->; +type PrincipalRecord = OptionValue>>; +type ApiKeyBindingRecord = OptionValue>>; const selectingPrincipal = (record: PrincipalRecord | undefined) => - repository({ loadPrincipal: () => Effect.succeed(Option.fromNullishOr(record)) }); + repository({ + loadPrincipal: () => Effect.succeed(Option.fromNullishOr(record)), + }); const selectingBinding = (record: ApiKeyBindingRecord | undefined) => - repository({ loadApiKeyBinding: () => Effect.succeed(Option.fromNullishOr(record)) }); -const repositoryForSupportParticipants = ( - results: readonly (readonly { readonly authBindingId: string }[])[], -) => { + repository({ + loadApiKeyBinding: () => Effect.succeed(Option.fromNullishOr(record)), + }); +const repositoryForSupportParticipants = (results: readonly (readonly { readonly authBindingId: string }[])[]) => { let call = 0; return repository({ loadSupportBindings: () => @@ -61,7 +59,10 @@ const provideRepository = (service: PrincipalManagementRepositoryService) => Effect.provideService(PrincipalManagementRepository, service); it.effect('rejects human principal administration and managed keys targeting humans', () => Effect.gen(function* rejectsHumanPrincipalAdministration() { - const transaction = selectingPrincipal({ kind: 'human', status: 'active' }); + const transaction = selectingPrincipal({ + kind: 'human', + status: 'active', + }); const principalError = yield* Effect.flip( changePrincipalStatus({ expectedStatus: 'active', @@ -151,11 +152,20 @@ it.effect('enforces expected state, terminal revocation, and revocation reasons' it.effect('rejects managed binding transitions for human or inactive targets', () => Effect.gen(function* rejectsIneligibleBindingTargets() { const records = [ - { bindingStatus: 'active', principalKind: 'human', principalStatus: 'active' }, - { bindingStatus: 'active', principalKind: 'service', principalStatus: 'disabled' }, + { + bindingStatus: 'active', + principalKind: 'human', + principalStatus: 'active', + }, + { + bindingStatus: 'active', + principalKind: 'service', + principalStatus: 'disabled', + }, ] satisfies readonly ApiKeyBindingRecord[]; - yield* Effect.all( - records.map((record) => + yield* Effect.forEach( + records, + (record) => Effect.gen(function* rejectsIneligibleBindingTarget() { const error = yield* Effect.flip( setApiKeyBindingStatus({ @@ -169,44 +179,40 @@ it.effect('rejects managed binding transitions for human or inactive targets', ( ); expect(Predicate.isTagged(error, 'IdentityTargetInvalidError')).toBe(true); }), - ), + { concurrency: 1 }, ); }), ); -it.effect( - 'binds only eligible active self and managed principal kinds without secret material', - () => - Effect.gen(function* bindsEligiblePrincipal() { - let inserted: - | Parameters[0] - | undefined; - const transaction = repository({ - insertApiKeyBinding: (value) => - Effect.sync(() => { - inserted = value; - return Option.some({ authBindingId }); - }), - loadPrincipal: () => Effect.succeed(Option.some({ kind: 'service', status: 'active' })), - }); - const result = yield* bindApiKey({ - managed: true, - principalId, - providerSubjectId: 'provider-key-id', - tenantId, - }).pipe(provideRepository(transaction)); +it.effect('binds only eligible active self and managed principal kinds without secret material', () => + Effect.gen(function* bindsEligiblePrincipal() { + let inserted: Parameters[0] | undefined; + const transaction = repository({ + insertApiKeyBinding: (value) => + Effect.sync(() => { + inserted = value; + return Option.some({ authBindingId }); + }), + loadPrincipal: () => Effect.succeedSome({ kind: 'service', status: 'active' }), + }); + const result = yield* bindApiKey({ + managed: true, + principalId, + providerSubjectId: 'provider-key-id', + tenantId, + }).pipe(provideRepository(transaction)); - expect(result).toEqual({ authBindingId, status: 'active' }); - expect(inserted?.providerSubjectId).toBe('provider-key-id'); - expect('key' in (inserted ?? {})).toBe(false); - expect('secret' in (inserted ?? {})).toBe(false); - expect('hash' in (inserted ?? {})).toBe(false); - }), + expect(result).toEqual({ authBindingId, status: 'active' }); + expect(inserted?.providerSubjectId).toBe('provider-key-id'); + expect('key' in (inserted ?? {})).toBe(false); + expect('secret' in (inserted ?? {})).toBe(false); + expect('hash' in (inserted ?? {})).toBe(false); + }), ); it.effect('maps an existing API key binding to a lifecycle conflict', () => Effect.gen(function* mapsExistingBindingToConflict() { const transaction = repository({ - insertApiKeyBinding: () => Effect.succeed(Option.none()), - loadPrincipal: () => Effect.succeed(Option.some({ kind: 'service', status: 'active' })), + insertApiKeyBinding: () => Effect.succeedNone, + loadPrincipal: () => Effect.succeedSome({ kind: 'service', status: 'active' }), }); const error = yield* Effect.flip( @@ -221,34 +227,30 @@ it.effect('maps an existing API key binding to a lifecycle conflict', () => expect(Predicate.isTagged(error, 'IdentityLifecycleConflictError')).toBe(true); }), ); -it.effect( - 'requires exactly one active tenant-local user binding for both impersonation participants', - () => - Effect.gen(function* validatesSupportParticipants() { - const original = [{ authBindingId }]; - const target = [{ authBindingId: '30000000-0000-4000-8000-000000000002' }]; - const input: Parameters[0] = { - checkpoint: 'requested', - originalAuthBindingId: authBindingId, - originalPrincipalId: principalId, - targetPrincipalId: '20000000-0000-4000-8000-000000000002', - tenantId, - }; +it.effect('requires exactly one active tenant-local user binding for both impersonation participants', () => + Effect.gen(function* validatesSupportParticipants() { + const original = [{ authBindingId }]; + const target = [{ authBindingId: '30000000-0000-4000-8000-000000000002' }]; + const input: Parameters[0] = { + checkpoint: 'requested', + originalAuthBindingId: authBindingId, + originalPrincipalId: principalId, + targetPrincipalId: '20000000-0000-4000-8000-000000000002', + tenantId, + }; - yield* validateSupportImpersonation(input).pipe( - provideRepository(repositoryForSupportParticipants([original, target])), - ); - const error = yield* Effect.flip( - validateSupportImpersonation(input).pipe( - provideRepository(repositoryForSupportParticipants([original, []])), - ), - ); + yield* validateSupportImpersonation(input).pipe( + provideRepository(repositoryForSupportParticipants([original, target])), + ); + const error = yield* Effect.flip( + validateSupportImpersonation(input).pipe(provideRepository(repositoryForSupportParticipants([original, []]))), + ); - expect(Predicate.isTagged(error, 'IdentityTargetInvalidError')).toBe(true); + expect(Predicate.isTagged(error, 'IdentityTargetInvalidError')).toBe(true); - yield* validateSupportImpersonation({ - ...input, - checkpoint: 'stopped', - }).pipe(provideRepository(repositoryForSupportParticipants([original, target]))); - }), + yield* validateSupportImpersonation({ + ...input, + checkpoint: 'stopped', + }).pipe(provideRepository(repositoryForSupportParticipants([original, target]))); + }), ); diff --git a/app/packages/core-runtime/tests/unit/principal-resolver.test.ts b/app/packages/core-runtime/tests/unit/principal-resolver.test.ts index 5e77e0780..8db0e5daa 100644 --- a/app/packages/core-runtime/tests/unit/principal-resolver.test.ts +++ b/app/packages/core-runtime/tests/unit/principal-resolver.test.ts @@ -1,6 +1,5 @@ -import { expect, it } from 'effect-rstest'; - import { DateTime, Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; import type { PrincipalResolutionRecord } from '../../src/auth/principal-resolver.ts'; @@ -77,9 +76,7 @@ it.effect('lists safe eligible tenants by name and tenant ID', () => ); it.effect('lists and resolves one active tenant binding', () => Effect.gen(function* listsAndResolvesActiveTenant() { - expect(yield* classifyAvailableTenants([activeRecord])).toEqual([ - { name: 'Zeta tenant', tenantId: 'tenant-1' }, - ]); + expect(yield* classifyAvailableTenants([activeRecord])).toEqual([{ name: 'Zeta tenant', tenantId: 'tenant-1' }]); expect(yield* classifyDefaultPrincipal([activeRecord])).toEqual({ authBindingId: 'binding-1', displayName: 'Ada Lovelace', @@ -152,10 +149,7 @@ it.effect('rejects Better Auth user bindings to non-human principals', () => Effect.gen(function* rejectsNonHumanPrincipal() { const record = { ...activeRecord, principalKind }; expect( - Predicate.isTagged( - yield* Effect.flip(classifyDefaultPrincipal([record])), - 'PrincipalInactiveError', - ), + Predicate.isTagged(yield* Effect.flip(classifyDefaultPrincipal([record])), 'PrincipalInactiveError'), ).toBe(true); expect( Predicate.isTagged( @@ -164,51 +158,39 @@ it.effect('rejects Better Auth user bindings to non-human principals', () => ), ).toBe(true); expect( - Predicate.isTagged( - yield* Effect.flip(classifyAvailableTenants([record])), - 'PrincipalInactiveError', - ), + Predicate.isTagged(yield* Effect.flip(classifyAvailableTenants([record])), 'PrincipalInactiveError'), ).toBe(true); }), ), ), ); -it.effect( - 'resolves exactly one API-key subject for human, service, or integration principals', - () => - Effect.gen(function* resolvesApiKeySubject() { - yield* Effect.all( - (['human', 'service', 'integration'] as const).map((principalKind) => - Effect.gen(function* resolvesPrincipalKind() { - const resolved = yield* classifyApiKeyPrincipal([{ ...activeRecord, principalKind }]); - expect(resolved.principalKind).toBe(principalKind); - expect(resolved.authBindingId).toBe(activeRecord.authBindingId); - }), - ), - ); - expect( - Predicate.isTagged( - yield* Effect.flip( - classifyApiKeyPrincipal([activeRecord, { ...activeRecord, tenantId: 't-2' }]), - ), - 'PrincipalBindingAmbiguousError', - ), - ).toBe(true); - }), -); -it.effect('fails closed for empty, inactive, and duplicate eligible resolver states', () => - Effect.gen(function* rejectsInvalidResolverStates() { +it.effect('resolves exactly one API-key subject for human, service, or integration principals', () => + Effect.gen(function* resolvesApiKeySubject() { + yield* Effect.all( + (['human', 'service', 'integration'] as const).map((principalKind) => + Effect.gen(function* resolvesPrincipalKind() { + const resolved = yield* classifyApiKeyPrincipal([{ ...activeRecord, principalKind }]); + expect(resolved.principalKind).toBe(principalKind); + expect(resolved.authBindingId).toBe(activeRecord.authBindingId); + }), + ), + ); expect( Predicate.isTagged( - yield* Effect.flip(classifyAvailableTenants([])), - 'PrincipalBindingMissingError', + yield* Effect.flip(classifyApiKeyPrincipal([activeRecord, { ...activeRecord, tenantId: 't-2' }])), + 'PrincipalBindingAmbiguousError', ), ).toBe(true); + }), +); +it.effect('fails closed for empty, inactive, and duplicate eligible resolver states', () => + Effect.gen(function* rejectsInvalidResolverStates() { + expect(Predicate.isTagged(yield* Effect.flip(classifyAvailableTenants([])), 'PrincipalBindingMissingError')).toBe( + true, + ); expect( Predicate.isTagged( - yield* Effect.flip( - classifyAvailableTenants([{ ...activeRecord, bindingStatus: 'revoked' }]), - ), + yield* Effect.flip(classifyAvailableTenants([{ ...activeRecord, bindingStatus: 'revoked' }])), 'PrincipalBindingInactiveError', ), ).toBe(true); @@ -227,27 +209,20 @@ it.effect('fails closed for empty, inactive, and duplicate eligible resolver sta ).toBe(true); expect( Predicate.isTagged( - yield* Effect.flip( - classifyAvailableTenants([{ ...activeRecord, principalStatus: 'disabled' }]), - ), + yield* Effect.flip(classifyAvailableTenants([{ ...activeRecord, principalStatus: 'disabled' }])), 'PrincipalInactiveError', ), ).toBe(true); expect( Predicate.isTagged( - yield* Effect.flip( - classifyAvailableTenants([{ ...activeRecord, tenantStatus: 'suspended' }]), - ), + yield* Effect.flip(classifyAvailableTenants([{ ...activeRecord, tenantStatus: 'suspended' }])), 'TenantInactiveError', ), ).toBe(true); expect( Predicate.isTagged( yield* Effect.flip( - classifyAvailableTenants([ - activeRecord, - { ...activeRecord, principalId: 'duplicate-principal' }, - ]), + classifyAvailableTenants([activeRecord, { ...activeRecord, principalId: 'duplicate-principal' }]), ), 'PrincipalBindingAmbiguousError', ), @@ -261,7 +236,9 @@ it.effect('types database failures as resolver unavailability', () => executor: yield* makeTestDatabase(() => Effect.fail( new SqlError({ - reason: new ConnectionError({ cause: new Error('secret database error') }), + reason: new ConnectionError({ + cause: new Error('secret database error'), + }), }), ), ), diff --git a/app/packages/core-runtime/tests/unit/read-definition.test.ts b/app/packages/core-runtime/tests/unit/read-definition.test.ts index e14fca1e1..41502e9d9 100644 --- a/app/packages/core-runtime/tests/unit/read-definition.test.ts +++ b/app/packages/core-runtime/tests/unit/read-definition.test.ts @@ -1,11 +1,11 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Verifies package source files via the Node filesystem boundary; expires: 2026-12-31. -import { expect, it } from 'effect-rstest'; +import { fileURLToPath } from 'node:url'; -import { readFile } from 'node:fs/promises'; +import { NodeFileSystem } from '@effect/platform-node'; +import { Effect, FileSystem, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; -import { Effect, Schema } from 'effect'; -import { defineRead, validateReadDescriptorInput } from '../../src/reads/definition.ts'; import { defineSystemModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; +import { defineRead, validateReadDescriptorInput } from '../../src/reads/definition.ts'; const modulePermissionTarget = () => ({ kind: 'module', moduleId: 'core.shell' }) as const; it('defines immutable read metadata while keeping handler and service factory private', () => { @@ -14,12 +14,18 @@ it('defines immutable read metadata while keeping handler and service factory pr accessKind: 'list', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.shell.list', moduleKey: 'core.shell', role: 'api', }), - evidencePolicy: { captureMode: 'metadata_only', policyKey: 'core.shell.list.evidence.v1' }, + evidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'core.shell.list.evidence.v1', + }, inputSchema: Schema.Struct({}), legalEntityScope: 'forbidden', owningModuleKey: 'core.shell', @@ -42,7 +48,10 @@ it('requires an explicit valid owner-scoped read entrypoint', () => { validateReadDescriptorInput({ entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.foreign.detail', moduleKey: 'core.foreign', role: 'api', @@ -60,7 +69,10 @@ it('supports every governed access kind and rejects forged scope metadata', () = accessKind, entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: `core.shell.${accessKind}`, moduleKey: 'core.shell', role: 'api', @@ -89,7 +101,10 @@ it('supports every governed access kind and rejects forged scope metadata', () = validateReadDescriptorInput({ entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.shell.valid', moduleKey: 'core.shell', role: 'api', @@ -99,16 +114,19 @@ it('supports every governed access kind and rejects forged scope metadata', () = }), ).toThrow(); }); -it.effect('keeps low-level read runtime construction and Core schema out of package exports', () => - Effect.gen(function* migratedTest1() { - const [indexSource, packageSource] = yield* Effect.all( - [ - Effect.promise(() => readFile(new URL('../../src/index.ts', import.meta.url), 'utf-8')), - Effect.promise(() => readFile(new URL('../../package.json', import.meta.url), 'utf-8')), - ], - { concurrency: 'unbounded' }, - ); - expect(indexSource).not.toMatch(/\bmakeReadRuntime,?$/mu); - expect(packageSource).not.toMatch(/"\.\/db\/schema"/u); - }), -); +it.layer(NodeFileSystem.layer)('read package boundary', (suite) => { + suite.effect('keeps low-level read runtime construction and Core schema out of package exports', () => + Effect.gen(function* readPackageBoundary() { + const fs = yield* FileSystem.FileSystem; + const [indexSource, packageSource] = yield* Effect.all( + [ + fs.readFileString(fileURLToPath(new URL('../../src/index.ts', import.meta.url))), + fs.readFileString(fileURLToPath(new URL('../../package.json', import.meta.url))), + ], + { concurrency: 'unbounded' }, + ); + expect(indexSource).not.toMatch(/\bmakeReadRuntime,?$/mu); + expect(packageSource).not.toMatch(/"\.\/db\/schema"/u); + }), + ); +}); diff --git a/app/packages/core-runtime/tests/unit/read-runtime.test.ts b/app/packages/core-runtime/tests/unit/read-runtime.test.ts index 65f538cf4..c9419f37e 100644 --- a/app/packages/core-runtime/tests/unit/read-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/read-runtime.test.ts @@ -1,7 +1,6 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Option, Predicate, Schema } from 'effect'; /* oxlint-disable sonarjs/use-type-alias, typescript/no-unsafe-type-assertion -- Existing compatibility boundary; expires: 2026-12-31. */ import { expect, it } from 'effect-rstest'; - -import { Cause, Deferred, Effect, Exit, Fiber, Option, Predicate, Schema } from 'effect'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; import { defineGlobalPolicy, denyPolicy } from '../../src/actions/policy.ts'; @@ -63,12 +62,12 @@ const makeHarness = Effect.fn(function* makeHarness( if (text.includes('data_access_events')) { if (options.failEvidence === true) { return yield* new SqlError({ - reason: new ConnectionError({ cause: new Error('private persistence detail') }), + reason: new ConnectionError({ + cause: new Error('private persistence detail'), + }), }); } - const queryHash = values - .filter(Predicate.isString) - .find((value) => /^[\da-f]{64}$/u.test(value)); + const queryHash = values.filter(Predicate.isString).find((value) => /^[\da-f]{64}$/u.test(value)); evidenceRows.push(queryHash === undefined ? {} : { queryHash }); evidence += 1; } @@ -91,7 +90,9 @@ const makeHarness = Effect.fn(function* makeHarness( }), ), ); - Object.defineProperty(database.executor, 'transaction', { value: transaction }); + Object.defineProperty(database.executor, 'transaction', { + value: transaction, + }); const stages: string[] = []; const runtime = makeReadRuntime( database, @@ -121,10 +122,7 @@ const makeHarness = Effect.fn(function* makeHarness( } return Effect.succeed( resources.map((resource) => ({ - decision: - options.resultPermissionDecision ?? - options.permissionDecision ?? - ('unavailable' as const), + decision: options.resultPermissionDecision ?? options.permissionDecision ?? ('unavailable' as const), key: `${resource.moduleId}:${resource.resourceType}:${resource.resourceId}`, })), ); @@ -140,9 +138,7 @@ const makeHarness = Effect.fn(function* makeHarness( options.tenantPermissionDecision ?? options.permissionDecision ?? ('unavailable' as const)) - : (options.tenantPermissionDecision ?? - options.permissionDecision ?? - ('unavailable' as const)), + : (options.tenantPermissionDecision ?? options.permissionDecision ?? ('unavailable' as const)), key, })), ); @@ -150,7 +146,12 @@ const makeHarness = Effect.fn(function* makeHarness( }, { onStage: (stage) => stages.push(stage) }, ); - return { evidence: () => evidence, evidenceRows: () => evidenceRows, runtime, stages }; + return { + evidence: () => evidence, + evidenceRows: () => evidenceRows, + runtime, + stages, + }; }); const registration = (items: readonly string[] = []) => defineRead( @@ -158,12 +159,18 @@ const registration = (items: readonly string[] = []) => accessKind: 'list', entrypoint: defineSystemModuleEntrypoint({ access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'core.shell.items', moduleKey: 'core.shell', role: 'api', }), - evidencePolicy: { captureMode: 'metadata_only', policyKey: 'core.shell.items.v1' }, + evidencePolicy: { + captureMode: 'metadata_only', + policyKey: 'core.shell.items.v1', + }, inputSchema: Schema.Struct({}), legalEntityScope: 'forbidden', owningModuleKey: 'core.shell', @@ -181,29 +188,33 @@ const registration = (items: readonly string[] = []) => () => Effect.succeed({ items }), () => ({ kind: 'module', moduleId: 'core.shell' }), ); -it.effect( - 'runs every gate before the handler and persists evidence before releasing zero results', - () => - Effect.gen(function* migratedTest1() { - const harness = yield* makeHarness(); - const result = yield* harness.runtime.runRead({ - input: {}, - principal: scope, - registration: registration(), - transport: { correlationId: scope.correlationId }, - }); - expect(result).toEqual([]); - expect(harness.evidence()).toBe(1); - expect(harness.stages).toEqual(READ_RUNTIME_STAGES); - }), +it.effect('runs every gate before the handler and persists evidence before releasing zero results', () => + Effect.gen(function* migratedTest1() { + const harness = yield* makeHarness(); + const result = yield* harness.runtime.runRead({ + input: {}, + principal: scope, + registration: registration(), + transport: { correlationId: scope.correlationId }, + }); + expect(result).toEqual([]); + expect(harness.evidence()).toBe(1); + expect(harness.stages).toEqual(READ_RUNTIME_STAGES); + }), ); it.effect('validates decoded transformed results and preserves their nullable JSON encoding', () => Effect.gen(function* migratedTest2() { const harness = yield* makeHarness(); - const ResultSchema = Schema.Struct({ value: Schema.OptionFromNullOr(Schema.String) }); + const ResultSchema = Schema.Struct({ + value: Schema.OptionFromNullOr(Schema.String), + }); const transformedRegistration = defineRead( { ...registration().descriptor, resultSchema: ResultSchema }, - () => Effect.succeed({ evidence: { resultCount: 1 }, result: { value: Option.none() } }), + () => + Effect.succeed({ + evidence: { resultCount: 1 }, + result: { value: Option.none() }, + }), () => Effect.succeed({}), () => ({ kind: 'module', moduleId: 'core.shell' }), ); @@ -225,8 +236,7 @@ it.effect('uses each denying Policy reference own declared HTTP status', () => Effect.gen(function* migratedTest4() { const harness = yield* makeHarness(); const policy = defineGlobalPolicy>>({ - evaluate: () => - Effect.fail(denyPolicy(`policy-${denialStatus}`, 'Denied by test Policy')), + evaluate: () => Effect.fail(denyPolicy(`policy-${denialStatus}`, 'Denied by test Policy')), policyKey: `global.read-policy-${denialStatus}.v1`, }); const governed = defineRead( @@ -249,60 +259,60 @@ it.effect('uses each denying Policy reference own declared HTTP status', () => }), ); expect(Predicate.isTagged(error, 'ReadPolicyDenied')).toBe(true); - expect((yield* Schema.decodeUnknownEffect(ReadPolicyDenied)(error)).httpStatus).toBe( - denialStatus, - ); + expect( + (yield* Schema.decodeEffect(ReadPolicyDenied)( + yield* Effect.filterOrFail(Effect.succeed(error), Schema.is(ReadPolicyDenied)), + )).httpStatus, + ).toBe(denialStatus); }), ), { concurrency: 'unbounded' }, ), ); -it.effect( - 'executes every governed access kind and computes hash-only query evidence inside Core', - () => - Effect.all( - (['detail', 'download', 'export', 'list', 'report', 'search'] as const).map((accessKind) => - Effect.gen(function* migratedTest6() { - const legalEntityId = '00000000-0000-4000-8000-000000000004'; - const harness = yield* makeHarness({ - permissionDecision: 'allowed', - resolvedScope: { ...scope, legalEntityId }, - }); - const governed = defineRead( - { - ...registration().descriptor, - accessKind, - evidencePolicy: { - captureMode: 'hash_only', - policyKey: `core.shell.${accessKind}.hash.v1`, - }, - legalEntityScope: 'required', +it.effect('executes every governed access kind and computes hash-only query evidence inside Core', () => + Effect.all( + (['detail', 'download', 'export', 'list', 'report', 'search'] as const).map((accessKind) => + Effect.gen(function* migratedTest6() { + const legalEntityId = '00000000-0000-4000-8000-000000000004'; + const harness = yield* makeHarness({ + permissionDecision: 'allowed', + resolvedScope: { ...scope, legalEntityId }, + }); + const governed = defineRead( + { + ...registration().descriptor, + accessKind, + evidencePolicy: { + captureMode: 'hash_only', + policyKey: `core.shell.${accessKind}.hash.v1`, }, - () => Effect.succeed({ evidence: { resultCount: 0 }, result: [] }), - () => Effect.succeed({}), - () => ({ kind: 'module', moduleId: 'core.shell' }), - accessKind === 'search' ? () => [] : undefined, - ); - expect( - yield* harness.runtime.runRead({ - input: {}, - principal: { - authBindingId: '00000000-0000-4000-8000-000000000005', - authContextRef: 'better-auth-session:read-runtime', - authMethod: 'session', - legalEntityId, - principalId: scope.principalId, - tenantId: scope.tenantId, - }, - registration: governed, - transport: { correlationId: scope.correlationId }, - }), - ).toEqual([]); - expect(String(harness.evidenceRows()[0]?.queryHash)).toMatch(/^[\da-f]{64}$/u); - }), - ), - { concurrency: 'unbounded' }, + legalEntityScope: 'required', + }, + () => Effect.succeed({ evidence: { resultCount: 0 }, result: [] }), + () => Effect.succeed({}), + () => ({ kind: 'module', moduleId: 'core.shell' }), + accessKind === 'search' ? () => [] : undefined, + ); + expect( + yield* harness.runtime.runRead({ + input: {}, + principal: { + authBindingId: '00000000-0000-4000-8000-000000000005', + authContextRef: 'better-auth-session:read-runtime', + authMethod: 'session', + legalEntityId, + principalId: scope.principalId, + tenantId: scope.tenantId, + }, + registration: governed, + transport: { correlationId: scope.correlationId }, + }), + ).toEqual([]); + expect(String(harness.evidenceRows()[0]?.queryHash)).toMatch(/^[\da-f]{64}$/u); + }), ), + { concurrency: 'unbounded' }, + ), ); it.effect('rejects invalid input before opening a transaction or executing a handler', () => Effect.gen(function* migratedTest7() { @@ -398,12 +408,13 @@ it.effect('preserves scoped service-factory unavailability and never invokes the expect(harness.evidence()).toBe(0); }), ); -const counterpartyReadRegistration = ( - legalEntityScope: 'required' | 'optional', - onHandler: () => void, -) => +const counterpartyReadRegistration = (legalEntityScope: 'required' | 'optional', onHandler: () => void) => defineRead( - { ...registration().descriptor, legalEntityScope, permissionTarget: 'legal_entity' }, + { + ...registration().descriptor, + legalEntityScope, + permissionTarget: 'legal_entity', + }, () => { onHandler(); return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); @@ -472,182 +483,179 @@ it.effect('fails closed when explicit Counterparty read authority is unavailable expect(harness.evidence()).toBe(0); }), ); -it.effect( - 'derives the authorized resource from decoded input and ignores conflicting transport hints', - () => - Effect.gen(function* migratedTest13() { - const legalEntityId = '00000000-0000-4000-8000-000000000004'; - let authorizedTarget: - | { moduleId: string; resourceId: string; resourceType: string } - | undefined; - const harness = yield* makeHarness({ - onResourceTarget: (target) => { - authorizedTarget = target; - }, - permissionDecision: 'allowed', - resolvedScope: { ...scope, legalEntityId }, - }); - const target = { - moduleId: 'inventory.stock', - resourceId: 'stock-1', - resourceType: 'inventory.stock.item', - }; - const targetRegistration = defineRead( - { - ...registration().descriptor, - inputSchema: ResourceTargetSchema, - legalEntityScope: 'required', - permissionTarget: 'resource', - resultSchema: Schema.String, - }, - () => Effect.succeed({ evidence: { resultCount: 1 }, result: 'visible' }), - () => Effect.succeed({}), - (input) => ({ kind: 'resource', resource: input }), - ); - const result = yield* harness.runtime.runRead({ +it.effect('derives the authorized resource from decoded input and ignores conflicting transport hints', () => + Effect.gen(function* migratedTest13() { + const legalEntityId = '00000000-0000-4000-8000-000000000004'; + let authorizedTarget: { moduleId: string; resourceId: string; resourceType: string } | undefined; + const harness = yield* makeHarness({ + onResourceTarget: (target) => { + authorizedTarget = target; + }, + permissionDecision: 'allowed', + resolvedScope: { ...scope, legalEntityId }, + }); + const target = { + moduleId: 'inventory.stock', + resourceId: 'stock-1', + resourceType: 'inventory.stock.item', + }; + const targetRegistration = defineRead( + { + ...registration().descriptor, + inputSchema: ResourceTargetSchema, + legalEntityScope: 'required', + permissionTarget: 'resource', + resultSchema: Schema.String, + }, + () => Effect.succeed({ evidence: { resultCount: 1 }, result: 'visible' }), + () => Effect.succeed({}), + (input) => ({ kind: 'resource', resource: input }), + ); + const result = yield* harness.runtime.runRead({ + input: target, + principal: { + ...scope, + authBindingId: '00000000-0000-4000-8000-000000000005', + authContextRef: 'better-auth-session:read-runtime', + authMethod: 'session', + legalEntityId, + }, + registration: targetRegistration, + transport: { + correlationId: scope.correlationId, + targetModuleKey: 'forged.module', + targetResourceId: 'forged-resource', + targetResourceType: 'forged.type', + }, + }); + expect(result).toBe('visible'); + expect(authorizedTarget).toEqual(target); + }), +); +it.effect('authorizes a canonical Resource through explicit tenant Party administration alternatives', () => + Effect.gen(function* migratedTest14() { + const legalEntityId = '00000000-0000-4000-8000-000000000004'; + const target = { + moduleId: 'party.registry', + resourceId: 'counterparty-1', + resourceType: 'counterparty', + }; + const policyTargets: unknown[] = []; + const policy = defineGlobalPolicy({ + evaluate: ({ target: policyTarget }) => { + policyTargets.push(policyTarget); + return Effect.void; + }, + policyKey: 'party.registry.counterparty-read.v1', + }); + let handlerCalls = 0; + const counterpartyRead = defineRead( + { + ...registration().descriptor, + inputSchema: ResourceTargetSchema, + legalEntityScope: 'required', + permissionTarget: 'resource', + policies: [{ denialStatus: 422, policyKey: policy.policyKey }], + resultSchema: Schema.String, + }, + () => { + handlerCalls += 1; + return Effect.succeed({ + evidence: { resultCount: 1 }, + result: 'visible', + }); + }, + () => Effect.succeed({}), + (input) => ({ + kind: 'any_of', + targets: [ + { kind: 'resource', resource: input }, + { kind: 'tenant', permission: 'manage_party_identity' }, + ], + }), + undefined, + [policy], + ); + const principal = { + ...scope, + authBindingId: '00000000-0000-4000-8000-000000000005', + authContextRef: 'better-auth-session:read-runtime', + authMethod: 'session' as const, + legalEntityId, + }; + + const tenantAdmin = yield* makeHarness({ + permissionDecision: 'denied', + tenantPermissionDecision: 'allowed', + }); + expect( + yield* tenantAdmin.runtime.runRead({ input: target, - principal: { - ...scope, - authBindingId: '00000000-0000-4000-8000-000000000005', - authContextRef: 'better-auth-session:read-runtime', - authMethod: 'session', - legalEntityId, - }, - registration: targetRegistration, + principal: scope, + registration: counterpartyRead, transport: { correlationId: scope.correlationId, targetModuleKey: 'forged.module', targetResourceId: 'forged-resource', targetResourceType: 'forged.type', }, - }); - expect(result).toBe('visible'); - expect(authorizedTarget).toEqual(target); - }), -); -it.effect( - 'authorizes a canonical Resource through explicit tenant Party administration alternatives', - () => - Effect.gen(function* migratedTest14() { - const legalEntityId = '00000000-0000-4000-8000-000000000004'; - const target = { - moduleId: 'party.registry', - resourceId: 'counterparty-1', - resourceType: 'counterparty', - }; - const policyTargets: unknown[] = []; - const policy = defineGlobalPolicy({ - evaluate: ({ target: policyTarget }) => { - policyTargets.push(policyTarget); - return Effect.void; - }, - policyKey: 'party.registry.counterparty-read.v1', - }); - let handlerCalls = 0; - const counterpartyRead = defineRead( - { - ...registration().descriptor, - inputSchema: ResourceTargetSchema, - legalEntityScope: 'required', - permissionTarget: 'resource', - policies: [{ denialStatus: 422, policyKey: policy.policyKey }], - resultSchema: Schema.String, - }, - () => { - handlerCalls += 1; - return Effect.succeed({ evidence: { resultCount: 1 }, result: 'visible' }); - }, - () => Effect.succeed({}), - (input) => ({ - kind: 'any_of', - targets: [ - { kind: 'resource', resource: input }, - { kind: 'tenant', permission: 'manage_party_identity' }, - ], - }), - undefined, - [policy], - ); - const principal = { - ...scope, - authBindingId: '00000000-0000-4000-8000-000000000005', - authContextRef: 'better-auth-session:read-runtime', - authMethod: 'session' as const, - legalEntityId, - }; - - const tenantAdmin = yield* makeHarness({ - permissionDecision: 'denied', - tenantPermissionDecision: 'allowed', - }); - expect( - yield* tenantAdmin.runtime.runRead({ - input: target, - principal: scope, - registration: counterpartyRead, - transport: { - correlationId: scope.correlationId, - targetModuleKey: 'forged.module', - targetResourceId: 'forged-resource', - targetResourceType: 'forged.type', - }, - }), - ).toBe('visible'); - expect(policyTargets).toEqual([ - { - targetModuleKey: 'party.registry', - targetResourceId: 'counterparty-1', - targetResourceType: 'counterparty', - }, - ]); + }), + ).toBe('visible'); + expect(policyTargets).toEqual([ + { + targetModuleKey: 'party.registry', + targetResourceId: 'counterparty-1', + targetResourceType: 'counterparty', + }, + ]); - const resourceAuthority = yield* makeHarness({ - permissionDecision: 'allowed', - resolvedScope: { ...scope, legalEntityId }, - tenantPermissionDecision: 'denied', - }); - expect( - yield* resourceAuthority.runtime.runRead({ - input: target, - principal, - registration: counterpartyRead, - transport: { correlationId: scope.correlationId }, - }), - ).toBe('visible'); + const resourceAuthority = yield* makeHarness({ + permissionDecision: 'allowed', + resolvedScope: { ...scope, legalEntityId }, + tenantPermissionDecision: 'denied', + }); + expect( + yield* resourceAuthority.runtime.runRead({ + input: target, + principal, + registration: counterpartyRead, + transport: { correlationId: scope.correlationId }, + }), + ).toBe('visible'); - const indeterminate = yield* makeHarness({ - permissionDecision: 'denied', - resolvedScope: { ...scope, legalEntityId }, - tenantPermissionDecision: 'unavailable', - }); - const unavailable = yield* Effect.flip( - indeterminate.runtime.runRead({ - input: target, - principal, - registration: counterpartyRead, - transport: { correlationId: scope.correlationId }, - }), - ); - expect(Predicate.isTagged(unavailable, 'ReadPermissionUnavailable')).toBe(true); - expect(indeterminate.evidence()).toBe(0); + const indeterminate = yield* makeHarness({ + permissionDecision: 'denied', + resolvedScope: { ...scope, legalEntityId }, + tenantPermissionDecision: 'unavailable', + }); + const unavailable = yield* Effect.flip( + indeterminate.runtime.runRead({ + input: target, + principal, + registration: counterpartyRead, + transport: { correlationId: scope.correlationId }, + }), + ); + expect(Predicate.isTagged(unavailable, 'ReadPermissionUnavailable')).toBe(true); + expect(indeterminate.evidence()).toBe(0); - const denied = yield* makeHarness({ - permissionDecision: 'denied', - resolvedScope: { ...scope, legalEntityId }, - tenantPermissionDecision: 'denied', - }); - const denial = yield* Effect.flip( - denied.runtime.runRead({ - input: target, - principal, - registration: counterpartyRead, - transport: { correlationId: scope.correlationId }, - }), - ); - expect(Predicate.isTagged(denial, 'ReadPermissionDenied')).toBe(true); - expect(denied.evidence()).toBe(1); - expect(handlerCalls).toBe(2); - }), + const denied = yield* makeHarness({ + permissionDecision: 'denied', + resolvedScope: { ...scope, legalEntityId }, + tenantPermissionDecision: 'denied', + }); + const denial = yield* Effect.flip( + denied.runtime.runRead({ + input: target, + principal, + registration: counterpartyRead, + transport: { correlationId: scope.correlationId }, + }), + ); + expect(Predicate.isTagged(denial, 'ReadPermissionDenied')).toBe(true); + expect(denied.evidence()).toBe(1); + expect(handlerCalls).toBe(2); + }), ); it.effect('rejects generic tenant access as an alternative permission target', () => Effect.gen(function* migratedTest15() { @@ -682,7 +690,9 @@ it.effect('rejects generic tenant access as an alternative permission target', ( }) as never, ); const failure = yield* Effect.flip( - (yield* makeHarness({ resolvedScope: { ...scope, legalEntityId } })).runtime.runRead({ + (yield* makeHarness({ + resolvedScope: { ...scope, legalEntityId }, + })).runtime.runRead({ input: {}, principal: { ...scope, @@ -798,7 +808,7 @@ for (const scenario of [ it.effect('does not release generated search candidates denied by result-level authorization', () => Effect.gen(function* migratedTest20() { const legalEntityId = '00000000-0000-4000-8000-000000000004'; - const candidate = yield* Schema.decodeUnknownEffect(ResourceTargetSchema)({ + const candidate = yield* Schema.decodeEffect(ResourceTargetSchema)({ moduleId: 'inventory.stock', resourceId: 'stock-1', resourceType: 'inventory.stock.item', @@ -839,7 +849,7 @@ it.effect('does not release generated search candidates denied by result-level a ); it.effect('authorizes tenant-scoped Party search results without fabricating a Legal Entity', () => Effect.gen(function* migratedTest21() { - const candidate = yield* Schema.decodeUnknownEffect(ResourceTargetSchema)({ + const candidate = yield* Schema.decodeEffect(ResourceTargetSchema)({ moduleId: 'party.registry', resourceId: 'party-1', resourceType: 'party.registry.party', @@ -881,7 +891,7 @@ it.effect('authorizes tenant-scoped Party search results without fabricating a L ); it.effect('fails closed when tenant-scoped Party result authorization becomes unavailable', () => Effect.gen(function* migratedTest22() { - const candidate = yield* Schema.decodeUnknownEffect(ResourceTargetSchema)({ + const candidate = yield* Schema.decodeEffect(ResourceTargetSchema)({ moduleId: 'party.registry', resourceId: 'party-1', resourceType: 'party.registry.party', @@ -914,56 +924,50 @@ it.effect('fails closed when tenant-scoped Party result authorization becomes un expect(Predicate.isTagged(failure, 'ReadPermissionUnavailable')).toBe(true); }), ); -it.effect( - 'preserves declared owner read availability and not-found failures but sanitizes defects', - () => - Effect.gen(function* migratedTest23() { - const failures = [ - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'A provider is temporarily unavailable', - }), - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The resource does not exist', +it.effect('preserves declared owner read availability and not-found failures but sanitizes defects', () => + Effect.gen(function* migratedTest23() { + const failures = [ + new ReadHandlerUnavailable({ + code: 'read_handler_unavailable', + reason: 'A provider is temporarily unavailable', + }), + new ReadHandlerNotFound({ + code: 'read_handler_not_found', + reason: 'The resource does not exist', + }), + new Error('secret owner defect'), + ] as const; + const expectedTags = ['ReadHandlerUnavailable', 'ReadHandlerNotFound', 'ReadHandlerExecutionError']; + yield* Effect.forEach( + failures, + (failure, index) => + Effect.gen(function* migratedTest24() { + const harness = yield* makeHarness(); + const failingRegistration = defineRead( + registration().descriptor, + () => Effect.fail(failure), + () => Effect.succeed({}), + () => ({ kind: 'module', moduleId: 'core.shell' }), + ); + const error = yield* Effect.flip( + harness.runtime.runRead({ + input: {}, + principal: scope, + registration: failingRegistration, + transport: { correlationId: scope.correlationId }, + }), + ); + const expectedTag = expectedTags[index]; + if (expectedTag === undefined) { + expect.unreachable('Expected value to be present'); + } + expect(Predicate.isTagged(error, expectedTag)).toBe(true); + expect(error.reason).not.toMatch(/secret/u); + expect(harness.evidence()).toBe(0); }), - new Error('secret owner defect'), - ] as const; - const expectedTags = [ - 'ReadHandlerUnavailable', - 'ReadHandlerNotFound', - 'ReadHandlerExecutionError', - ]; - yield* Effect.all( - failures.map((failure, index) => - Effect.gen(function* migratedTest24() { - const harness = yield* makeHarness(); - const failingRegistration = defineRead( - registration().descriptor, - () => Effect.fail(failure), - () => Effect.succeed({}), - () => ({ kind: 'module', moduleId: 'core.shell' }), - ); - const error = yield* Effect.flip( - harness.runtime.runRead({ - input: {}, - principal: scope, - registration: failingRegistration, - transport: { correlationId: scope.correlationId }, - }), - ); - const expectedTag = expectedTags[index]; - if (expectedTag === undefined) { - expect.unreachable('Expected value to be present'); - } - expect(Predicate.isTagged(error, expectedTag)).toBe(true); - expect(error.reason).not.toMatch(/secret/u); - expect(harness.evidence()).toBe(0); - }), - ), - { concurrency: 'unbounded' }, - ); - }), + { concurrency: 'unbounded' }, + ); + }), ); it.effect('keeps read interruption and waits for transaction settlement', () => Effect.gen(function* migratedTest25() { diff --git a/app/packages/core-runtime/tests/unit/schema-contract.test.ts b/app/packages/core-runtime/tests/unit/schema-contract.test.ts index 47f3ae73e..c20df0724 100644 --- a/app/packages/core-runtime/tests/unit/schema-contract.test.ts +++ b/app/packages/core-runtime/tests/unit/schema-contract.test.ts @@ -1,8 +1,8 @@ -import { expect, it } from 'effect-rstest'; - import { getTableName, isTable } from 'drizzle-orm'; import { getTableConfig, PgDialect } from 'drizzle-orm/pg-core'; import type { PgTable } from 'drizzle-orm/pg-core'; +import { expect, it } from 'effect-rstest'; + import * as schemaExports from '../../src/db/schema.ts'; import { ACTION_INVOCATION_STATUSES, @@ -37,18 +37,12 @@ it('exports exactly the 18 Core tables in PostgreSQL schema core', () => { return `${config.schema}.${config.name}`; }) .toSorted(); - const expectedQualifiedNames = CORE_TABLE_INVENTORY.map( - (tableName) => `${CORE_SCHEMA_NAME}.${tableName}`, - ).toSorted(); + const expectedQualifiedNames = CORE_TABLE_INVENTORY.map((tableName) => `${CORE_SCHEMA_NAME}.${tableName}`).toSorted(); expect(qualifiedNames).toEqual(expectedQualifiedNames); expect(new Set(qualifiedNames).size).toBe(CORE_TABLE_INVENTORY.length); expect(qualifiedNames.some((name) => name.startsWith('public.'))).toBe(false); - expect( - qualifiedNames.some((name) => - /^(?:auth|ticketing|properties|property|accounting)\./u.test(name), - ), - ).toBe(false); + expect(qualifiedNames.some((name) => /^(?:auth|ticketing|properties|property|accounting)\./u.test(name))).toBe(false); }); it('supports pre-authentication Action Invocation rows and indeterminate outcomes', () => { expect(getColumn('principal_id').notNull).toBe(false); @@ -68,9 +62,7 @@ it('supports pre-authentication Action Invocation rows and indeterminate outcome 'replayed', ]); - const statusCheck = actionConfig.checks.find( - (candidate) => candidate.name === 'core_action_invocations_status_ck', - ); + const statusCheck = actionConfig.checks.find((candidate) => candidate.name === 'core_action_invocations_status_ck'); if (statusCheck === undefined) { expect.unreachable('Expected value to be present'); } @@ -89,10 +81,7 @@ it('preserves critical Action foreign keys and unique idempotency index', () => } expect(getTableName(principalForeignKey.reference().foreignTable)).toBe(getTableName(principals)); expect(principalForeignKey.onDelete).toBe('restrict'); - expect(principalForeignKey.reference().columns.map((column) => column.name)).toEqual([ - 'tenant_id', - 'principal_id', - ]); + expect(principalForeignKey.reference().columns.map((column) => column.name)).toEqual(['tenant_id', 'principal_id']); const idempotencyIndex = actionConfig.indexes.find( (candidate) => candidate.config.name === 'core_action_invocations_idempotency_uk', @@ -102,15 +91,16 @@ it('preserves critical Action foreign keys and unique idempotency index', () => } expect(idempotencyIndex.config.unique).toBe(true); expect(idempotencyIndex.config.where).toBeDefined(); - expect( - idempotencyIndex.config.columns.map((column) => ('name' in column ? column.name : false)), - ).toEqual(['tenant_id', 'action_key', 'principal_id', 'idempotency_key']); + expect(idempotencyIndex.config.columns.map((column) => ('name' in column ? column.name : false))).toEqual([ + 'tenant_id', + 'action_key', + 'principal_id', + 'idempotency_key', + ]); }); it('allocates Domain Event order through a database-owned monotonic sequence', () => { const domainEventConfig = getTableConfig(domainEvents); - const sequenceColumn = domainEventConfig.columns.find( - (candidate) => candidate.name === 'tenant_sequence_no', - ); + const sequenceColumn = domainEventConfig.columns.find((candidate) => candidate.name === 'tenant_sequence_no'); if (sequenceColumn === undefined) { expect.unreachable('Expected value to be present'); @@ -126,9 +116,10 @@ it('allocates Domain Event order through a database-owned monotonic sequence', ( expect.unreachable('Expected value to be present'); } expect(sequenceIndex.config.unique).toBe(true); - expect( - sequenceIndex.config.columns.map((column) => ('name' in column ? column.name : false)), - ).toEqual(['tenant_id', 'tenant_sequence_no']); + expect(sequenceIndex.config.columns.map((column) => ('name' in column ? column.name : false))).toEqual([ + 'tenant_id', + 'tenant_sequence_no', + ]); }); it('keeps the inferred Action status type aligned with the lifecycle union', () => { type ActionInsert = typeof actionInvocations.$inferInsert; diff --git a/app/packages/core-runtime/tests/unit/scoped-transaction.test.ts b/app/packages/core-runtime/tests/unit/scoped-transaction.test.ts index f508d3963..df81fc567 100644 --- a/app/packages/core-runtime/tests/unit/scoped-transaction.test.ts +++ b/app/packages/core-runtime/tests/unit/scoped-transaction.test.ts @@ -1,13 +1,13 @@ +import { getTableConfig, pgTable, uuid } from 'drizzle-orm/pg-core'; +import { Effect, Option, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Option, Predicate } from 'effect'; import { OperationalScopeTransaction, installOperationalScopeFromTransactionService, tenantLegalEntityRlsPolicies, tenantRlsPolicies, } from '../../src/db/scoped-transaction.ts'; -import { getTableConfig, pgTable, uuid } from 'drizzle-orm/pg-core'; import type { OperationalScopeTransactionService } from '../../src/db/scoped-transaction.ts'; const unusedOperation = (): never => { @@ -34,7 +34,10 @@ it.effect('installs and verifies transaction-local scope and exposes no transact }), Effect.sync(() => { calls += 1; - return Option.some({ legal_entity_id: 'entity', tenant_id: 'tenant' }); + return Option.some({ + legal_entity_id: 'entity', + tenant_id: 'tenant', + }); }), ); const capability = yield* installOperationalScopeFromTransactionService({ @@ -56,7 +59,7 @@ it.effect('fails closed when transaction settings do not match', () => Effect.gen(function* migratedTest2() { const transaction = transactionService( () => Effect.void, - Effect.succeed(Option.some({ legal_entity_id: '', tenant_id: 'foreign' })), + Effect.succeedSome({ legal_entity_id: '', tenant_id: 'foreign' }), ); const error = yield* Effect.flip( installOperationalScopeFromTransactionService({ diff --git a/app/packages/core-runtime/tests/unit/search-ingestion.test.ts b/app/packages/core-runtime/tests/unit/search-ingestion.test.ts index 9126101da..42b89d8e1 100644 --- a/app/packages/core-runtime/tests/unit/search-ingestion.test.ts +++ b/app/packages/core-runtime/tests/unit/search-ingestion.test.ts @@ -1,11 +1,13 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { CORE_SEARCH_INGESTION_REGISTRATIONS, CORE_SEARCH_PARTY_LIFECYCLE_TOPICS, makeCoreSearchIngestion, } from '../../src/search/ingestion.ts'; import { + CoreSearchProjectionStore, createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '../../src/search/projection.ts'; @@ -39,9 +41,7 @@ const observation = (projectionVersion: string, title: string) => ({ }); it('declares one immutable Core registration for every closed Party lifecycle topic', () => { - expect(CORE_SEARCH_INGESTION_REGISTRATIONS.map(({ topic }) => topic)).toEqual( - CORE_SEARCH_PARTY_LIFECYCLE_TOPICS, - ); + expect(CORE_SEARCH_INGESTION_REGISTRATIONS.map(({ topic }) => topic)).toEqual(CORE_SEARCH_PARTY_LIFECYCLE_TOPICS); expect(Object.isFrozen(CORE_SEARCH_INGESTION_REGISTRATIONS)).toBe(true); expect( CORE_SEARCH_INGESTION_REGISTRATIONS.every( @@ -56,9 +56,9 @@ it('declares one immutable Core registration for every closed Party lifecycle to it.effect('ingests duplicate and out-of-order post-commit observations idempotently', () => { const store = makeInMemoryCoreSearchProjectionStore(); const ingestion = makeCoreSearchIngestion(store); - const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* ingestObservationsIdempotently() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); yield* ingestion.ingest(observation('2', 'Current title')); yield* ingestion.ingest(observation('2', 'Current title')); yield* ingestion.ingest(observation('1', 'Stale title')); @@ -117,10 +117,9 @@ it.effect('rejects undeclared topics and sequence/document identity mismatches', }, ]; return Effect.gen(function* testInvalidObservations() { - const failures = yield* Effect.all( - invalidObservations.map((invalidObservation) => - Effect.flip(ingestion.ingest(invalidObservation)), - ), + const failures = yield* Effect.forEach( + invalidObservations, + (invalidObservation) => Effect.flip(ingestion.ingest(invalidObservation)), { concurrency: 'unbounded' }, ); for (const failure of failures) { diff --git a/app/packages/core-runtime/tests/unit/search-projection.test.ts b/app/packages/core-runtime/tests/unit/search-projection.test.ts index 259ed373b..9c43d9509 100644 --- a/app/packages/core-runtime/tests/unit/search-projection.test.ts +++ b/app/packages/core-runtime/tests/unit/search-projection.test.ts @@ -1,10 +1,16 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; + import { + CoreSearchProjectionInvalid, + CoreSearchProjectionUnavailable, + CoreSearchQueryRuntime, + CoreSearchProjectionStore, createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '../../src/search/projection.ts'; +import type { CoreSearchProjectionHit, CoreSearchProjectionStoreService } from '../../src/search/projection.ts'; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Any)); @@ -56,7 +62,6 @@ it.effect( 'a projection rebuild floor prevents unseen stale resources and rejects divergent equal-version rebuilds', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); const rebuild = { documents: [], moduleId: partyRef.moduleId, @@ -65,9 +70,14 @@ it.effect( tenantId, }; return Effect.gen(function* testProjectionRebuildFloor() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); yield* store.replace(rebuild); yield* store.apply({ document: party(), kind: 'upsert' }); - yield* store.replace({ ...rebuild, documents: [party()], rebuildVersion: '1' }); + yield* store.replace({ + ...rebuild, + documents: [party()], + rebuildVersion: '1', + }); expect( yield* runtime.search({ includeArchived: false, @@ -80,7 +90,10 @@ it.effect( yield* store.replace(rebuild); const divergent = yield* Effect.flip(store.replace({ ...rebuild, documents: [party()] })); expect(Predicate.isTagged(divergent, 'CoreSearchProjectionInvalid')).toBe(true); - yield* store.apply({ document: party({ projectionVersion: '3' }), kind: 'upsert' }); + yield* store.apply({ + document: party({ projectionVersion: '3' }), + kind: 'upsert', + }); yield* store.replace(rebuild); const searchResults = yield* runtime.search({ includeArchived: false, @@ -94,105 +107,106 @@ it.effect( }, ); -it.effect( - 'Core Search identifies alias-only matches while canonical evidence takes precedence', - () => { - const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); - return Effect.gen(function* testAliasMatches() { - yield* store.apply({ - document: party({ - aliases: [ - { kind: 'resource', ref: aliasRef, searchableText: ['Former Company', 'Acme'] }, - ], - matchedRef: aliasRef, - }), - kind: 'upsert', - }); - const search = (query: string) => - runtime.search({ - includeArchived: false, - moduleId: partyRef.moduleId, - query, - resourceType: partyRef.resourceType, - tenantId, - }); - const aliasHits = yield* search('former'); - expect(aliasHits.length).toBe(1); - expect(aliasHits[0]?.ref).toEqual(partyRef); - expect(aliasHits[0]?.matchedRef).toEqual(aliasRef); - expect(aliasHits[0]?.matchedSubjectRef).toBe(undefined); - expect(encodeJson(aliasHits)).not.toMatch(/Former Company|searchableText|aliases/u); - const canonicalHits = yield* search('acme'); - expect(canonicalHits[0]?.matchedRef).toBe(undefined); - }); - }, -); - -it.effect( - 'Core Search rejects cross-tenant aliases and malformed or oversized temporal evidence', - () => { - const store = makeInMemoryCoreSearchProjectionStore(); - const invalidEvidence = [ - { +it.effect('Core Search identifies alias-only matches while canonical evidence takes precedence', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + return Effect.gen(function* testAliasMatches() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + yield* store.apply({ + document: party({ aliases: [ { kind: 'resource', - ref: { ...aliasRef, tenantId: otherTenantId }, - searchableText: ['foreign'], - }, - ], - }, - { temporalSearchableText: [{ validFrom: 'not-a-date', value: 'private' }] }, - { - temporalSearchableText: [ - { validFrom: '2026-02-01', validTo: '2026-02-01', value: 'private' }, - ], - }, - { - aliases: [ - { - kind: 'subject', ref: aliasRef, - searchableText: [], - temporalSearchableText: [ - { validFrom: '2026-02-01', validTo: '2026-01-01', value: 'private' }, - ], + searchableText: ['Former Company', 'Acme'], }, ], - }, - { - aliases: Array.from({ length: 101 }, () => ({ + matchedRef: aliasRef, + }), + kind: 'upsert', + }); + const search = (query: string) => + runtime.search({ + includeArchived: false, + moduleId: partyRef.moduleId, + query, + resourceType: partyRef.resourceType, + tenantId, + }); + const aliasHits = yield* search('former'); + expect(aliasHits.length).toBe(1); + expect(aliasHits[0]?.ref).toEqual(partyRef); + expect(aliasHits[0]?.matchedRef).toEqual(aliasRef); + expect(aliasHits[0]?.matchedSubjectRef).toBe(undefined); + expect(encodeJson(aliasHits)).not.toMatch(/Former Company|searchableText|aliases/u); + const canonicalHits = yield* search('acme'); + expect(canonicalHits[0]?.matchedRef).toBe(undefined); + }); +}); + +it.effect('Core Search rejects cross-tenant aliases and malformed or oversized temporal evidence', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + const invalidEvidence = [ + { + aliases: [ + { kind: 'resource', + ref: { ...aliasRef, tenantId: otherTenantId }, + searchableText: ['foreign'], + }, + ], + }, + { + temporalSearchableText: [{ validFrom: 'not-a-date', value: 'private' }], + }, + { + temporalSearchableText: [{ validFrom: '2026-02-01', validTo: '2026-02-01', value: 'private' }], + }, + { + aliases: [ + { + kind: 'subject', ref: aliasRef, - searchableText: ['private'], - })), - }, - { - temporalSearchableText: Array.from({ length: 101 }, () => ({ - validFrom: '2026-01-01', - value: 'private', - })), - }, - ]; - return Effect.gen(function* testInvalidEvidence() { - const failures = yield* Effect.all( - invalidEvidence.map((evidence) => - Effect.flip(store.apply({ document: party(evidence), kind: 'upsert' })), - ), - { concurrency: 'unbounded' }, - ); - for (const failure of failures) { - expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); - } - }); - }, -); + searchableText: [], + temporalSearchableText: [ + { + validFrom: '2026-02-01', + validTo: '2026-01-01', + value: 'private', + }, + ], + }, + ], + }, + { + aliases: Array.from({ length: 101 }, () => ({ + kind: 'resource', + ref: aliasRef, + searchableText: ['private'], + })), + }, + { + temporalSearchableText: Array.from({ length: 101 }, () => ({ + validFrom: '2026-01-01', + value: 'private', + })), + }, + ]; + return Effect.gen(function* testInvalidEvidence() { + const failures = yield* Effect.forEach( + invalidEvidence, + (evidence) => Effect.flip(store.apply({ document: party(evidence), kind: 'upsert' })), + { concurrency: 'unbounded' }, + ); + for (const failure of failures) { + expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); + } + }); +}); it.effect('Core Search honors half-open evidence periods for canonical and subject aliases', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testHalfOpenEvidencePeriods() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); yield* TestClock.setTime(Date.parse('2026-09-03T00:00:00Z')); yield* store.apply({ document: party({ @@ -211,7 +225,10 @@ it.effect('Core Search honors half-open evidence periods for canonical and subje }, ], temporalSearchableText: [ - { validFrom: '2026-02-01T00:00:00Z', value: 'current-private@example.test' }, + { + validFrom: '2026-02-01T00:00:00Z', + value: 'current-private@example.test', + }, { validFrom: '2000-01-01T00:00:00Z', validTo: '2100-01-01T00:00:00Z', @@ -246,9 +263,9 @@ it.effect('Core Search honors half-open evidence periods for canonical and subje it.effect('Core Search rebuilds one owned projection atomically and isolates tenants', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testOwnedProjectionRebuild() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); yield* store.replace({ documents: [party()], moduleId: 'party.registry', @@ -288,7 +305,13 @@ it.effect('Core Search rebuilds one owned projection atomically and isolates ten expect(encodeJson(result)).not.toMatch(/private@example\.test/u); yield* store.replace({ - documents: [party({ archived: true, projectionVersion: '2', title: 'Replacement' })], + documents: [ + party({ + archived: true, + projectionVersion: '2', + title: 'Replacement', + }), + ], moduleId: 'party.registry', rebuildVersion: '2', resourceType: 'party.registry.party', @@ -305,68 +328,32 @@ it.effect('Core Search rebuilds one owned projection atomically and isolates ten }); }); -it.effect( - 'Core Search applies typed Legal Entity and role facets without returning match evidence', - () => { - const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); - const counterpartyRef = { - moduleId: 'party.registry', - resourceId: '40000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.counterparty', - tenantId, - } as const; - return Effect.gen(function* testTypedLegalEntityAndRoleFacets() { - yield* store.replace({ - documents: [ - { - archived: false, - facets: [], - matchedSubjectRef: aliasRef, - metadata: [{ key: 'current-roles', kind: 'strings', value: ['CUSTOMER', 'SUPPLIER'] }], - projectionVersion: '1', - ref: counterpartyRef, - searchableText: ['Acme', 'private@example.test'], - selectedLegalEntityId: legalEntityId, - subjectRef: partyRef, - temporalFacets: [ - { - key: 'current-role', - validFrom: '2026-01-01T00:00:00.000Z', - value: 'CUSTOMER', - }, - { - key: 'current-role', - validFrom: '2026-02-01T00:00:00.000Z', - value: 'SUPPLIER', - }, - ], - title: 'Acme', - }, - ], - moduleId: 'party.registry', - rebuildVersion: '1', - resourceType: 'party.registry.counterparty', - tenantId, - }); - - const result = yield* runtime.search({ - effectiveAt: '2026-09-03T00:00:00.000Z', - facets: [{ key: 'current-role', values: ['SUPPLIER'] }], - includeArchived: false, - moduleId: 'party.registry', - query: 'private@example.test', - resourceType: 'party.registry.counterparty', - selectedLegalEntityId: legalEntityId, - tenantId, - }); - expect(result).toEqual([ +it.effect('Core Search applies typed Legal Entity and role facets without returning match evidence', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + const counterpartyRef = { + moduleId: 'party.registry', + resourceId: '40000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.counterparty', + tenantId, + } as const; + return Effect.gen(function* testTypedLegalEntityAndRoleFacets() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + yield* store.replace({ + documents: [ { archived: false, facets: [], matchedSubjectRef: aliasRef, - metadata: [{ key: 'current-roles', kind: 'strings', value: ['CUSTOMER', 'SUPPLIER'] }], + metadata: [ + { + key: 'current-roles', + kind: 'strings', + value: ['CUSTOMER', 'SUPPLIER'], + }, + ], + projectionVersion: '1', ref: counterpartyRef, + searchableText: ['Acme', 'private@example.test'], selectedLegalEntityId: legalEntityId, subjectRef: partyRef, temporalFacets: [ @@ -383,69 +370,118 @@ it.effect( ], title: 'Acme', }, - ]); - expect(encodeJson(result)).not.toMatch(/private@example\.test/u); - expect( - yield* runtime.search({ - includeArchived: false, - moduleId: 'party.registry', - query: 'acme', - resourceType: 'party.registry.counterparty', - tenantId, - }), - ).toEqual([]); + ], + moduleId: 'party.registry', + rebuildVersion: '1', + resourceType: 'party.registry.counterparty', + tenantId, }); - }, -); -it.effect( - 'Core Search rejects malformed or cross-owner rebuild documents without partial replacement', - () => { - const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); - return Effect.gen(function* testMalformedRebuildDocuments() { - yield* store.replace({ - documents: [party()], + const result = yield* runtime.search({ + effectiveAt: '2026-09-03T00:00:00.000Z', + facets: [{ key: 'current-role', values: ['SUPPLIER'] }], + includeArchived: false, + moduleId: 'party.registry', + query: 'private@example.test', + resourceType: 'party.registry.counterparty', + selectedLegalEntityId: legalEntityId, + tenantId, + }); + expect(result).toEqual([ + { + archived: false, + facets: [], + matchedSubjectRef: aliasRef, + metadata: [ + { + key: 'current-roles', + kind: 'strings', + value: ['CUSTOMER', 'SUPPLIER'], + }, + ], + ref: counterpartyRef, + selectedLegalEntityId: legalEntityId, + subjectRef: partyRef, + temporalFacets: [ + { + key: 'current-role', + validFrom: '2026-01-01T00:00:00.000Z', + value: 'CUSTOMER', + }, + { + key: 'current-role', + validFrom: '2026-02-01T00:00:00.000Z', + value: 'SUPPLIER', + }, + ], + title: 'Acme', + }, + ]); + expect(encodeJson(result)).not.toMatch(/private@example\.test/u); + expect( + yield* runtime.search({ + includeArchived: false, moduleId: 'party.registry', - rebuildVersion: '1', - resourceType: 'party.registry.party', + query: 'acme', + resourceType: 'party.registry.counterparty', tenantId, - }); + }), + ).toEqual([]); + }); +}); - const failure = yield* Effect.flip( - store.replace({ - documents: [party({ ref: { ...partyRef, moduleId: 'foreign.module' } })], - moduleId: 'party.registry', - rebuildVersion: '1', - resourceType: 'party.registry.party', - tenantId, - }), - ); - expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); - const result = yield* runtime.search({ - includeArchived: false, +it.effect('Core Search rejects malformed or cross-owner rebuild documents without partial replacement', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + return Effect.gen(function* testMalformedRebuildDocuments() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + yield* store.replace({ + documents: [party()], + moduleId: 'party.registry', + rebuildVersion: '1', + resourceType: 'party.registry.party', + tenantId, + }); + + const failure = yield* Effect.flip( + store.replace({ + documents: [party({ ref: { ...partyRef, moduleId: 'foreign.module' } })], moduleId: 'party.registry', - query: 'acme', + rebuildVersion: '1', resourceType: 'party.registry.party', tenantId, - }); - expect(result.length).toBe(1); + }), + ); + expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); + const result = yield* runtime.search({ + includeArchived: false, + moduleId: 'party.registry', + query: 'acme', + resourceType: 'party.registry.party', + tenantId, }); - }, -); + expect(result.length).toBe(1); + }); +}); it.effect('Core Search makes duplicate and out-of-order lifecycle observations harmless', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = createCoreSearchQueryRuntime(store); - const versionTwo = party({ projectionVersion: '2', title: 'Current title' }); + const versionTwo = party({ + projectionVersion: '2', + title: 'Current title', + }); return Effect.gen(function* testDuplicateLifecycleObservations() { + const runtime = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); yield* store.apply({ document: versionTwo, kind: 'upsert' }); yield* store.apply({ document: versionTwo, kind: 'upsert' }); yield* store.apply({ document: party({ projectionVersion: '1', title: 'Stale title' }), kind: 'upsert', }); - yield* store.apply({ kind: 'delete', projectionVersion: '3', ref: partyRef }); + yield* store.apply({ + kind: 'delete', + projectionVersion: '3', + ref: partyRef, + }); yield* store.apply({ document: versionTwo, kind: 'upsert' }); expect( @@ -459,3 +495,78 @@ it.effect('Core Search makes duplicate and out-of-order lifecycle observations h ).toEqual([]); }); }); + +it.effect('native projection services preserve keys and provided identity', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + return Effect.gen(function* testNativeServiceIdentity() { + const runtime = yield* createCoreSearchQueryRuntime; + expect(CoreSearchProjectionStore.key).toBe('@app/core-runtime/search/projection/CoreSearchProjectionStore'); + expect(CoreSearchQueryRuntime.key).toBe('@app/core-runtime/search/projection/CoreSearchQueryRuntime'); + expect(yield* CoreSearchProjectionStore).toBe(store); + expect(yield* CoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchQueryRuntime, runtime))).toBe(runtime); + }).pipe(Effect.provideService(CoreSearchProjectionStore, store)); +}); + +it.effect('native projection services compose store writes with query reads', () => { + const store = makeInMemoryCoreSearchProjectionStore(); + return Effect.gen(function* testNativeProjectionComposition() { + const providedStore = yield* CoreSearchProjectionStore; + const runtime = yield* CoreSearchQueryRuntime; + yield* providedStore.apply({ document: party(), kind: 'upsert' }); + const hits = yield* runtime.search({ + includeArchived: false, + moduleId: partyRef.moduleId, + query: 'acme', + resourceType: partyRef.resourceType, + tenantId, + }); + expect(hits.length).toBe(1); + expect(hits[0]?.ref).toEqual(partyRef); + }).pipe( + Effect.provideServiceEffect(CoreSearchQueryRuntime, createCoreSearchQueryRuntime), + Effect.provideService(CoreSearchProjectionStore, store), + ); +}); + +const searchThroughNativeService = ( + input: Parameters[0], +): Effect.Effect< + readonly CoreSearchProjectionHit[], + CoreSearchProjectionInvalid | InstanceType, + CoreSearchQueryRuntime +> => + Effect.gen(function* searchNativeProjection() { + const runtime = yield* CoreSearchQueryRuntime; + return yield* runtime.search(input); + }); + +it.effect('native projection services retain invalid and unavailable failures', () => { + const unavailable = new CoreSearchProjectionUnavailable({ + code: 'core_search_projection_unavailable', + reason: 'Projection test store unavailable', + }); + const store: CoreSearchProjectionStoreService = { + ...makeInMemoryCoreSearchProjectionStore(), + queryCandidates: () => Effect.fail(unavailable), + }; + return Effect.gen(function* testNativeProjectionFailures() { + const providedStore = yield* CoreSearchProjectionStore; + const invalid = yield* Effect.flip(providedStore.apply({ kind: 'invalid' })); + expect(Schema.is(CoreSearchProjectionInvalid)(invalid)).toBe(true); + const invalidQuery = yield* Effect.flip(searchThroughNativeService({})); + expect(Schema.is(CoreSearchProjectionInvalid)(invalidQuery)).toBe(true); + const failedQuery = yield* Effect.flip( + searchThroughNativeService({ + includeArchived: false, + moduleId: partyRef.moduleId, + query: 'acme', + resourceType: partyRef.resourceType, + tenantId, + }), + ); + expect(failedQuery).toBe(unavailable); + }).pipe( + Effect.provideServiceEffect(CoreSearchQueryRuntime, createCoreSearchQueryRuntime), + Effect.provideService(CoreSearchProjectionStore, store), + ); +}); diff --git a/app/packages/core-runtime/tests/unit/search-schema.test.ts b/app/packages/core-runtime/tests/unit/search-schema.test.ts index 147d48bd5..fe422a658 100644 --- a/app/packages/core-runtime/tests/unit/search-schema.test.ts +++ b/app/packages/core-runtime/tests/unit/search-schema.test.ts @@ -1,10 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { getTableConfig, PgDialect } from 'drizzle-orm/pg-core'; -import { - searchIndexEntries, - searchProjectionGenerations, - searchProjectionRebuilds, -} from '../../src/db/schema.ts'; +import { expect, it } from 'effect-rstest'; + +import { searchIndexEntries, searchProjectionGenerations, searchProjectionRebuilds } from '../../src/db/schema.ts'; const config = getTableConfig(searchIndexEntries); @@ -16,11 +13,7 @@ it('Core Search rebuild floors are tenant/resource-scoped and cannot be deleted 'source_module_key', 'source_resource_type', ]); - expect(rebuilds.policies.map(({ for: operation }) => operation)).toEqual([ - 'select', - 'insert', - 'update', - ]); + expect(rebuilds.policies.map(({ for: operation }) => operation)).toEqual(['select', 'insert', 'update']); expect(rebuilds.checks.map(({ name }) => name)).toEqual([ 'core_search_projection_rebuilds_version_ck', 'core_search_projection_rebuilds_fingerprint_ck', @@ -30,15 +23,8 @@ it('Core Search rebuild floors are tenant/resource-scoped and cannot be deleted it('Core Search snapshot generations are independent tenant/source-scoped infrastructure', () => { const generations = getTableConfig(searchProjectionGenerations); expect(generations.enableRLS).toBe(true); - expect(generations.primaryKeys[0]?.columns.map(({ name }) => name)).toEqual([ - 'tenant_id', - 'source_module_key', - ]); - expect(generations.policies.map(({ for: operation }) => operation)).toEqual([ - 'select', - 'insert', - 'update', - ]); + expect(generations.primaryKeys[0]?.columns.map(({ name }) => name)).toEqual(['tenant_id', 'source_module_key']); + expect(generations.policies.map(({ for: operation }) => operation)).toEqual(['select', 'insert', 'update']); expect(generations.columns.some(({ name }) => name === 'generation')).toBe(true); expect(generations.columns.some(({ name }) => name === 'event_watermark')).toBe(true); }); @@ -53,9 +39,7 @@ it('Core Search physical projection has versioned tenant-qualified lookup keys', { name: 'deleted', notNull: true }, { name: 'projection_version', notNull: true }, ]); - const source = config.indexes.find( - ({ config: index }) => index.name === 'core_search_index_entries_source_uk', - ); + const source = config.indexes.find(({ config: index }) => index.name === 'core_search_index_entries_source_uk'); expect(source?.config.unique).toBe(true); expect(source?.config.columns.map((column) => 'name' in column && column.name)).toEqual([ 'tenant_id', @@ -63,9 +47,7 @@ it('Core Search physical projection has versioned tenant-qualified lookup keys', 'source_resource_type', 'source_resource_id', ]); - const query = config.indexes.find( - ({ config: index }) => index.name === 'core_search_index_entries_query_idx', - ); + const query = config.indexes.find(({ config: index }) => index.name === 'core_search_index_entries_query_idx'); expect(query).toBeDefined(); expect(query?.config.columns.map((column) => 'name' in column && column.name)).toEqual([ 'tenant_id', @@ -83,22 +65,15 @@ it('Core Search projection declares complete tenant RLS and bounded document che 'core_search_index_entries_tenant_update', 'core_search_index_entries_tenant_delete', ]); - expect(config.policies.map((policy) => policy.for)).toEqual([ - 'select', - 'insert', - 'update', - 'delete', - ]); + expect(config.policies.map((policy) => policy.for)).toEqual(['select', 'insert', 'update', 'delete']); expect(config.policies.every(({ to }) => to === 'ontos_runtime')).toBe(true); const dialect = new PgDialect(); const checks = config.checks.map(({ name, value }) => ({ name, sql: dialect.sqlToQuery(value).sql, })); - expect( - checks.find(({ name }) => name === 'core_search_index_entries_document_ck')?.sql ?? '', - ).toMatch(/body_text/u); - expect( - checks.find(({ name }) => name === 'core_search_index_entries_version_ck')?.sql ?? '', - ).toMatch(/projection_version/u); + expect(checks.find(({ name }) => name === 'core_search_index_entries_document_ck')?.sql ?? '').toMatch(/body_text/u); + expect(checks.find(({ name }) => name === 'core_search_index_entries_version_ck')?.sql ?? '').toMatch( + /projection_version/u, + ); }); diff --git a/app/packages/core-runtime/tests/unit/search-worker-snapshot.test.ts b/app/packages/core-runtime/tests/unit/search-worker-snapshot.test.ts index 186c1c198..392aa50bb 100644 --- a/app/packages/core-runtime/tests/unit/search-worker-snapshot.test.ts +++ b/app/packages/core-runtime/tests/unit/search-worker-snapshot.test.ts @@ -1,11 +1,9 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { attestOutboxWorkerHandlerContext } from '../../src/outbox/definition.ts'; import { CoreSearchProjectionUnavailable } from '../../src/search/projection.ts'; -import { - makeCoreSearchWorkerSnapshot, - retryCoreSearchSnapshot, -} from '../../src/search/worker-snapshot.ts'; +import { makeCoreSearchWorkerSnapshot, retryCoreSearchSnapshot } from '../../src/search/worker-snapshot.ts'; import type { CoreSearchSnapshotBackend, CoreSearchSnapshotReadExecutor, @@ -34,14 +32,11 @@ const executor: CoreSearchSnapshotReadExecutor = { }, }; -class SnapshotRetryFailure extends Schema.TaggedError()( - 'SnapshotRetryFailure', - { - cause: Schema.optional(Schema.Unknown), - code: Schema.optional(Schema.String), - message: Schema.String, - }, -) {} +class SnapshotRetryFailure extends Schema.TaggedError()('SnapshotRetryFailure', { + cause: Schema.optional(Schema.Unknown), + code: Schema.optional(Schema.String), + message: Schema.String, +}) {} const readParty = (readExecutor: CoreSearchSnapshotReadExecutor) => { expect(Object.keys(readExecutor)).toEqual(['select']); @@ -56,38 +51,38 @@ const readCounterparty = (readExecutor: CoreSearchSnapshotReadExecutor) => { const readInvalid = () => Effect.succeed('invalid'); const readStillInvalid = () => Effect.succeed('still invalid'); -it.effect( - 'worker snapshot rejects caller-created and unregistered contexts before opening persistence', - () => { - let calls = 0; - const backend: CoreSearchSnapshotBackend = { - run: () => { - calls += 1; - return Effect.die(new Error('unreachable')); - }, - }; - const snapshot = makeCoreSearchWorkerSnapshot(backend); - return Effect.gen(function* rejectInvalidWorkerContexts() { - const failures = yield* Effect.forEach( - [ - context, - attestOutboxWorkerHandlerContext({ - ...context, - workerKey: 'party.registry.unregistered', - }), - attestOutboxWorkerHandlerContext({ ...context, producerModuleKey: 'foreign.module' }), - ], - (candidate) => Effect.flip(snapshot.read(candidate, () => Effect.succeed('unreachable'))), - { concurrency: 'unbounded' }, - ); - expect(failures.length).toBe(3); - for (const failure of failures) { - expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); - } - expect(calls).toBe(0); - }); - }, -); +it.effect('worker snapshot rejects caller-created and unregistered contexts before opening persistence', () => { + let calls = 0; + const backend: CoreSearchSnapshotBackend = { + run: () => { + calls += 1; + return Effect.die(new Error('unreachable')); + }, + }; + const snapshot = makeCoreSearchWorkerSnapshot(backend); + return Effect.gen(function* rejectInvalidWorkerContexts() { + const failures = yield* Effect.forEach( + [ + context, + attestOutboxWorkerHandlerContext({ + ...context, + workerKey: 'party.registry.unregistered', + }), + attestOutboxWorkerHandlerContext({ + ...context, + producerModuleKey: 'foreign.module', + }), + ], + (candidate) => Effect.flip(snapshot.read(candidate, () => Effect.succeed('unreachable'))), + { concurrency: 'unbounded' }, + ); + expect(failures.length).toBe(3); + for (const failure of failures) { + expect(Predicate.isTagged(failure, 'CoreSearchProjectionInvalid')).toBe(true); + } + expect(calls).toBe(0); + }); +}); it.effect('identifier-update worker receives the verified Core snapshot capability', () => { const reader = makeCoreSearchWorkerSnapshot({ @@ -116,89 +111,85 @@ it.effect('identifier-update worker receives the verified Core snapshot capabili }); }); -it.effect( - 'worker snapshot exposes select-only owner reads at one current watermark and restores scope', - () => { - const installedScopes: (string | undefined)[] = []; - const backend: CoreSearchSnapshotBackend = { - run: (_context, readSnapshot) => - readSnapshot( - { - eventWatermark: '100', - legalEntityIds: [legalEntityId], - projectionVersion: '42', - tenantId, - }, - executor, - (scope) => { - installedScopes.push(scope); - return Effect.void; - }, - ), - }; - const snapshot = makeCoreSearchWorkerSnapshot(backend); - return Effect.gen(function* readCurrentOwnerSnapshot() { - const result = yield* snapshot.read(attestOutboxWorkerHandlerContext(context), (view) => - Effect.gen(function* readOwnerProjection() { - expect(view.projectionVersion).toBe('42'); - expect(view.eventWatermark).toBe('100'); - expect(view.tenantId).toBe(tenantId); - expect(view.legalEntityIds).toEqual([legalEntityId]); - const party = yield* view.tenant(readParty); - const counterparty = yield* view.forLegalEntity(legalEntityId, readCounterparty); - return { counterparty, party, projectionVersion: view.projectionVersion }; - }), - ); - expect(result).toEqual({ - counterparty: 'counterparty', - party: 'party', - projectionVersion: '42', - }); - expect(installedScopes).toEqual([undefined, undefined, legalEntityId, undefined]); +it.effect('worker snapshot exposes select-only owner reads at one current watermark and restores scope', () => { + const installedScopes: (string | undefined)[] = []; + const backend: CoreSearchSnapshotBackend = { + run: (_context, readSnapshot) => + readSnapshot( + { + eventWatermark: '100', + legalEntityIds: [legalEntityId], + projectionVersion: '42', + tenantId, + }, + executor, + (scope) => { + installedScopes.push(scope); + return Effect.void; + }, + ), + }; + const snapshot = makeCoreSearchWorkerSnapshot(backend); + return Effect.gen(function* readCurrentOwnerSnapshot() { + const result = yield* snapshot.read(attestOutboxWorkerHandlerContext(context), (view) => + Effect.gen(function* readOwnerProjection() { + expect(view.projectionVersion).toBe('42'); + expect(view.eventWatermark).toBe('100'); + expect(view.tenantId).toBe(tenantId); + expect(view.legalEntityIds).toEqual([legalEntityId]); + const party = yield* view.tenant(readParty); + const counterparty = yield* view.forLegalEntity(legalEntityId, readCounterparty); + return { + counterparty, + party, + projectionVersion: view.projectionVersion, + }; + }), + ); + expect(result).toEqual({ + counterparty: 'counterparty', + party: 'party', + projectionVersion: '42', }); - }, -); + expect(installedScopes).toEqual([undefined, undefined, legalEntityId, undefined]); + }); +}); -it.effect( - 'worker snapshot rejects a Legal Entity outside its tenant enumeration and preserves owner failures', - () => { - const installedScopes: (string | undefined)[] = []; - const backend: CoreSearchSnapshotBackend = { - run: (_context, readSnapshot) => - readSnapshot( - { - eventWatermark: '100', - legalEntityIds: [legalEntityId], - projectionVersion: '42', - tenantId, - }, - executor, - (scope) => { - installedScopes.push(scope); - return Effect.void; - }, - ), - }; - const snapshot = makeCoreSearchWorkerSnapshot(backend); - const verified = attestOutboxWorkerHandlerContext(context); - return Effect.gen(function* rejectInvalidAndPreserveOwnerFailure() { - const invalidScope = yield* Effect.flip( - snapshot.read(verified, (view) => - view.forLegalEntity('20000000-0000-4000-8000-000000000002', () => Effect.succeed('no')), - ), - ); - expect(Predicate.isTagged(invalidScope, 'CoreSearchProjectionInvalid')).toBe(true); - expect(installedScopes).toEqual([]); - const failure = yield* Effect.flip( - snapshot.read(verified, (view) => - view.forLegalEntity(legalEntityId, () => Effect.fail('owner-unavailable')), - ), - ); - expect(failure).toBe('owner-unavailable'); - expect(installedScopes).toEqual([legalEntityId, undefined]); - }); - }, -); +it.effect('worker snapshot rejects a Legal Entity outside its tenant enumeration and preserves owner failures', () => { + const installedScopes: (string | undefined)[] = []; + const backend: CoreSearchSnapshotBackend = { + run: (_context, readSnapshot) => + readSnapshot( + { + eventWatermark: '100', + legalEntityIds: [legalEntityId], + projectionVersion: '42', + tenantId, + }, + executor, + (scope) => { + installedScopes.push(scope); + return Effect.void; + }, + ), + }; + const snapshot = makeCoreSearchWorkerSnapshot(backend); + const verified = attestOutboxWorkerHandlerContext(context); + return Effect.gen(function* rejectInvalidAndPreserveOwnerFailure() { + const invalidScope = yield* Effect.flip( + snapshot.read(verified, (view) => + view.forLegalEntity('20000000-0000-4000-8000-000000000002', () => Effect.succeed('no')), + ), + ); + expect(Predicate.isTagged(invalidScope, 'CoreSearchProjectionInvalid')).toBe(true); + expect(installedScopes).toEqual([]); + const failure = yield* Effect.flip( + snapshot.read(verified, (view) => view.forLegalEntity(legalEntityId, () => Effect.fail('owner-unavailable'))), + ); + expect(failure).toBe('owner-unavailable'); + expect(installedScopes).toEqual([legalEntityId, undefined]); + }); +}); it.effect('worker snapshot maps persistence failure to a sanitized unavailable error', () => { const snapshot = makeCoreSearchWorkerSnapshot({ @@ -220,53 +211,55 @@ it.effect('worker snapshot maps persistence failure to a sanitized unavailable e }); }); -it.effect( - 'snapshot generation retries serialization conflicts only and bounds repeated contention', - () => { - let attempts = 0; - return Effect.gen(function* retrySerializationFailures() { - const snapshot = yield* Effect.suspend(() => { - attempts += 1; - return attempts < 3 - ? Effect.fail( - new SnapshotRetryFailure({ - cause: { code: '40001' }, - message: 'wrapped serialization', - }), - ) - : Effect.succeed('fresh snapshot'); - }).pipe(retryCoreSearchSnapshot); - expect(snapshot).toBe('fresh snapshot'); - expect(attempts).toBe(3); +it.effect('snapshot generation retries serialization conflicts only and bounds repeated contention', () => { + let attempts = 0; + return Effect.gen(function* retrySerializationFailures() { + const snapshot = yield* Effect.suspend(() => { + attempts += 1; + return attempts < 3 + ? Effect.fail( + new SnapshotRetryFailure({ + cause: { code: '40001' }, + message: 'wrapped serialization', + }), + ) + : Effect.succeed('fresh snapshot'); + }).pipe(retryCoreSearchSnapshot); + expect(snapshot).toBe('fresh snapshot'); + expect(attempts).toBe(3); - attempts = 0; - const contention = yield* Effect.flip( - Effect.suspend(() => { - attempts += 1; - return Effect.fail(new SnapshotRetryFailure({ code: '40001', message: 'contention' })); - }).pipe(retryCoreSearchSnapshot), - ); - expect(contention.message).toMatch(/contention/u); - expect(attempts).toBe(4); + attempts = 0; + const contention = yield* Effect.flip( + Effect.suspend(() => { + attempts += 1; + return Effect.fail(new SnapshotRetryFailure({ code: '40001', message: 'contention' })); + }).pipe(retryCoreSearchSnapshot), + ); + expect(contention.message).toMatch(/contention/u); + expect(attempts).toBe(4); - attempts = 0; - const nonSerialization = yield* Effect.flip( - Effect.suspend(() => { - attempts += 1; - return Effect.fail(new SnapshotRetryFailure({ message: 'not serialization' })); - }).pipe(retryCoreSearchSnapshot), - ); - expect(nonSerialization.message).toMatch(/not serialization/u); - expect(attempts).toBe(1); - }); - }, -); + attempts = 0; + const nonSerialization = yield* Effect.flip( + Effect.suspend(() => { + attempts += 1; + return Effect.fail(new SnapshotRetryFailure({ message: 'not serialization' })); + }).pipe(retryCoreSearchSnapshot), + ); + expect(nonSerialization.message).toMatch(/not serialization/u); + expect(attempts).toBe(1); + }); +}); it.effect('snapshot revokes escaped scope capabilities when the owner callback finishes', () => { const reader = makeCoreSearchWorkerSnapshot({ run: (_context, readSnapshot) => readSnapshot( - { eventWatermark: '3', legalEntityIds: [legalEntityId], projectionVersion: '1', tenantId }, + { + eventWatermark: '3', + legalEntityIds: [legalEntityId], + projectionVersion: '1', + tenantId, + }, executor, () => Effect.void, ), @@ -285,7 +278,12 @@ it.effect('nested scope rejection does not unlock the active owner read', () => const reader = makeCoreSearchWorkerSnapshot({ run: (_context, readSnapshot) => readSnapshot( - { eventWatermark: '3', legalEntityIds: [legalEntityId], projectionVersion: '1', tenantId }, + { + eventWatermark: '3', + legalEntityIds: [legalEntityId], + projectionVersion: '1', + tenantId, + }, executor, () => Effect.void, ), diff --git a/app/packages/core-runtime/tests/unit/service-public-surface.test.ts b/app/packages/core-runtime/tests/unit/service-public-surface.test.ts index 24dcda527..a2252e303 100644 --- a/app/packages/core-runtime/tests/unit/service-public-surface.test.ts +++ b/app/packages/core-runtime/tests/unit/service-public-surface.test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import type { ContextAccessService, InstalledModuleCatalogServiceContract, @@ -22,9 +23,7 @@ type PublicServiceContract = | SupportRecoveryPrincipalContextResolverService | TenantModuleStateServiceContract; -const preservePublicServiceContract = ( - service: Service, -): Service => service; +const preservePublicServiceContract = (service: Service): Service => service; it('exports the anti-slop-compliant Core service contracts', () => { expect(preservePublicServiceContract.length).toBe(1); diff --git a/app/packages/core-runtime/tests/unit/shell-contribution.test.ts b/app/packages/core-runtime/tests/unit/shell-contribution.test.ts index 5e9f5db2a..b67c1df61 100644 --- a/app/packages/core-runtime/tests/unit/shell-contribution.test.ts +++ b/app/packages/core-runtime/tests/unit/shell-contribution.test.ts @@ -1,5 +1,6 @@ import { Schema } from 'effect'; import { expect, it } from 'effect-rstest'; + import { validateShellContributions } from '../../src/modules/shell-contribution.ts'; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Any)); @@ -14,7 +15,10 @@ const first = (values: readonly Value[]): Value => { }; const entrypoint = (role: 'api' | 'page' | 'public_component' | 'report' | 'search') => ({ access: 'read' as const, - authorization: { kind: 'context_permission' as const, permission: 'module.access' }, + authorization: { + kind: 'context_permission' as const, + permission: 'module.access', + }, entrypointKey: `${moduleId}.${role.replace('_', '-')}.primary`, moduleKey: moduleId, role, @@ -136,11 +140,17 @@ it('rejects extra keys, duplicates, cross-owner entrypoints, and missing referen const crossOwner = full(); crossOwner.pages[0] = { ...first(crossOwner.pages), - entrypoint: { ...first(crossOwner.pages).entrypoint, moduleKey: 'billing.core' }, + entrypoint: { + ...first(crossOwner.pages).entrypoint, + moduleKey: 'billing.core', + }, }; expect(() => validateShellContributions(crossOwner, references)).toThrow(/owner/u); expect(() => - validateShellContributions(full(), { ...references, componentKeys: new Set() }), + validateShellContributions(full(), { + ...references, + componentKeys: new Set(), + }), ).toThrow(); }); @@ -162,7 +172,10 @@ it('rejects incompatible entrypoint roles and arbitrary transport metadata', () pages: [ { ...first(baseline.pages), - entrypoint: { ...first(baseline.pages).entrypoint, access: 'write' }, + entrypoint: { + ...first(baseline.pages).entrypoint, + access: 'write', + }, }, ], }, diff --git a/app/packages/core-runtime/tests/unit/spicedb-client.test.ts b/app/packages/core-runtime/tests/unit/spicedb-client.test.ts index f07a2dc87..1e37727b0 100644 --- a/app/packages/core-runtime/tests/unit/spicedb-client.test.ts +++ b/app/packages/core-runtime/tests/unit/spicedb-client.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { v1 } from '@authzed/authzed-node'; +import { expect, it } from 'effect-rstest'; + import { spiceDbClientSecurity } from '../../src/permissions/client.ts'; import { SpiceDbConfigError } from '../../src/permissions/config-error.ts'; diff --git a/app/packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts b/app/packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts index 3a0e6c670..a26f7b87d 100644 --- a/app/packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts +++ b/app/packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts @@ -1,7 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Reads repository fixture files through the Node promise API; expires: 2026-12-31. -import { Effect } from 'effect'; +import { fileURLToPath } from 'node:url'; + +import { NodeFileSystem } from '@effect/platform-node'; +import { Effect, FileSystem } from 'effect'; import { expect, it } from 'effect-rstest'; -import { readFile } from 'node:fs/promises'; + import { parseSpiceDbDatabaseBootstrapConfig } from '../../src/install/spicedb-database-config.ts'; import { toModuleAccessObjectId } from '../../src/permissions/context-access.ts'; import { ONTOS_SPICEDB_SCHEMA } from '../../src/permissions/schema.ts'; @@ -10,9 +12,7 @@ const extractSchema = (source: string): string => source .slice( 'schema: |-\n'.length, - source.includes('\nrelationships: |-') - ? source.indexOf('\nrelationships: |-') - : source.length, + source.includes('\nrelationships: |-') ? source.indexOf('\nrelationships: |-') : source.length, ) .trimEnd() .split('\n') @@ -53,65 +53,67 @@ it('rejects unsafe SpiceDB database bootstrap targets', () => { } }); -it.effect('keeps the stage bootstrap schema aligned without development relationships', () => - Effect.gen(function* testScenario1() { - const development = yield* Effect.promise(() => - readFile(new URL('../../spicedb/bootstrap.yaml', import.meta.url), 'utf-8'), - ); - const stage = yield* Effect.promise(() => - readFile(new URL('../../spicedb/stage-bootstrap.yaml', import.meta.url), 'utf-8'), - ); - expect(extractSchema(development)).toBe(ONTOS_SPICEDB_SCHEMA); - expect(extractSchema(stage)).toBe(ONTOS_SPICEDB_SCHEMA); - expect(stage).not.toMatch(/relationships:|assertions:/u); - expect(development).toMatch(/#executor@tenant:test-tenant#member/u); - expect(development).toMatch(/#executor@principal:allowed-principal/u); - }), -); +it.layer(NodeFileSystem.layer)('SpiceDB bootstrap sources', (suite) => { + suite.effect('keeps the stage bootstrap schema aligned without development relationships', () => + Effect.gen(function* testScenario1() { + const fs = yield* FileSystem.FileSystem; + const development = yield* fs.readFileString( + fileURLToPath(new URL('../../spicedb/bootstrap.yaml', import.meta.url)), + ); + const stage = yield* fs.readFileString( + fileURLToPath(new URL('../../spicedb/stage-bootstrap.yaml', import.meta.url)), + ); + expect(extractSchema(development)).toBe(ONTOS_SPICEDB_SCHEMA); + expect(extractSchema(stage)).toBe(ONTOS_SPICEDB_SCHEMA); + expect(stage).not.toMatch(/relationships:|assertions:/u); + expect(development).toMatch(/#executor@tenant:test-tenant#member/u); + expect(development).toMatch(/#executor@principal:allowed-principal/u); + }), + ); -it.effect('grants fresh development module access only to Contacts', () => - Effect.gen(function* testScenario2() { - const development = yield* Effect.promise(() => - readFile(new URL('../../spicedb/bootstrap.yaml', import.meta.url), 'utf-8'), - ); - const tenantId = '50000000-0000-4000-8000-000000000001'; - const legalEntityId = '55000000-0000-4000-8000-000000000001'; - const contactsObjectId = toModuleAccessObjectId(tenantId, legalEntityId, 'contacts.core'); - expect(contactsObjectId !== undefined && contactsObjectId.length > 0).toBe(true); - expect( - development.match( - /^ {2}module_access:\S+#accessor@principal:60000000-0000-4000-8000-000000000001$/gmu, - ), - ).toEqual([ - ` module_access:${contactsObjectId}#accessor@principal:60000000-0000-4000-8000-000000000001`, - ]); - }), -); + suite.effect('grants fresh development module access only to Contacts', () => + Effect.gen(function* testScenario2() { + const fs = yield* FileSystem.FileSystem; + const development = yield* fs.readFileString( + fileURLToPath(new URL('../../spicedb/bootstrap.yaml', import.meta.url)), + ); + const tenantId = '50000000-0000-4000-8000-000000000001'; + const legalEntityId = '55000000-0000-4000-8000-000000000001'; + const contactsObjectId = toModuleAccessObjectId(tenantId, legalEntityId, 'contacts.core'); + expect(contactsObjectId !== undefined && contactsObjectId.length > 0).toBe(true); + expect( + development.match(/^ {2}module_access:\S+#accessor@principal:60000000-0000-4000-8000-000000000001$/gmu), + ).toEqual([` module_access:${contactsObjectId}#accessor@principal:60000000-0000-4000-8000-000000000001`]); + }), + ); -it.effect('declares the complete Party tenant permission vocabulary', () => - Effect.gen(function* testScenario3() { - const development = yield* Effect.promise(() => - readFile(new URL('../../spicedb/bootstrap.yaml', import.meta.url), 'utf-8'), - ); - for (const permission of [ - 'manage_party_identity', - 'manage_party_relationships', - 'merge_party_identity', - 'read_party_identity', - 'review_party_identity', - ]) { - expect(development).toMatch(new RegExp(`permission ${permission} =`, 'u')); - } - }), -); + suite.effect('declares the complete Party tenant permission vocabulary', () => + Effect.gen(function* testScenario3() { + const fs = yield* FileSystem.FileSystem; + const development = yield* fs.readFileString( + fileURLToPath(new URL('../../spicedb/bootstrap.yaml', import.meta.url)), + ); + for (const permission of [ + 'manage_party_identity', + 'manage_party_relationships', + 'merge_party_identity', + 'read_party_identity', + 'review_party_identity', + ]) { + expect(development).toMatch(new RegExp(`permission ${permission} =`, 'u')); + } + }), + ); -it.effect('declares the Counterparty Legal Entity permission vocabulary', () => - Effect.gen(function* testScenario4() { - const development = yield* Effect.promise(() => - readFile(new URL('../../spicedb/bootstrap.yaml', import.meta.url), 'utf-8'), - ); - for (const permission of ['manage_counterparty', 'read_counterparty']) { - expect(development).toMatch(new RegExp(`permission ${permission} =`, 'u')); - } - }), -); + suite.effect('declares the Counterparty Legal Entity permission vocabulary', () => + Effect.gen(function* testScenario4() { + const fs = yield* FileSystem.FileSystem; + const development = yield* fs.readFileString( + fileURLToPath(new URL('../../spicedb/bootstrap.yaml', import.meta.url)), + ); + for (const permission of ['manage_counterparty', 'read_counterparty']) { + expect(development).toMatch(new RegExp(`permission ${permission} =`, 'u')); + } + }), + ); +}); diff --git a/app/packages/core-runtime/tests/unit/stage-context-bootstrap.test.ts b/app/packages/core-runtime/tests/unit/stage-context-bootstrap.test.ts index b76aaf6c3..e2bcfeaa9 100644 --- a/app/packages/core-runtime/tests/unit/stage-context-bootstrap.test.ts +++ b/app/packages/core-runtime/tests/unit/stage-context-bootstrap.test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import { STAGE_CONTEXTS } from '../../src/install/stage-context-bootstrap.ts'; it('defines the exact Techsio and Siampark stage contexts', () => { diff --git a/app/packages/core-runtime/tests/unit/system-principal-context.test.ts b/app/packages/core-runtime/tests/unit/system-principal-context.test.ts index 4f0ed0cee..1d917af72 100644 --- a/app/packages/core-runtime/tests/unit/system-principal-context.test.ts +++ b/app/packages/core-runtime/tests/unit/system-principal-context.test.ts @@ -1,5 +1,6 @@ +import { Effect, Schema, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Option, Schema, Predicate } from 'effect'; + import { TrustedPrincipalContextSchema } from '../../src/actions/principal-context.ts'; import { decodeTrustedPrincipalContext } from '../../src/auth/system-principal-context-provenance.ts'; import { @@ -16,12 +17,14 @@ const resolverFor = (record: { readonly tenantStatus: 'active' | 'suspended'; }) => systemPrincipalContextResolverFromRepository({ - load: () => Effect.succeed(Option.some(record)), + load: () => Effect.succeedSome(record), }); it.effect('constructs one immutable trusted system context from a branded registration', () => Effect.gen(function* testScenario1() { - const registration = registerSystemWorkload({ jobKey: 'inventory-reconcile' }); + const registration = registerSystemWorkload({ + jobKey: 'inventory-reconcile', + }); const context = yield* resolverFor({ kind: 'system', principalStatus: 'active', @@ -41,9 +44,7 @@ it.effect('constructs one immutable trusted system context from a branded regist principalId, tenantId, }); - expect(yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)(context)).toEqual( - context, - ); + expect(yield* Schema.decodeEffect(TrustedPrincipalContextSchema)(context)).toEqual(context); expect(yield* decodeTrustedPrincipalContext(context)).toEqual(context); expect(yield* Effect.flip(decodeTrustedPrincipalContext({ ...context }))).toBeDefined(); }), @@ -51,10 +52,16 @@ it.effect('constructs one immutable trusted system context from a branded regist it.effect('rejects forged registrations, unsafe refs, wrong kinds, and inactive state', () => Effect.gen(function* testScenario2() { - const registration = registerSystemWorkload({ jobKey: 'inventory-reconcile' }); + const registration = registerSystemWorkload({ + jobKey: 'inventory-reconcile', + }); const forged = { ...registration }; const invalid = yield* Effect.flip( - resolverFor({ kind: 'system', principalStatus: 'active', tenantStatus: 'active' }).resolve({ + resolverFor({ + kind: 'system', + principalStatus: 'active', + tenantStatus: 'active', + }).resolve({ principalId, registration: forged, runReference: 'run-42', @@ -62,7 +69,11 @@ it.effect('rejects forged registrations, unsafe refs, wrong kinds, and inactive }), ); const wrongKind = yield* Effect.flip( - resolverFor({ kind: 'human', principalStatus: 'active', tenantStatus: 'active' }).resolve({ + resolverFor({ + kind: 'human', + principalStatus: 'active', + tenantStatus: 'active', + }).resolve({ principalId, registration, runReference: 'run-42', @@ -70,7 +81,11 @@ it.effect('rejects forged registrations, unsafe refs, wrong kinds, and inactive }), ); const inactive = yield* Effect.flip( - resolverFor({ kind: 'system', principalStatus: 'disabled', tenantStatus: 'active' }).resolve({ + resolverFor({ + kind: 'system', + principalStatus: 'disabled', + tenantStatus: 'active', + }).resolve({ principalId, registration, runReference: 'run-42', @@ -88,7 +103,11 @@ it.effect('rejects forged registrations, unsafe refs, wrong kinds, and inactive it.effect('permits service principals only when the trusted registration opts in', () => Effect.gen(function* testScenario3() { const denied = yield* Effect.flip( - resolverFor({ kind: 'service', principalStatus: 'active', tenantStatus: 'active' }).resolve({ + resolverFor({ + kind: 'service', + principalStatus: 'active', + tenantStatus: 'active', + }).resolve({ principalId, registration: registerSystemWorkload({ jobKey: 'service-job' }), runReference: 'run-1', @@ -101,7 +120,10 @@ it.effect('permits service principals only when the trusted registration opts in tenantStatus: 'active', }).resolve({ principalId, - registration: registerSystemWorkload({ allowServicePrincipal: true, jobKey: 'service-job' }), + registration: registerSystemWorkload({ + allowServicePrincipal: true, + jobKey: 'service-job', + }), runReference: 'run-1', tenantId, }); @@ -142,7 +164,7 @@ it('enforces mode-specific trusted context cross-field invariants', () => { expect(() => Schema.decodeUnknownSync(TrustedPrincipalContextSchema)(context)).not.toThrow(); } expect(() => - Schema.decodeUnknownSync(TrustedPrincipalContextSchema)({ + Schema.decodeSync(TrustedPrincipalContextSchema)({ authContextRef: 'better-auth-api-key:key-id', authMethod: 'api_key', principalId, @@ -150,7 +172,7 @@ it('enforces mode-specific trusted context cross-field invariants', () => { }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(TrustedPrincipalContextSchema)({ + Schema.decodeSync(TrustedPrincipalContextSchema)({ authBindingId: binding, authContextRef: 'better-auth-session:nested', authMethod: 'support_impersonation', diff --git a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts index 6a0be7051..e695d629f 100644 --- a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts @@ -1,10 +1,9 @@ -import { makeInstalledCatalogFixture as catalog } from '../support/installed-catalog.ts'; -import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; +import { Effect, Schema, Predicate } from 'effect'; // @effect-diagnostics preferSchemaOverJson:off -- Verifies native JSON serialization of errors and schema AST metadata; expires: 2026-12-31. import { expect, it } from 'effect-rstest'; -import { Effect, Schema, Predicate } from 'effect'; -import { changeTenantModuleStateAction } from '../../src/modules/actions/change-tenant-module-state.action.ts'; + import type { OntosModuleDeploymentContract } from '../../src/index.ts'; +import { changeTenantModuleStateAction } from '../../src/modules/actions/change-tenant-module-state.action.ts'; import { TenantModuleStateConcurrentChangeError, TenantModuleStatePersistenceUnavailableError, @@ -23,6 +22,8 @@ import { resolveTenantModuleStateChangeSource, validateTenantModuleStateTransition, } from '../../src/modules/tenant-module-state-service.ts'; +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; +import { makeInstalledCatalogFixture as catalog } from '../support/installed-catalog.ts'; const contract = ( moduleId: string, @@ -40,13 +41,11 @@ const contract = ( it.effect('uses one canonical tenant module state schema', () => Effect.gen(function* testScenario1() { const decodedStates = yield* Effect.forEach((state: (typeof TENANT_MODULE_STATES)[number]) => - Schema.decodeUnknownEffect(TenantModuleStateSchema)(state), + Schema.decodeEffect(TenantModuleStateSchema)(state), )(TENANT_MODULE_STATES); expect(decodedStates).toEqual(TENANT_MODULE_STATES); - const failure = yield* Effect.flip( - Schema.decodeUnknownEffect(TenantModuleStateSchema)('enabled'), - ); + const failure = yield* Effect.flip(Schema.decodeUnknownEffect(TenantModuleStateSchema)('enabled')); expect(Predicate.isTagged(failure, 'SchemaError')).toBe(true); }), ); @@ -58,9 +57,7 @@ it.effect('maps only trusted supported authentication methods to history sources expect(yield* resolveTenantModuleStateChangeSource('system')).toBe('system'); const unsupported = yield* Effect.flip(resolveTenantModuleStateChangeSource('api_key')); - expect(Predicate.isTagged(unsupported, 'TenantModuleStateUnsupportedChangeSourceError')).toBe( - true, - ); + expect(Predicate.isTagged(unsupported, 'TenantModuleStateUnsupportedChangeSourceError')).toBe(true); expect(unsupported.code).toBe('tenant_module_state_change_source_unsupported'); }), ); @@ -128,9 +125,7 @@ it.effect('validates only installed membership and the target module supported s const target = contract('property.registry', ['inactive', 'active', 'read_only']); const installed = catalog(other, target); - const unknown = yield* Effect.flip( - validateTenantModuleStateTransition(installed, 'unknown.module', 'active'), - ); + const unknown = yield* Effect.flip(validateTenantModuleStateTransition(installed, 'unknown.module', 'active')); expect(Predicate.isTagged(unknown, 'TenantModuleStateUnknownModuleError')).toBe(true); const unsupported = yield* Effect.flip( validateTenantModuleStateTransition(installed, 'property.registry', 'archived'), @@ -153,7 +148,7 @@ it.effect('declares the generated Core Action contract and bounded business payl expect(JSON.stringify(descriptor.domainErrorSchema.ast)).not.toMatch(/dependency/iu); expect( - yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)({ + yield* Schema.decodeEffect(descriptor.payloadSchema)({ expectedState: 'inactive', moduleKey: 'testing.module', newState: 'active', @@ -167,7 +162,7 @@ it.effect('declares the generated Core Action contract and bounded business payl }); expect( yield* Effect.flip( - Schema.decodeUnknownEffect(descriptor.payloadSchema)({ + Schema.decodeEffect(descriptor.payloadSchema)({ moduleKey: 'testing.module', newState: 'active', reason: 'x'.repeat(501), diff --git a/app/packages/gateway-principal-verifier/package.json b/app/packages/gateway-principal-verifier/package.json index 68e0dab99..1e3966ab5 100644 --- a/app/packages/gateway-principal-verifier/package.json +++ b/app/packages/gateway-principal-verifier/package.json @@ -14,11 +14,11 @@ "dependencies": { "@app/core-runtime": "workspace:*", "@app/shared-contracts": "workspace:*", - "effect": "4.0.0-beta.107", + "effect": "4.0.0-rc.112", "jose": "6.2.5" }, "devDependencies": { - "@types/node": "20.19.43", + "@types/node": "^26.4.1", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc", "@rstest/core": "0.11.11" } diff --git a/app/packages/gateway-principal-verifier/rstest.config.ts b/app/packages/gateway-principal-verifier/rstest.config.ts index 324494aa4..61c8cd901 100644 --- a/app/packages/gateway-principal-verifier/rstest.config.ts +++ b/app/packages/gateway-principal-verifier/rstest.config.ts @@ -1,5 +1,11 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ - projects: [{ include: ['tests/unit/**/*.test.ts'], name: 'unit', testEnvironment: 'node' }], + projects: [ + { + include: ['tests/unit/**/*.test.ts'], + name: 'unit', + testEnvironment: 'node', + }, + ], }); diff --git a/app/packages/gateway-principal-verifier/src/server.ts b/app/packages/gateway-principal-verifier/src/server.ts index 3017a7bd6..a04d53e2f 100644 --- a/app/packages/gateway-principal-verifier/src/server.ts +++ b/app/packages/gateway-principal-verifier/src/server.ts @@ -27,25 +27,13 @@ import type { LocalJWKSet } from 'jose'; export const ACTION_PRINCIPAL_BEARER_CHALLENGE = 'Bearer' as const; const errorFields = { reason: Schema.String }; -export const ActionPrincipalMissingErrorSchema = Schema.TaggedStruct( - 'ActionPrincipalMissingError', - errorFields, -); +export const ActionPrincipalMissingErrorSchema = Schema.TaggedStruct('ActionPrincipalMissingError', errorFields); export type ActionPrincipalMissingError = typeof ActionPrincipalMissingErrorSchema.Type; -export const ActionPrincipalInvalidErrorSchema = Schema.TaggedStruct( - 'ActionPrincipalInvalidError', - errorFields, -); +export const ActionPrincipalInvalidErrorSchema = Schema.TaggedStruct('ActionPrincipalInvalidError', errorFields); export type ActionPrincipalInvalidError = typeof ActionPrincipalInvalidErrorSchema.Type; -export const ActionPrincipalExpiredErrorSchema = Schema.TaggedStruct( - 'ActionPrincipalExpiredError', - errorFields, -); +export const ActionPrincipalExpiredErrorSchema = Schema.TaggedStruct('ActionPrincipalExpiredError', errorFields); export type ActionPrincipalExpiredError = typeof ActionPrincipalExpiredErrorSchema.Type; -export const ActionPrincipalScopeErrorSchema = Schema.TaggedStruct( - 'ActionPrincipalScopeError', - errorFields, -); +export const ActionPrincipalScopeErrorSchema = Schema.TaggedStruct('ActionPrincipalScopeError', errorFields); export type ActionPrincipalScopeError = typeof ActionPrincipalScopeErrorSchema.Type; export const ActionPrincipalConfigurationErrorSchema = Schema.TaggedStruct( 'ActionPrincipalConfigurationError', @@ -93,7 +81,9 @@ const configurationError = (): ActionPrincipalConfigurationError => reason: 'Action identity verification is misconfigured', }); const invalidError = (): ActionPrincipalInvalidError => - ActionPrincipalInvalidErrorSchema.make({ reason: 'The Bearer assertion is invalid' }); + ActionPrincipalInvalidErrorSchema.make({ + reason: 'The Bearer assertion is invalid', + }); const unavailableError = (): ActionPrincipalUnavailableError => ActionPrincipalUnavailableErrorSchema.make({ reason: 'Action identity verification is unavailable', @@ -124,9 +114,7 @@ const PublicVerificationKeysSchema = Schema.Struct({ const VerificationEnvironmentSchema = Schema.Struct({ ONTOS_GATEWAY_ISSUER: Schema.String.check( Schema.isPattern(/^https?:\/\//u), - Schema.makeFilter((value) => - URL.canParse(value) ? undefined : 'An absolute HTTP issuer is required', - ), + Schema.makeFilter((value) => (URL.canParse(value) ? undefined : 'An absolute HTTP issuer is required')), ), ONTOS_GATEWAY_PUBLIC_JWKS: Schema.fromJsonString(PublicVerificationKeysSchema), }); @@ -143,10 +131,7 @@ interface VerificationConfiguration { } interface GatewayPrincipalVerifierService { - readonly configuration: Effect.Effect< - VerificationConfiguration, - ActionPrincipalConfigurationError - >; + readonly configuration: Effect.Effect; } export class GatewayPrincipalVerifierConfiguration extends Context.Service< @@ -158,9 +143,7 @@ const loadGatewayPrincipalVerificationConfiguration = ( provider?: ConfigProvider.ConfigProvider, ): Effect.Effect => { const configuration = - provider === undefined - ? gatewayVerificationEnvironment - : gatewayVerificationEnvironment.parse(provider); + provider === undefined ? gatewayVerificationEnvironment : gatewayVerificationEnvironment.parse(provider); return configuration.pipe( Effect.flatMap(Schema.decodeUnknownEffect(VerificationEnvironmentSchema)), Effect.flatMap(({ ONTOS_GATEWAY_ISSUER: issuer, ONTOS_GATEWAY_PUBLIC_JWKS: jwks }) => { @@ -199,7 +182,9 @@ export const makeGatewayPrincipalVerifierLayer = ( Layer.effect( GatewayPrincipalVerifierConfiguration, Effect.cached(loadGatewayPrincipalVerificationConfiguration(provider)).pipe( - Effect.map((configuration): GatewayPrincipalVerifierService => ({ configuration })), + Effect.map((configuration): GatewayPrincipalVerifierService => ({ + configuration, + })), ), ); @@ -242,7 +227,9 @@ const readBearer = ( const authorizationValue = Redacted.value(authorization); if (authorizationValue === undefined) { return Effect.fail( - ActionPrincipalMissingErrorSchema.make({ reason: 'A Bearer assertion is required' }), + ActionPrincipalMissingErrorSchema.make({ + reason: 'A Bearer assertion is required', + }), ); } const token = /^Bearer (?[^\s]+)$/iu.exec(authorizationValue)?.groups?.['token']; @@ -261,14 +248,8 @@ const verifyAuthenticatedToken = Effect.fn('GatewayPrincipalVerifier.verifyAuthe expectedAudience: string, token: string, options: GatewayPrincipalVerificationOptions, - ): Effect.fn.Return< - VerifiedGatewayPrincipal, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - > { - const audience = yield* Schema.decodeUnknownEffect(GatewayAudienceSchema)( - expectedAudience, - ).pipe( + ): Effect.fn.Return { + const audience = yield* Schema.decodeEffect(GatewayAudienceSchema)(expectedAudience).pipe( // oxlint-disable-next-line effect-native/no-failure-discarding-error-callback -- Schema diagnostics are deliberately sanitized at the trust boundary; remove-when: the rule supports security-boundary sanitizers. Effect.mapError(() => configurationError()), ); @@ -315,13 +296,10 @@ const verifyAuthenticatedToken = Effect.fn('GatewayPrincipalVerifier.verifyAuthe // oxlint-disable-next-line effect-native/no-failure-discarding-error-callback -- Claim diagnostics are deliberately sanitized at the trust boundary; remove-when: the rule supports security-boundary sanitizers. Effect.mapError(() => invalidError()), ); - if ( - claims.ver !== GATEWAY_ASSERTION_VERSION || - claims.iat > now + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS - ) { + if (claims.ver !== GATEWAY_ASSERTION_VERSION || claims.iat > now + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS) { return yield* Effect.fail(invalidError()); } - const principal = yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema, { + const principal = yield* Schema.decodeEffect(TrustedPrincipalContextSchema, { onExcessProperty: 'error', })(claims.principal).pipe( // oxlint-disable-next-line effect-native/no-failure-discarding-error-callback -- Principal decode diagnostics are deliberately sanitized at the trust boundary; remove-when: the rule supports security-boundary sanitizers. @@ -336,9 +314,7 @@ const verifyAuthenticatedToken = Effect.fn('GatewayPrincipalVerifier.verifyAuthe }, ); -export const bindGatewayPrincipalVerifier = ( - expectedAudience: Audience, -) => { +export const bindGatewayPrincipalVerifier = (expectedAudience: Audience) => { function verifyPrincipal( authorization: Redacted.Redacted, options: GatewayPrincipalVerificationEnvironmentOptions, @@ -346,19 +322,11 @@ export const bindGatewayPrincipalVerifier = ( function verifyPrincipal( authorization: Redacted.Redacted, options?: GatewayPrincipalVerificationOptions, - ): Effect.Effect< - VerifiedGatewayPrincipal, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - >; + ): Effect.Effect; function verifyPrincipal( authorization: Redacted.Redacted, options: GatewayPrincipalVerificationOptions = {}, - ): Effect.Effect< - VerifiedGatewayPrincipal, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - > { + ): Effect.Effect { const verification = readBearer(authorization).pipe( Effect.flatMap((token) => verifyAuthenticatedToken(expectedAudience, token, options)), ); @@ -381,19 +349,11 @@ export const bindGatewayPrincipalVerifier = ( function verify( authorization: Redacted.Redacted, options?: GatewayPrincipalVerificationOptions, - ): Effect.Effect< - TrustedPrincipalContext, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - >; + ): Effect.Effect; function verify( authorization: Redacted.Redacted, options: GatewayPrincipalVerificationOptions = {}, - ): Effect.Effect< - TrustedPrincipalContext, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - > { + ): Effect.Effect { return verifyPrincipal(authorization, options).pipe(Effect.map(({ principal }) => principal)); } @@ -404,23 +364,20 @@ export const bindGatewayPrincipalVerifier = ( function verifyAndRedeem( authorization: Redacted.Redacted, options: GatewayPrincipalVerificationWithRedemptionOptions, - ): Effect.Effect< - TrustedPrincipalContext, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - >; + ): Effect.Effect; function verifyAndRedeem( authorization: Redacted.Redacted, options: GatewayPrincipalVerificationWithRedemptionOptions, - ): Effect.Effect< - TrustedPrincipalContext, - ActionPrincipalError, - GatewayPrincipalVerifierConfiguration - > { + ): Effect.Effect { return verifyPrincipal(authorization, options).pipe( Effect.tap(({ expiresAtEpochSeconds, issuer, jti }) => options.redemption - .consume({ audience: expectedAudience, expiresAtEpochSeconds, issuer, jti }) + .consume({ + audience: expectedAudience, + expiresAtEpochSeconds, + issuer, + jti, + }) .pipe( Effect.catchTags({ GatewayAssertionRedemptionUnavailableError: mapRedemptionUnavailable, diff --git a/app/packages/gateway-principal-verifier/tests/unit/gateway-principal-verifier.test.ts b/app/packages/gateway-principal-verifier/tests/unit/gateway-principal-verifier.test.ts index e2e8ec197..1a7f43290 100644 --- a/app/packages/gateway-principal-verifier/tests/unit/gateway-principal-verifier.test.ts +++ b/app/packages/gateway-principal-verifier/tests/unit/gateway-principal-verifier.test.ts @@ -1,11 +1,9 @@ -import { expect, it } from 'effect-rstest'; -import { - GatewayAssertionRedemptionUnavailableError, - GatewayAssertionReplayError, -} from '@app/core-runtime'; +import { GatewayAssertionRedemptionUnavailableError, GatewayAssertionReplayError } from '@app/core-runtime'; import { Effect, Redacted, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; import type { JWK, LocalJWKSet } from 'jose'; + import { ActionPrincipalConfigurationErrorSchema, ActionPrincipalInvalidErrorSchema, @@ -36,7 +34,11 @@ const makeFixture = (audience: string, version = 1) => }; const token = yield* Effect.promise(() => new SignJWT({ principal, ver: version }) - .setProtectedHeader({ alg: 'EdDSA', kid: 'shared-verifier-test', typ: 'JWT' }) + .setProtectedHeader({ + alg: 'EdDSA', + kid: 'shared-verifier-test', + typ: 'JWT', + }) .setIssuer(issuer) .setAudience(audience) .setSubject(principal.principalId) @@ -48,9 +50,7 @@ const makeFixture = (audience: string, version = 1) => return { environment: { ONTOS_GATEWAY_ISSUER: issuer, - ONTOS_GATEWAY_PUBLIC_JWKS: yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ + ONTOS_GATEWAY_PUBLIC_JWKS: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ keys: [publicJwk], }), }, @@ -63,10 +63,9 @@ const isConfigurationError = Schema.is(ActionPrincipalConfigurationErrorSchema); const isInvalidError = Schema.is(ActionPrincipalInvalidErrorSchema); const isScopeError = Schema.is(ActionPrincipalScopeErrorSchema); const isUnavailableError = Schema.is(ActionPrincipalUnavailableErrorSchema); -const failingKeySet = Object.assign( - () => Promise.reject(new Error('fixture verifier details must be discarded')), - { jwks: () => ({ keys: [] }) }, -) satisfies LocalJWKSet; +const failingKeySet = Object.assign(() => Promise.reject(new Error('fixture verifier details must be discarded')), { + jwks: () => ({ keys: [] }), +}) satisfies LocalJWKSet; it.effect('an audience-bound verifier accepts only its exact topology app ID', () => Effect.gen(function* verifyAudienceBinding() { @@ -80,9 +79,7 @@ it.effect('an audience-bound verifier accepts only its exact topology app ID', ( }); expect(yield* verify(partyFixture.token, partyFixture.environment)).toEqual(principal); - expect( - isScopeError(yield* Effect.flip(verify(billingFixture.token, billingFixture.environment))), - ).toBe(true); + expect(isScopeError(yield* Effect.flip(verify(billingFixture.token, billingFixture.environment)))).toBe(true); }), ); @@ -142,13 +139,10 @@ it.effect('empty and malformed audience bindings fail closed as configuration er (audience) => Effect.gen(function* checkMalformedBinding() { const failure = yield* Effect.flip( - bindGatewayPrincipalVerifier(audience).verify( - Redacted.make(`Bearer ${fixture.token}`), - { - currentTimeSeconds: Effect.succeed(currentTimeSeconds), - environment: fixture.environment, - }, - ), + bindGatewayPrincipalVerifier(audience).verify(Redacted.make(`Bearer ${fixture.token}`), { + currentTimeSeconds: Effect.succeed(currentTimeSeconds), + environment: fixture.environment, + }), ); expect(isConfigurationError(failure)).toBe(true); }), @@ -172,14 +166,16 @@ it.effect('redemption failures remain sanitized and distinguish replay from unav verify({ consume: () => Effect.fail( - new GatewayAssertionReplayError({ reason: 'fixture replay details must be discarded' }), + new GatewayAssertionReplayError({ + reason: 'fixture replay details must be discarded', + }), ), }), ); expect(isInvalidError(replayFailure)).toBe(true); - expect( - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(replayFailure), - ).not.toMatch(/fixture|eyJ/u); + expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(replayFailure)).not.toMatch( + /fixture|eyJ/u, + ); const unavailableFailure = yield* Effect.flip( verify({ consume: () => @@ -191,9 +187,9 @@ it.effect('redemption failures remain sanitized and distinguish replay from unav }), ); expect(isUnavailableError(unavailableFailure)).toBe(true); - expect( - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(unavailableFailure), - ).not.toMatch(/fixture|eyJ/u); + expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(unavailableFailure)).not.toMatch( + /fixture|eyJ/u, + ); }), ); @@ -222,9 +218,7 @@ it.effect('unsupported assertion versions and unexpected verifier failures fail ), ); expect(isUnavailableError(failure)).toBe(true); - expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(failure)).not.toMatch( - /fixture|eyJ/u, - ); + expect(yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(failure)).not.toMatch(/fixture|eyJ/u); }), ); diff --git a/app/packages/shared-contracts/package.json b/app/packages/shared-contracts/package.json index 020e6bcae..7ae13a53d 100644 --- a/app/packages/shared-contracts/package.json +++ b/app/packages/shared-contracts/package.json @@ -6,10 +6,11 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./microvertical-api-baseline": "./src/microvertical-api-baseline.ts", + "./server/effect-bff-runtime": "./src/effect-bff-runtime.ts", "./ultramodern-build": "./src/ultramodern-build.ts", "./problem-details": "./src/problem-details.ts", - "./client-runtime": "./src/client-runtime.ts", - "./server/effect-bff-runtime": "./src/effect-bff-runtime.ts" + "./client-runtime": "./src/client-runtime.ts" }, "scripts": { "test:types": "node ../../scripts/ultramodern-typecheck.mts --project tests/tsconfig.json && node ../../scripts/ultramodern-typecheck.mts --project tests/client-runtime-types.tsconfig.json", @@ -17,12 +18,13 @@ "typecheck": "node ../../scripts/ultramodern-typecheck.mts --project tsconfig.json" }, "dependencies": { - "@app/core-runtime": "workspace:*", - "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12", - "effect": "4.0.0-beta.107" + "@effect/opentelemetry": "4.0.0-rc.112", + "effect": "4.0.0-rc.112", + "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4", + "@app/core-runtime": "workspace:*" }, "devDependencies": { - "@types/node": "20.19.43", + "@types/node": "^26.4.1", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc", "@rstest/core": "0.11.11" } diff --git a/app/packages/shared-contracts/rstest.config.ts b/app/packages/shared-contracts/rstest.config.ts index 324494aa4..61c8cd901 100644 --- a/app/packages/shared-contracts/rstest.config.ts +++ b/app/packages/shared-contracts/rstest.config.ts @@ -1,5 +1,11 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ - projects: [{ include: ['tests/unit/**/*.test.ts'], name: 'unit', testEnvironment: 'node' }], + projects: [ + { + include: ['tests/unit/**/*.test.ts'], + name: 'unit', + testEnvironment: 'node', + }, + ], }); diff --git a/app/packages/shared-contracts/src/client-runtime.ts b/app/packages/shared-contracts/src/client-runtime.ts index e25614b78..a4c0ff0a0 100644 --- a/app/packages/shared-contracts/src/client-runtime.ts +++ b/app/packages/shared-contracts/src/client-runtime.ts @@ -1,9 +1,5 @@ import { Effect, Schema, makeEffectHttpApiClient } from '@modern-js/plugin-bff/effect-client'; -import type { - EffectHttpApiClientOptions, - HttpApi, - HttpApiGroup, -} from '@modern-js/plugin-bff/effect-client'; +import type { EffectHttpApiClientOptions, HttpApi, HttpApiGroup } from '@modern-js/plugin-bff/effect-client'; import { Redacted } from 'effect'; import { Headers as HttpHeaders, HttpClient, HttpClientRequest } from 'effect/unstable/http'; @@ -12,14 +8,7 @@ const EffectBffOperationContextSchema = Schema.Struct({ // eslint-disable-next-line effect-native/no-unbranded-identifier-schema -- The framework operation name is owner-supplied routing metadata, not an interchangeable Resource identifier. operationId: Schema.String, routePath: Schema.String, - source: Schema.Literals([ - 'client', - 'server', - 'generated-client', - 'effect-adapter', - 'data-platform', - 'unknown', - ]), + source: Schema.Literals(['client', 'server', 'generated-client', 'effect-adapter', 'data-platform', 'unknown']), }); export type EffectBffOperationContext = typeof EffectBffOperationContextSchema.Type; @@ -45,9 +34,7 @@ export interface EffectBffClientConfig< readonly defaultApiPrefix: string | URL; } -const encodeOperationContext = Schema.encodeResult( - Schema.fromJsonString(EffectBffOperationContextSchema), -); +const encodeOperationContext = Schema.encodeResult(Schema.fromJsonString(EffectBffOperationContextSchema)); export const makeEffectBffClient = ({ api, @@ -81,9 +68,7 @@ export const makeEffectBffClient = - Effect.provideService(effect, HttpClient.TracerPropagationEnabled, false), - ), + HttpClient.transform((effect) => Effect.provideService(effect, HttpClient.TracerPropagationEnabled, false)), ); }, }; @@ -94,16 +79,11 @@ export const makeEffectBffClient = = - operationContext === undefined - ? Effect.succeed(null) - : Effect.fromResult(encodeOperationContext(operationContext)); + operationContext === undefined ? Effect.succeed(null) : Effect.fromResult(encodeOperationContext(operationContext)); return operationContextText.pipe(Effect.flatMap(makeClient)); }; -interface GovernedEffectBffClientConfig< - ApiId extends string, - Groups extends HttpApiGroup.Constraint, -> { +interface GovernedEffectBffClientConfig { readonly api: HttpApi.HttpApi; readonly credential: Redacted.Redacted; readonly defaultApiPrefix: string | URL; @@ -125,16 +105,8 @@ const isGovernedBaseUrl = (value: string): boolean => { }; /** Fresh per-invocation transport; credentials remain redacted until HTTP header construction. */ -export const makeGovernedEffectBffClient = < - ApiId extends string, - Groups extends HttpApiGroup.Constraint, ->( - { - api, - credential, - defaultApiPrefix, - requestCorrelation, - }: GovernedEffectBffClientConfig, +export const makeGovernedEffectBffClient = ( + { api, credential, defaultApiPrefix, requestCorrelation }: GovernedEffectBffClientConfig, options: Pick, ) => { const baseUrl = String(options.baseUrl ?? defaultApiPrefix); diff --git a/app/packages/shared-contracts/src/effect-bff-runtime.ts b/app/packages/shared-contracts/src/effect-bff-runtime.ts index e67d6ed8d..1f71ac50a 100644 --- a/app/packages/shared-contracts/src/effect-bff-runtime.ts +++ b/app/packages/shared-contracts/src/effect-bff-runtime.ts @@ -2,11 +2,7 @@ /* oxlint-disable effect-native/no-dependency-parameters -- The approved BFF assembly seam intentionally accepts caller-composed Layers; expires: 2027-09-07. */ import { governedReadHttpStatus } from '@app/core-runtime/http/governed-read'; import { defineEffectBff, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; -import type { - EffectRuntimeRequirements, - HttpApi, - HttpApiGroup, -} from '@modern-js/plugin-bff/effect-edge'; +import type { EffectRuntimeRequirements, HttpApi, HttpApiGroup } from '@modern-js/plugin-bff/effect-edge'; export interface EffectBffRuntimeAssembly< ApiId extends string, @@ -65,9 +61,7 @@ export const makeGovernedReadProblems = < readonly policyConflict: GovernedProblemConstructor<409, PolicyConflict>; readonly policyIneligible: GovernedProblemConstructor<422, PolicyIneligible>; readonly unavailable: { - readonly make: ( - fields: GovernedProblemFields<503> & { readonly retryable: true }, - ) => Unavailable; + readonly make: (fields: GovernedProblemFields<503> & { readonly retryable: true }) => Unavailable; }; }) => ({ authentication: () => diff --git a/app/packages/shared-contracts/src/effect-bff-runtime.type-test.ts b/app/packages/shared-contracts/src/effect-bff-runtime.type-test.ts index 5d31b15ea..d0e720f8f 100644 --- a/app/packages/shared-contracts/src/effect-bff-runtime.type-test.ts +++ b/app/packages/shared-contracts/src/effect-bff-runtime.type-test.ts @@ -25,9 +25,7 @@ const FixtureStartupError = Data.TaggedError('FixtureStartupError')<{ }>; type IsExact = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false; + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; type ExpectedRuntimeRequirements = | Exclude< @@ -55,8 +53,7 @@ const fixtureRuntime = assembleEffectBffRuntime({ handlers: fixtureHandlers, }); -const concreteRuntime: EffectBffDefinition & - EffectBffRuntime = fixtureRuntime; +const concreteRuntime: EffectBffDefinition & EffectBffRuntime = fixtureRuntime; const inferredApiIsExact: IsExact = true; const inferredRequirementsAreExact: IsExact< Layer.Services, @@ -73,7 +70,11 @@ const failingFixtureHandlers = HttpApiBuilder.group(fixtureApi, 'fixture', (hand Layer.provide( Layer.effect( FixtureDependency, - Effect.fail(new FixtureStartupError({ reason: 'must be resolved at the runtime root' })), + Effect.fail( + new FixtureStartupError({ + reason: 'must be resolved at the runtime root', + }), + ), ), ), ); diff --git a/app/packages/shared-contracts/src/gateway-context.ts b/app/packages/shared-contracts/src/gateway-context.ts index 86da38575..ebbd9ffd0 100644 --- a/app/packages/shared-contracts/src/gateway-context.ts +++ b/app/packages/shared-contracts/src/gateway-context.ts @@ -1,5 +1,5 @@ -// eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- These pure helpers construct contract schemas, not Effect services. -import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from './problem-details.ts'; +import { TrustedPrincipalContextSchema } from '@app/core-runtime/actions/principal-context'; +import type { TrustedPrincipalContext } from '@app/core-runtime/actions/principal-context'; import { Effect, HttpApi, @@ -9,11 +9,13 @@ import { makeEffectHttpApiClient, } from '@modern-js/plugin-bff/effect-client'; import type { HttpClientError } from '@modern-js/plugin-bff/effect-client'; -import { TrustedPrincipalContextSchema } from '@app/core-runtime/actions/principal-context'; -import type { TrustedPrincipalContext } from '@app/core-runtime/actions/principal-context'; import { Context } from 'effect'; import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; +/* oxlint-disable anti-slop-effect/no-service-constructor-imports -- These pure helpers construct contract schemas, not Effect services. */ +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from './problem-details.ts'; +/* oxlint-enable anti-slop-effect/no-service-constructor-imports */ + export const GATEWAY_ASSERTION_VERSION = 1 as const; export const GATEWAY_ASSERTION_TTL_SECONDS = 300 as const; export const GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS = 30 as const; @@ -24,9 +26,7 @@ const LegalEntityIdSchema = uuid.pipe(Schema.brand('LegalEntityId')); const epochSeconds = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); export const GatewayAudienceSchema = nonEmptyString.check( Schema.makeFilter((value) => - /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/u.test(value) - ? undefined - : 'audience must be a stable topology app ID', + /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/u.test(value) ? undefined : 'audience must be a stable topology app ID', ), ); @@ -40,9 +40,7 @@ export const GatewayContextProtectedHeaderSchema = Schema.Struct({ typ: Schema.Literal('JWT'), }); -export type GatewayContextProtectedHeader = Schema.Schema.Type< - typeof GatewayContextProtectedHeaderSchema ->; +export type GatewayContextProtectedHeader = Schema.Schema.Type; export const GatewayContextClaimsSchema = Schema.Struct({ aud: GatewayAudienceSchema, @@ -60,10 +58,16 @@ export const GatewayContextClaimsSchema = Schema.Struct({ issues.push({ issue: 'exp must be greater than iat', path: ['exp'] }); } if (claims.exp - claims.iat !== GATEWAY_ASSERTION_TTL_SECONDS) { - issues.push({ issue: 'exp must be exactly 300 seconds after iat', path: ['exp'] }); + issues.push({ + issue: 'exp must be exactly 300 seconds after iat', + path: ['exp'], + }); } if (claims.sub !== claims.principal.principalId) { - issues.push({ issue: 'sub must equal principal.principalId', path: ['sub'] }); + issues.push({ + issue: 'sub must equal principal.principalId', + path: ['sub'], + }); } return issues; }), @@ -75,10 +79,9 @@ export const decodeGatewayContextClaims = Schema.decodeUnknownEffect(GatewayCont onExcessProperty: 'error', }); -export const decodeGatewayContextProtectedHeader = Schema.decodeUnknownEffect( - GatewayContextProtectedHeaderSchema, - { onExcessProperty: 'error' }, -); +export const decodeGatewayContextProtectedHeader = Schema.decodeUnknownEffect(GatewayContextProtectedHeaderSchema, { + onExcessProperty: 'error', +}); export const GatewayContextRequestSchema = Schema.Struct({ audience: GatewayAudienceSchema, @@ -97,32 +100,20 @@ export const GatewayAuthenticationRequiredProblemSchema = makeProblemDetailsSche 401, ); -export const GatewayAudienceInvalidProblemSchema = makeProblemDetailsSchema( - 'GatewayAudienceInvalidProblem', - 400, -); +export const GatewayAudienceInvalidProblemSchema = makeProblemDetailsSchema('GatewayAudienceInvalidProblem', 400); -export const GatewayUnavailableProblemSchema = makeRetryableProblemDetailsSchema( - 'GatewayUnavailableProblem', - 503, -); +export const GatewayUnavailableProblemSchema = makeRetryableProblemDetailsSchema('GatewayUnavailableProblem', 503); export const GatewayInternalProblemSchema = makeProblemDetailsSchema('GatewayInternalProblem', 500); const GatewayForbiddenProblemSchema = makeProblemDetailsSchema('GatewayForbiddenProblem', 403); -export const GatewayRateLimitedProblemSchema = makeProblemDetailsSchema( - 'GatewayRateLimitedProblem', - 429, - { - retryAfterSeconds: Schema.Finite, - }, -); +export const GatewayRateLimitedProblemSchema = makeProblemDetailsSchema('GatewayRateLimitedProblem', 429, { + retryAfterSeconds: Schema.Finite, +}); export type GatewayAuthenticationRequiredProblem = Schema.Schema.Type< typeof GatewayAuthenticationRequiredProblemSchema >; -export type GatewayAudienceInvalidProblem = Schema.Schema.Type< - typeof GatewayAudienceInvalidProblemSchema ->; +export type GatewayAudienceInvalidProblem = Schema.Schema.Type; export type GatewayUnavailableProblem = Schema.Schema.Type; export type GatewayInternalProblem = Schema.Schema.Type; type GatewayForbiddenProblem = Schema.Schema.Type; @@ -203,17 +194,13 @@ export interface GatewayContextClientOptions { readonly cookie?: string; } -export type GatewayContextClientError = - | GatewayContextProblem - | HttpClientError.HttpClientError - | Schema.SchemaError; +export type GatewayContextClientError = GatewayContextProblem | HttpClientError.HttpClientError | Schema.SchemaError; export type GatewayContextClientEffect = Effect.Effect; -const GatewayContextRequestOptions = Context.Reference( - 'GatewayContextRequestOptions', - { defaultValue: () => ({}) }, -); +const GatewayContextRequestOptions = Context.Reference('GatewayContextRequestOptions', { + defaultValue: () => ({}), +}); const gatewayContextClient = makeEffectHttpApiClient(GatewayContextApi, { transformClient: HttpClient.mapRequestEffect((request) => @@ -236,12 +223,10 @@ export const issueGatewayContext = ( payload: GatewayContextRequest, options: GatewayContextClientOptions = {}, ): GatewayContextClientEffect => - Schema.decodeUnknownEffect(GatewayContextRequestSchema)(payload).pipe( + Schema.decodeEffect(GatewayContextRequestSchema)(payload).pipe( Effect.flatMap((decodedPayload) => gatewayContextClient.pipe( - Effect.flatMap((client) => - client.gatewayContext.issueGatewayContext({ payload: decodedPayload }), - ), + Effect.flatMap((client) => client.gatewayContext.issueGatewayContext({ payload: decodedPayload })), ), ), Effect.provideService(GatewayContextRequestOptions, options), diff --git a/app/packages/shared-contracts/src/index.ts b/app/packages/shared-contracts/src/index.ts index 6cdb48690..2ed164f10 100644 --- a/app/packages/shared-contracts/src/index.ts +++ b/app/packages/shared-contracts/src/index.ts @@ -54,11 +54,7 @@ export type { MicroVerticalReadiness, } from './microvertical-api-baseline.ts'; export { makeOperationGateway } from './operation-gateway.ts'; -export type { - OperationGateway, - OperationGatewayAttempt, - OperationGatewayIssuer, -} from './operation-gateway.ts'; +export type { OperationGateway, OperationGatewayAttempt, OperationGatewayIssuer } from './operation-gateway.ts'; export const UltramodernPublicSitemapChangeFrequencySchema = Schema.Literals([ 'always', @@ -69,8 +65,7 @@ export const UltramodernPublicSitemapChangeFrequencySchema = Schema.Literals([ 'yearly', 'never', ]); -export type UltramodernPublicSitemapChangeFrequency = - typeof UltramodernPublicSitemapChangeFrequencySchema.Type; +export type UltramodernPublicSitemapChangeFrequency = typeof UltramodernPublicSitemapChangeFrequencySchema.Type; export interface UltramodernPublicSitemapEntry { changeFrequency?: UltramodernPublicSitemapChangeFrequency; @@ -97,8 +92,7 @@ export const UltramodernPerformanceReadinessSignalIdSchema = Schema.Literals([ 'save-data-behavior', 'cloudflare-ssr-cache-hints', ]); -export type UltramodernPerformanceReadinessSignalId = - typeof UltramodernPerformanceReadinessSignalIdSchema.Type; +export type UltramodernPerformanceReadinessSignalId = typeof UltramodernPerformanceReadinessSignalIdSchema.Type; export interface UltramodernPerformanceReadinessDiagnosticsConfig { /** @@ -143,13 +137,8 @@ export const ultramodernWorkspaceContract = { export const UltramodernWorkspaceLocaleSchema = Schema.Literals(['en', 'cs']); export type UltramodernWorkspaceLocale = typeof UltramodernWorkspaceLocaleSchema.Type; -export const UltramodernPerformanceReadinessSignalStatusSchema = Schema.Literals([ - 'pass', - 'warn', - 'fail', -]); -export type UltramodernPerformanceReadinessSignalStatus = - typeof UltramodernPerformanceReadinessSignalStatusSchema.Type; +export const UltramodernPerformanceReadinessSignalStatusSchema = Schema.Literals(['pass', 'warn', 'fail']); +export type UltramodernPerformanceReadinessSignalStatus = typeof UltramodernPerformanceReadinessSignalStatusSchema.Type; export const ultramodernWorkspaceEventNames = { navigate: 'ultramodern:navigate', @@ -172,16 +161,11 @@ const UltramodernWorkspaceJsonValueSchema: Schema.Codec = Schema.su ]), ); -export const UltramodernWorkspaceJsonObjectSchema = Schema.Record( - Schema.String, - UltramodernWorkspaceJsonValueSchema, -); +export const UltramodernWorkspaceJsonObjectSchema = Schema.Record(Schema.String, UltramodernWorkspaceJsonValueSchema); export type UltramodernWorkspaceJsonObject = typeof UltramodernWorkspaceJsonObjectSchema.Type; const UltramodernWorkspaceNonEmptyStringSchema = Schema.String.check(Schema.isPattern(/\S/u)); -const UltramodernWorkspaceNonNegativeNumberSchema = Schema.Finite.check( - Schema.isGreaterThanOrEqualTo(0), -); +const UltramodernWorkspaceNonNegativeNumberSchema = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)); const UltramodernWorkspaceAppIdSchema = UltramodernWorkspaceNonEmptyStringSchema.pipe( Schema.brand('UltramodernWorkspaceAppId'), ); @@ -214,8 +198,7 @@ export const UltramodernPerformanceSignalPayloadSchema = Schema.Struct({ signalId: UltramodernPerformanceReadinessSignalIdSchema, status: UltramodernPerformanceReadinessSignalStatusSchema, }); -export type UltramodernPerformanceSignalPayload = - typeof UltramodernPerformanceSignalPayloadSchema.Type; +export type UltramodernPerformanceSignalPayload = typeof UltramodernPerformanceSignalPayloadSchema.Type; export interface UltramodernWorkspaceEventPayloadMap { 'ultramodern:navigate': UltramodernNavigatePayload; @@ -239,18 +222,15 @@ export class UltramodernWorkspaceEventValidationError { export const isUltramodernNavigatePayload = ( payload: Payload, -): payload is Payload & UltramodernNavigatePayload => - Schema.is(UltramodernNavigatePayloadSchema)(payload); +): payload is Payload & UltramodernNavigatePayload => Schema.is(UltramodernNavigatePayloadSchema)(payload); export const isUltramodernRouteSettledPayload = ( payload: Payload, -): payload is Payload & UltramodernRouteSettledPayload => - Schema.is(UltramodernRouteSettledPayloadSchema)(payload); +): payload is Payload & UltramodernRouteSettledPayload => Schema.is(UltramodernRouteSettledPayloadSchema)(payload); export const isUltramodernRemoteReadyPayload = ( payload: Payload, -): payload is Payload & UltramodernRemoteReadyPayload => - Schema.is(UltramodernRemoteReadyPayloadSchema)(payload); +): payload is Payload & UltramodernRemoteReadyPayload => Schema.is(UltramodernRemoteReadyPayloadSchema)(payload); export const isUltramodernPerformanceSignalPayload = ( payload: Payload, @@ -264,9 +244,7 @@ const ultramodernWorkspaceEventPayloadSchemas = { [ultramodernWorkspaceEventNames.routeSettled]: UltramodernRouteSettledPayloadSchema, }; -const ultramodernWorkspaceCustomEventSchema = ( - eventName: Name, -) => +const ultramodernWorkspaceCustomEventSchema = (eventName: Name) => Schema.Opaque>()( Schema.Struct({ detail: ultramodernWorkspaceEventPayloadSchemas[eventName], @@ -274,19 +252,13 @@ const ultramodernWorkspaceCustomEventSchema = ( +export const isUltramodernWorkspaceEventPayload = ( eventName: Name, payload: Payload, ): payload is Payload & UltramodernWorkspaceEventPayloadMap[Name] => Schema.is(ultramodernWorkspaceEventPayloadSchemas[eventName])(payload); -export const assertUltramodernWorkspaceEventPayload = < - Name extends UltramodernWorkspaceEventName, - Payload, ->( +export const assertUltramodernWorkspaceEventPayload = ( eventName: Name, payload: Payload, ): Payload & UltramodernWorkspaceEventPayloadMap[Name] => { @@ -340,54 +312,33 @@ export const onUltramodernWorkspaceEvent = dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.navigate, payload); +export const dispatchUltramodernNavigate = (target: EventTarget, payload: UltramodernNavigatePayload) => + dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.navigate, payload); -export const dispatchUltramodernRouteSettled = ( - target: EventTarget, - payload: UltramodernRouteSettledPayload, -) => +export const dispatchUltramodernRouteSettled = (target: EventTarget, payload: UltramodernRouteSettledPayload) => dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.routeSettled, payload); -export const dispatchUltramodernRemoteReady = ( - target: EventTarget, - payload: UltramodernRemoteReadyPayload, -) => dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.remoteReady, payload); +export const dispatchUltramodernRemoteReady = (target: EventTarget, payload: UltramodernRemoteReadyPayload) => + dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.remoteReady, payload); export const dispatchUltramodernPerformanceSignal = ( target: EventTarget, payload: UltramodernPerformanceSignalPayload, -) => - dispatchUltramodernWorkspaceEvent( - target, - ultramodernWorkspaceEventNames.performanceSignal, - payload, - ); +) => dispatchUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.performanceSignal, payload); export const onUltramodernNavigate = ( target: EventTarget, - handler: ( - payload: UltramodernNavigatePayload, - event: CustomEvent, - ) => void, + handler: (payload: UltramodernNavigatePayload, event: CustomEvent) => void, ) => onUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.navigate, handler); export const onUltramodernRouteSettled = ( target: EventTarget, - handler: ( - payload: UltramodernRouteSettledPayload, - event: CustomEvent, - ) => void, + handler: (payload: UltramodernRouteSettledPayload, event: CustomEvent) => void, ) => onUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.routeSettled, handler); export const onUltramodernRemoteReady = ( target: EventTarget, - handler: ( - payload: UltramodernRemoteReadyPayload, - event: CustomEvent, - ) => void, + handler: (payload: UltramodernRemoteReadyPayload, event: CustomEvent) => void, ) => onUltramodernWorkspaceEvent(target, ultramodernWorkspaceEventNames.remoteReady, handler); export const onUltramodernPerformanceSignal = ( diff --git a/app/packages/shared-contracts/src/microvertical-api-baseline.ts b/app/packages/shared-contracts/src/microvertical-api-baseline.ts index a4a9f97e7..2f3ba629c 100644 --- a/app/packages/shared-contracts/src/microvertical-api-baseline.ts +++ b/app/packages/shared-contracts/src/microvertical-api-baseline.ts @@ -1,9 +1,6 @@ import { Schema } from 'effect'; -const MicroVerticalAppIdSchema = Schema.String.pipe( - Schema.brand('MicroVerticalAppId'), - Schema.decodeTo(Schema.String), -); +const MicroVerticalAppIdSchema = Schema.String.pipe(Schema.brand('MicroVerticalAppId'), Schema.decodeTo(Schema.String)); const MicroVerticalUnitIdSchema = Schema.String.pipe( Schema.brand('MicroVerticalUnitId'), Schema.decodeTo(Schema.String), @@ -86,9 +83,7 @@ export const createMicroVerticalOperationContext = < return input.traceId === undefined ? context : { ...context, traceId: input.traceId }; }; -export const microVerticalOperationAttributes = ( - operationContext: MicroVerticalOperationContext, -) => { +export const microVerticalOperationAttributes = (operationContext: MicroVerticalOperationContext) => { const attributes = { 'modernjs.operation.id': operationContext.operationId, 'modernjs.operation.method': operationContext.method, diff --git a/app/packages/shared-contracts/src/operation-gateway.ts b/app/packages/shared-contracts/src/operation-gateway.ts index a4d1f08b2..4756f11ec 100644 --- a/app/packages/shared-contracts/src/operation-gateway.ts +++ b/app/packages/shared-contracts/src/operation-gateway.ts @@ -1,4 +1,5 @@ import { Effect, Redacted } from 'effect'; + import { issueGatewayContext } from './gateway-context.ts'; import type { GatewayContextClientError, diff --git a/app/packages/shared-contracts/src/operation-gateway.type-test.ts b/app/packages/shared-contracts/src/operation-gateway.type-test.ts index c75446133..71625bd97 100644 --- a/app/packages/shared-contracts/src/operation-gateway.type-test.ts +++ b/app/packages/shared-contracts/src/operation-gateway.type-test.ts @@ -1,12 +1,11 @@ import { Effect } from 'effect'; + // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Pure browser gateway value constructor, not a Context service. import { makeOperationGateway } from './operation-gateway.ts'; import type { OperationGatewayIssuer } from './operation-gateway.ts'; type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false; + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; type EffectChannels = Value extends Effect.Effect ? readonly [Success, Failure, Requirements] diff --git a/app/packages/shared-contracts/src/problem-details.ts b/app/packages/shared-contracts/src/problem-details.ts index fab7046f3..43f32ddd2 100644 --- a/app/packages/shared-contracts/src/problem-details.ts +++ b/app/packages/shared-contracts/src/problem-details.ts @@ -56,19 +56,14 @@ const isConcreteExtensionValue = (ast: SchemaAST.AST): boolean => { return true; }; -const visitExtensionChildren = ( - ast: SchemaAST.AST, - visit: (ast: SchemaAST.AST) => boolean, -): boolean => { +const visitExtensionChildren = (ast: SchemaAST.AST, visit: (ast: SchemaAST.AST) => boolean): boolean => { if (SchemaAST.isArrays(ast)) { return ast.elements.every(visit) && ast.rest.every(visit); } if (SchemaAST.isObjects(ast)) { return ( ast.indexSignatures.length === 0 && - ast.propertySignatures.every( - ({ name, type }) => Predicate.isString(name) && name !== '__proto__' && visit(type), - ) + ast.propertySignatures.every(({ name, type }) => Predicate.isString(name) && name !== '__proto__' && visit(type)) ); } if (SchemaAST.isUnion(ast)) { @@ -104,10 +99,7 @@ const cloneDescriptors = (value: Value, clone: (value: Current) if ('value' in descriptor) { const descriptorValue = descriptor.value; descriptor.value = - key === 'thunk' && - SchemaAST.isAST(value) && - SchemaAST.isSuspend(value) && - Predicate.isFunction(descriptorValue) + key === 'thunk' && SchemaAST.isAST(value) && SchemaAST.isSuspend(value) && Predicate.isFunction(descriptorValue) ? () => clone(descriptorValue()) : clone(descriptorValue); } @@ -158,11 +150,7 @@ const stableExtensionField = (name: string, descriptor: PropertyDescriptor) => { } const fieldDescriptors = Object.getOwnPropertyDescriptors(field); const astDescriptor = fieldDescriptors.ast; - if ( - astDescriptor === undefined || - !('value' in astDescriptor) || - !SchemaAST.isAST(astDescriptor.value) - ) { + if (astDescriptor === undefined || !('value' in astDescriptor) || !SchemaAST.isAST(astDescriptor.value)) { // eslint-disable-next-line effect-native/no-native-error-construction -- Schema AST accessors could change after validation; the captured AST must be the one consumed by TaggedStruct. throw new TypeError(`Problem Details extension field "${name}" must use a concrete schema.`); } @@ -175,30 +163,16 @@ const stableExtensionField = (name: string, descriptor: PropertyDescriptor) => { return Object.defineProperties(Object.create(Object.getPrototypeOf(field)), fieldDescriptors); }; -const reservedExtensionFields = new Set([ - '__proto__', - '_tag', - 'detail', - 'retryable', - 'status', - 'title', - 'type', -]); +const reservedExtensionFields = new Set(['__proto__', '_tag', 'detail', 'retryable', 'status', 'title', 'type']); const concreteExtensionsSnapshot = ( extensions: Extensions, ): Extensions => { - if ( - Object.getPrototypeOf(extensions) !== Object.prototype && - Object.getPrototypeOf(extensions) !== null - ) { + if (Object.getPrototypeOf(extensions) !== Object.prototype && Object.getPrototypeOf(extensions) !== null) { // eslint-disable-next-line effect-native/no-native-error-construction -- This synchronous, browser-safe schema factory rejects a caller programming error before a contract can be published. throw new TypeError('Problem Details extensions must use a plain object.'); } - const snapshot = Object.defineProperties( - {}, - Object.getOwnPropertyDescriptors(extensions), - ) as Extensions; + const snapshot = Object.defineProperties({}, Object.getOwnPropertyDescriptors(extensions)) as Extensions; for (const name of Reflect.ownKeys(snapshot)) { if (!Predicate.isString(name)) { // eslint-disable-next-line effect-native/no-native-error-construction -- This synchronous, browser-safe schema factory rejects a caller programming error before a contract can be published. @@ -211,12 +185,13 @@ const concreteExtensionsSnapshot = { - const concreteExtensions = - extensions === undefined ? extensions : concreteExtensionsSnapshot(extensions); + const concreteExtensions = extensions === undefined ? extensions : concreteExtensionsSnapshot(extensions); // eslint-disable-next-line prefer-object-spread -- Object.assign preserves the concrete generic marker and extension fields in the inferred schema type. const fields = Object.assign( { @@ -246,10 +220,7 @@ const makeAnnotatedProblemDetailsSchema = < marker, concreteExtensions, ); - return Schema.TaggedStruct(tag, fields).pipe( - problemDetailsRepresentation, - HttpApiSchema.status(status), - ); + return Schema.TaggedStruct(tag, fields).pipe(problemDetailsRepresentation, HttpApiSchema.status(status)); }; /** @@ -277,5 +248,4 @@ export const makeRetryableProblemDetailsSchema = < tag: Tag, status: Status, extensions?: Extensions, -) => - makeAnnotatedProblemDetailsSchema(tag, status, { retryable: Schema.Literal(true) }, extensions); +) => makeAnnotatedProblemDetailsSchema(tag, status, { retryable: Schema.Literal(true) }, extensions); diff --git a/app/packages/shared-contracts/tests/unit/client-runtime.test.ts b/app/packages/shared-contracts/tests/unit/client-runtime.test.ts index 72f6c0c0d..f76e60b0d 100644 --- a/app/packages/shared-contracts/tests/unit/client-runtime.test.ts +++ b/app/packages/shared-contracts/tests/unit/client-runtime.test.ts @@ -1,5 +1,3 @@ -import { expect, it } from 'effect-rstest'; - import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, @@ -10,6 +8,7 @@ import { Schema, } from '@modern-js/plugin-bff/effect-client'; import { Predicate, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; const RepresentativeConflictSchema = Schema.TaggedStruct('RepresentativeConflict', { @@ -17,10 +16,7 @@ const RepresentativeConflictSchema = Schema.TaggedStruct('RepresentativeConflict status: Schema.Literal(409), title: Schema.String, type: Schema.String, -}).pipe( - HttpApiSchema.asJson({ contentType: 'application/problem+json' }), - HttpApiSchema.status(409), -); +}).pipe(HttpApiSchema.asJson({ contentType: 'application/problem+json' }), HttpApiSchema.status(409)); const RepresentativeApi = HttpApi.make('RepresentativeApi').add( HttpApiGroup.make('representative').add( @@ -46,9 +42,7 @@ type RepresentativeReadError = Effect.Error; const preserveRepresentativeReadType = (client: RepresentativeClient): RepresentativeReadEffect => client.representative.read({}); -const preserveRepresentativeSuccessType = ( - success: RepresentativeReadSuccess, -): Readonly<{ value: string }> => success; +const preserveRepresentativeSuccessType = (success: RepresentativeReadSuccess): Readonly<{ value: string }> => success; const preserveRepresentativeErrorType = (error: RepresentativeReadError) => { if (Schema.is(RepresentativeConflictSchema)(error)) { return error.status satisfies 409; @@ -107,9 +101,7 @@ it.effect('uses the owner-supplied API prefix by default', () => ); expect(result).toEqual({ value: 'default-prefix' }); - expect(requests.map(({ url }) => url)).toEqual([ - 'https://shell.example/representative-api/read', - ]); + expect(requests.map(({ url }) => url)).toEqual(['https://shell.example/representative-api/read']); }), ); @@ -177,7 +169,7 @@ it.effect('propagates supported request context and resolved transport headers', expect(request.headers.get('traceparent')).toBe(traceparent); expect(request.headers.get('x-operation-id')).toBe(operationContext.operationId); expect( - yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( request.headers.get('x-modernjs-bff-operation-context') ?? '', ), ).toEqual(operationContext); @@ -210,12 +202,7 @@ it.effect('omits absent optional request context and transport header values', ( if (request === undefined) { throw new Error('Expected captured request'); } - for (const header of [ - 'accept-language', - 'traceparent', - 'x-modernjs-bff-operation-context', - 'x-operation-id', - ]) { + for (const header of ['accept-language', 'traceparent', 'x-modernjs-bff-operation-context', 'x-operation-id']) { expect(request.headers.has(header), header).toBe(false); } }), diff --git a/app/packages/shared-contracts/tests/unit/effect-bff-runtime.test.ts b/app/packages/shared-contracts/tests/unit/effect-bff-runtime.test.ts index cff53fb25..42a53b831 100644 --- a/app/packages/shared-contracts/tests/unit/effect-bff-runtime.test.ts +++ b/app/packages/shared-contracts/tests/unit/effect-bff-runtime.test.ts @@ -1,5 +1,3 @@ -import { expect, it } from 'effect-rstest'; - import { Effect, HttpApi, @@ -11,6 +9,7 @@ import { } from '@modern-js/plugin-bff/effect-edge'; import type { EffectBffDefinition, EffectBffRuntime } from '@modern-js/plugin-bff/effect-edge'; import { Context, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { assembleEffectBffRuntime } from '../../src/effect-bff-runtime.ts'; @@ -50,9 +49,7 @@ const makeCorsRuntime = (greeting: string) => const failingStartupRuntime = assembleEffectBffRuntime({ api, - handlers: handlers.pipe( - Layer.provide(Layer.effect(Greeting, Effect.die('fixture layer startup defect'))), - ), + handlers: handlers.pipe(Layer.provide(Layer.effect(Greeting, Effect.die('fixture layer startup defect')))), }); const inferredRuntime: EffectBffDefinition & EffectBffRuntime = @@ -65,9 +62,7 @@ it.live('assembles a concrete API with caller-provided handler dependencies', () Effect.sync(() => makeRuntime('substitute runtime').createHandler()), (runtimeServer) => Effect.promise(() => runtimeServer.dispose()), ); - const response = yield* Effect.promise(() => - server.handler(new Request('http://localhost/greet')), - ); + const response = yield* Effect.promise(() => server.handler(new Request('http://localhost/greet'))); expect(response.status).toBe(200); expect(yield* Effect.promise(() => response.json())).toEqual({ greeting: 'substitute runtime', @@ -104,9 +99,7 @@ it.live('keeps strict runtime defect handling at the generated HTTP boundary', ( Effect.sync(() => makeRuntime('unused').createHandler()), (runtimeServer) => Effect.promise(() => runtimeServer.dispose()), ); - const response = yield* Effect.promise(() => - server.handler(new Request('http://localhost/fail')), - ); + const response = yield* Effect.promise(() => server.handler(new Request('http://localhost/fail'))); expect(response.status).toBe(500); }), ); @@ -117,9 +110,9 @@ it.live('preserves caller-owned Layer startup defects', () => Effect.sync(() => failingStartupRuntime.createHandler()), (runtimeServer) => Effect.promise(() => runtimeServer.dispose()), ); - const error = yield* Effect.tryPromise(() => - server.handler(new Request('http://localhost/greet')), - ).pipe(Effect.flip); + const error = yield* Effect.tryPromise(() => server.handler(new Request('http://localhost/greet'))).pipe( + Effect.flip, + ); expect(String(error.cause)).toMatch(/fixture layer startup defect/u); }), ); diff --git a/app/packages/shared-contracts/tests/unit/gateway-context.test.ts b/app/packages/shared-contracts/tests/unit/gateway-context.test.ts index 9e46b9072..6fb6d86ad 100644 --- a/app/packages/shared-contracts/tests/unit/gateway-context.test.ts +++ b/app/packages/shared-contracts/tests/unit/gateway-context.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { TrustedPrincipalContextSchema } from '@app/core-runtime/actions/principal-context'; import { Effect, Schema, SchemaAST, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { ApiKeyGatewayHeadersSchema, GatewayContextApiGroup, @@ -53,7 +54,7 @@ it.effect('decodes the exact versioned public assertion contract', () => Effect.gen(function* testScenario1() { expect(yield* decodeGatewayContextClaims(claims)).toEqual(claims); expect( - yield* Schema.decodeUnknownEffect(GatewayContextProtectedHeaderSchema)({ + yield* Schema.decodeEffect(GatewayContextProtectedHeaderSchema)({ alg: 'EdDSA', kid: 'current-2026-08', typ: 'JWT', @@ -64,12 +65,12 @@ it.effect('decodes the exact versioned public assertion contract', () => typ: 'JWT', }); expect( - yield* Schema.decodeUnknownEffect(GatewayContextRequestSchema)({ + yield* Schema.decodeEffect(GatewayContextRequestSchema)({ audience: 'inventory-stock', }), ).toEqual({ audience: 'inventory-stock' }); expect( - yield* Schema.decodeUnknownEffect(GatewayContextResponseSchema)({ + yield* Schema.decodeEffect(GatewayContextResponseSchema)({ expiresAt: claims.exp, token: 'header.payload.signature', }), @@ -80,14 +81,14 @@ it.effect('decodes the exact versioned public assertion contract', () => it.effect('rejects malformed audiences, invalid ordering, and subject mismatch', () => Effect.gen(function* testScenario2() { expect( - yield* Effect.flip(Schema.decodeUnknownEffect(GatewayContextRequestSchema)({ audience: '' })), - ).toBeDefined(); - expect( - yield* Effect.flip(decodeGatewayContextClaims({ ...claims, exp: claims.iat })), - ).toBeDefined(); - expect( - yield* Effect.flip(decodeGatewayContextClaims({ ...claims, exp: claims.iat + 301 })), + yield* Effect.flip( + Schema.decodeEffect(GatewayContextRequestSchema)({ + audience: '', + }), + ), ).toBeDefined(); + expect(yield* Effect.flip(decodeGatewayContextClaims({ ...claims, exp: claims.iat }))).toBeDefined(); + expect(yield* Effect.flip(decodeGatewayContextClaims({ ...claims, exp: claims.iat + 301 }))).toBeDefined(); expect( yield* Effect.flip( decodeGatewayContextClaims({ @@ -145,11 +146,7 @@ it('schemas publish only the required public field names', () => { 'sub', 'ver', ]); - expect(Object.keys(GatewayContextProtectedHeaderSchema.fields).toSorted()).toEqual([ - 'alg', - 'kid', - 'typ', - ]); + expect(Object.keys(GatewayContextProtectedHeaderSchema.fields).toSorted()).toEqual(['alg', 'kid', 'typ']); }); it('publishes the exact API-key credential boundary and failure statuses', () => { @@ -176,14 +173,16 @@ it('preserves migrated gateway Problem Details shapes and ordered endpoint membe title: 'Gateway unavailable', type: 'https://ontos.dev/problems/gateway-unavailable', } as const; - const decodedRateLimited = Schema.decodeUnknownSync(GatewayRateLimitedProblemSchema)(rateLimited); + const decodedRateLimited = Schema.decodeSync(GatewayRateLimitedProblemSchema)(rateLimited); expect(Schema.is(GatewayRateLimitedProblemSchema)(decodedRateLimited)).toBe(true); expect(Struct.omit(decodedRateLimited, ['_tag'])).toEqual(Struct.omit(rateLimited, ['_tag'])); - const decodedUnavailable = Schema.decodeUnknownSync(GatewayUnavailableProblemSchema)(unavailable); + const decodedUnavailable = Schema.decodeSync(GatewayUnavailableProblemSchema)(unavailable); expect(Schema.is(GatewayUnavailableProblemSchema)(decodedUnavailable)).toBe(true); expect(Struct.omit(decodedUnavailable, ['_tag'])).toEqual(Struct.omit(unavailable, ['_tag'])); expect(() => - Schema.decodeUnknownSync(GatewayRateLimitedProblemSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownSync(GatewayRateLimitedProblemSchema, { + onExcessProperty: 'error', + })({ ...rateLimited, internalDiagnostic: 'must-not-pass', }), diff --git a/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts b/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts index ca20ceebc..5d59a0b5a 100644 --- a/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts +++ b/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts @@ -1,13 +1,11 @@ -import { expect, it } from 'effect-rstest'; -import { Effect, Redacted, Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from '@modern-js/plugin-bff/effect-client'; +import { Effect, Redacted, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; -import { makeGovernedReadProblems } from '../../src/effect-bff-runtime.ts'; + import { makeGovernedEffectBffClient } from '../../src/client-runtime.ts'; -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '../../src/problem-details.ts'; +import { makeGovernedReadProblems } from '../../src/effect-bff-runtime.ts'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '../../src/problem-details.ts'; const schemas = { authentication: makeProblemDetailsSchema('AuthenticationProblem', 401), @@ -92,43 +90,37 @@ const makeClient = (credential: string, requestCorrelation: string, baseUrl: str { baseUrl }, ); -it.effect( - 'shared transport is lazy and keeps each invocation credential, correlation and trusted URL', - () => - Effect.gen(function* checkTransport() { - const requests: Request[] = []; - const fetch: typeof globalThis.fetch = (input, init) => { - requests.push(new Request(input, init)); - return Promise.resolve(Response.json('ok')); - }; - const url = new URL('https://owner.example/custom'); - const first = makeClient('Bearer first', 'first-correlation', url); - url.protocol = 'ftp:'; - url.hostname = 'attacker.example'; - const second = makeClient( - 'Bearer second', - 'second-correlation', - 'https://owner.example/custom', +it.effect('shared transport is lazy and keeps each invocation credential, correlation and trusted URL', () => + Effect.gen(function* checkTransport() { + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = (input, init) => { + requests.push(new Request(input, init)); + return Promise.resolve(Response.json('ok')); + }; + const url = new URL('https://owner.example/custom'); + const first = makeClient('Bearer first', 'first-correlation', url); + url.protocol = 'ftp:'; + url.hostname = 'attacker.example'; + const second = makeClient('Bearer second', 'second-correlation', 'https://owner.example/custom'); + expect(requests.length).toBe(0); + for (const client of [first, second]) { + const result = yield* client.pipe( + Effect.flatMap((value) => value.read.execute({})), + Effect.provideService(FetchHttpClient.Fetch, fetch), ); - expect(requests.length).toBe(0); - for (const client of [first, second]) { - const result = yield* client.pipe( - Effect.flatMap((value) => value.read.execute({})), - Effect.provideService(FetchHttpClient.Fetch, fetch), - ); - expect(result).toBe('ok'); - } - expect( - requests.map((request) => [ - request.url, - request.headers.get('authorization'), - request.headers.get('x-correlation-id'), - ]), - ).toEqual([ - ['https://owner.example/custom/read', 'Bearer first', 'first-correlation'], - ['https://owner.example/custom/read', 'Bearer second', 'second-correlation'], - ]); - }), + expect(result).toBe('ok'); + } + expect( + requests.map((request) => [ + request.url, + request.headers.get('authorization'), + request.headers.get('x-correlation-id'), + ]), + ).toEqual([ + ['https://owner.example/custom/read', 'Bearer first', 'first-correlation'], + ['https://owner.example/custom/read', 'Bearer second', 'second-correlation'], + ]); + }), ); it.effect('shared transport retains the concrete retryable backend error union', () => @@ -140,11 +132,7 @@ it.effect('shared transport retains the concrete retryable backend error union', status: 503, }), ); - const result = yield* makeClient( - 'Bearer proof', - 'correlation', - 'https://owner.example/api', - ).pipe( + const result = yield* makeClient('Bearer proof', 'correlation', 'https://owner.example/api').pipe( Effect.flatMap((client) => client.read.execute({})), Effect.provideService(FetchHttpClient.Fetch, fetch), Effect.flip, @@ -157,11 +145,7 @@ it.effect('shared transport retains the concrete retryable backend error union', }), ); -for (const baseUrl of [ - 'data:text/plain,unsafe', - 'https://user:password@owner.example/api', - '//attacker.example/api', -]) { +for (const baseUrl of ['data:text/plain,unsafe', 'https://user:password@owner.example/api', '//attacker.example/api']) { it.effect(`shared transport rejects unsafe URL ${baseUrl} before fetch`, () => Effect.gen(function* checkUnsafeUrl() { let calls = 0; diff --git a/app/packages/shared-contracts/tests/unit/microvertical-api-baseline.test.ts b/app/packages/shared-contracts/tests/unit/microvertical-api-baseline.test.ts index b53711d47..cd7a361d2 100644 --- a/app/packages/shared-contracts/tests/unit/microvertical-api-baseline.test.ts +++ b/app/packages/shared-contracts/tests/unit/microvertical-api-baseline.test.ts @@ -1,5 +1,5 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { MicroVerticalBuildMarkerSchema, @@ -48,13 +48,9 @@ it.effect('marker and readiness schemas preserve the generated wire representati versionSkew: 'none' as const, }; - expect( - yield* Schema.decodeUnknownEffect(MicroVerticalBuildMarkerSchema)(generatedBuildMetadata), - ).toEqual(marker); + expect(yield* Schema.decodeEffect(MicroVerticalBuildMarkerSchema)(generatedBuildMetadata)).toEqual(marker); expect(yield* Schema.encodeEffect(MicroVerticalBuildMarkerSchema)(marker)).toEqual(marker); - expect(yield* Schema.decodeUnknownEffect(MicroVerticalReadinessSchema)(readiness)).toEqual( - readiness, - ); + expect(yield* Schema.decodeEffect(MicroVerticalReadinessSchema)(readiness)).toEqual(readiness); expect(yield* Schema.encodeEffect(MicroVerticalReadinessSchema)(readiness)).toEqual(readiness); }), ); diff --git a/app/packages/shared-contracts/tests/unit/operation-gateway.test.ts b/app/packages/shared-contracts/tests/unit/operation-gateway.test.ts index 0ee9d9e1d..94575705b 100644 --- a/app/packages/shared-contracts/tests/unit/operation-gateway.test.ts +++ b/app/packages/shared-contracts/tests/unit/operation-gateway.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeOperationGateway } from '../../src/operation-gateway.ts'; const gatewayAcquisitionFailure = { @@ -26,13 +27,13 @@ it.effect('preserves the literal audience and success and failure inference', () return Effect.fail(gatewayAcquisitionFailure); }); - yield* expectType< - Effect.Effect - >(gateway.invoke(() => Effect.fail(operationAttemptFailure))).pipe(Effect.flip); + yield* expectType>( + gateway.invoke(() => Effect.fail(operationAttemptFailure)), + ).pipe(Effect.flip); yield* expectType>( - makeOperationGateway(audience, () => - Effect.succeed({ expiresAt: 1_700_000_300, token: 'test-token' }), - ).invoke(() => Effect.succeed('completed' as const)), + makeOperationGateway(audience, () => Effect.succeed({ expiresAt: 1_700_000_300, token: 'test-token' })).invoke( + () => Effect.succeed('completed' as const), + ), ); }), ); @@ -48,7 +49,10 @@ it.effect('acquires one audience-scoped assertion and forwards options unchanged Effect.sync(() => { expect(receivedOptions).toBe(options); issuerCalls.push({ audience: payload.audience, options }); - return { expiresAt: 1_700_000_300, token: 'header.payload.signature' }; + return { + expiresAt: 1_700_000_300, + token: 'header.payload.signature', + }; }), ); @@ -110,9 +114,7 @@ it.effect('preserves the attempted-operation failure without translation', () => Effect.succeed({ expiresAt: 1_700_000_300, token: 'test-token' }), ); - const received = yield* gateway - .invoke(() => Effect.fail(operationAttemptFailure)) - .pipe(Effect.flip); + const received = yield* gateway.invoke(() => Effect.fail(operationAttemptFailure)).pipe(Effect.flip); expect(received).toBe(operationAttemptFailure); }), diff --git a/app/packages/shared-contracts/tests/unit/problem-details.test.ts b/app/packages/shared-contracts/tests/unit/problem-details.test.ts index 4d50c95cb..ff989710c 100644 --- a/app/packages/shared-contracts/tests/unit/problem-details.test.ts +++ b/app/packages/shared-contracts/tests/unit/problem-details.test.ts @@ -1,4 +1,3 @@ -import { expect, it } from 'effect-rstest'; import { makeEffectHttpApiClient } from '@modern-js/plugin-bff/effect-client'; import { HttpApi, @@ -9,11 +8,10 @@ import { HttpServer, } from '@modern-js/plugin-bff/effect-edge'; import { Context, Effect, Layer, Predicate, Schema, SchemaAST, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '../../src/problem-details.ts'; + +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '../../src/problem-details.ts'; import { ImportedUnconstrainedExtensionSchema } from '../fixtures/unconstrained-extension.ts'; const statuses = [400, 401, 403, 404, 409, 422, 428, 429, 500, 503, 504] as const; @@ -32,7 +30,7 @@ for (const status of statuses) { type: `urn:ontos:test:problem:${status}`, } as const; - const decodedProblem = Schema.decodeUnknownSync(schema)(problem); + const decodedProblem = Schema.decodeSync(schema)(problem); expect(Schema.is(schema)(decodedProblem)).toBe(true); expect(Struct.omit(decodedProblem, ['_tag'])).toEqual(Struct.omit(problem, ['_tag'])); const encodedProblem = Schema.encodeUnknownSync(schema)(problem); @@ -71,7 +69,7 @@ it('adds only the deliberate retryable literal marker', () => { type: 'urn:ontos:test:retryable', } as const; - const decodedProblem = Schema.decodeUnknownSync(schema)(problem); + const decodedProblem = Schema.decodeSync(schema)(problem); expect(Schema.is(schema)(decodedProblem)).toBe(true); expect(Struct.omit(decodedProblem, ['_tag'])).toEqual(Struct.omit(problem, ['_tag'])); expect(() => Schema.decodeUnknownSync(schema)({ ...problem, retryable: false })).toThrow(); @@ -104,10 +102,7 @@ it.live('drives real HttpApi responses and generated client decoding', () => const server = yield* Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( - HttpApiBuilder.layer(api).pipe( - Layer.provide(handlers), - Layer.provide(HttpServer.layerServices), - ), + HttpApiBuilder.layer(api).pipe(Layer.provide(handlers), Layer.provide(HttpServer.layerServices)), { disableLogger: true }, ), ), @@ -124,7 +119,9 @@ it.live('drives real HttpApi responses and generated client decoding', () => expect(response.headers.get('content-type') ?? '').toMatch(/^application\/problem\+json\b/u); expect(yield* Effect.promise(() => response.json())).toEqual(problem); - const client = makeEffectHttpApiClient(api, { baseUrl: 'https://fixture.ontos.test' }); + const client = makeEffectHttpApiClient(api, { + baseUrl: 'https://fixture.ontos.test', + }); const clientError = yield* Effect.flip( client.pipe( Effect.flatMap((generated) => generated.problemFixture.execute({ payload: {} })), @@ -146,7 +143,9 @@ it('rejects reserved and unconstrained extension schemas at construction', () => }), ).toThrow(/reserved/u); expect(() => - makeProblemDetailsSchema('PrototypeSyntaxFixtureProblem', 400, { __proto__: Schema.String }), + makeProblemDetailsSchema('PrototypeSyntaxFixtureProblem', 400, { + __proto__: Schema.String, + }), ).toThrow(/plain object/u); expect(() => makeProblemDetailsSchema('SymbolKeyFixtureProblem', 400, { @@ -186,16 +185,12 @@ it('rejects reserved and unconstrained extension schemas at construction', () => Schema.Undefined, ImportedUnconstrainedExtensionSchema, ]) { - expect(() => makeProblemDetailsSchema('NestedUnknownFixtureProblem', 400, { unsafe })).toThrow( - /concrete/u, - ); + expect(() => makeProblemDetailsSchema('NestedUnknownFixtureProblem', 400, { unsafe })).toThrow(/concrete/u); } for (const literal of [undefined, Symbol('non-json-literal')]) { // @ts-expect-error JavaScript callers can provide unsupported literal values, so the runtime factory must still reject them. const unsafe = Schema.Literal(literal); - expect(() => makeProblemDetailsSchema('NonJsonLiteralFixtureProblem', 400, { unsafe })).toThrow( - /concrete/u, - ); + expect(() => makeProblemDetailsSchema('NonJsonLiteralFixtureProblem', 400, { unsafe })).toThrow(/concrete/u); } }); diff --git a/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts index 4ea124d9a..42731cb15 100644 --- a/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts +++ b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts @@ -1,5 +1,5 @@ -import { expect, it } from 'effect-rstest'; import { withUltramodernBuildIdentity } from '@app/shared-contracts/ultramodern-build'; +import { expect, it } from 'effect-rstest'; it('injected build identity updates all surfaces without mutating generated metadata', () => { const deliveryUnit = { diff --git a/app/packages/shared-contracts/tooling/modern-config.ts b/app/packages/shared-contracts/tooling/modern-config.ts index f458368c5..a328d3a2c 100644 --- a/app/packages/shared-contracts/tooling/modern-config.ts +++ b/app/packages/shared-contracts/tooling/modern-config.ts @@ -7,11 +7,7 @@ interface ExternalRequest { dependencyType?: string; request?: string; } -type ExternalResult = [ - error?: Error | undefined, - result?: string | string[], - type?: 'module-import', -]; +type ExternalResult = [error?: Error | undefined, result?: string | string[], type?: 'module-import']; export const resolveCloudflareExternal = ( { dependencyType, request }: ExternalRequest, @@ -25,8 +21,7 @@ export const resolveCloudflareExternal = ( return []; } const specifier = isNodeBuiltin && !request.startsWith('node:') ? `node:${request}` : request; - const nativeImport = - dependencyType?.startsWith('commonjs') === true ? [specifier, 'default'] : specifier; + const nativeImport = dependencyType?.startsWith('commonjs') === true ? [specifier, 'default'] : specifier; return [undefined, nativeImport, 'module-import']; }; @@ -85,10 +80,7 @@ interface ReplacementResource { } const retainWorkerLoader = (resource: ReplacementResource) => { - resource.request = resource.request.replace( - /(?[?&])retain=[^&]*/u, - '$retain=true', - ); + resource.request = resource.request.replace(/(?[?&])retain=[^&]*/u, '$retain=true'); }; const markWorkerApiSource = (resource: ReplacementResource, sourceDirectory: string) => { diff --git a/app/packages/shared-contracts/tsconfig.json b/app/packages/shared-contracts/tsconfig.json index 8c221943e..dad5c771d 100644 --- a/app/packages/shared-contracts/tsconfig.json +++ b/app/packages/shared-contracts/tsconfig.json @@ -9,7 +9,9 @@ "noEmit": false, "outDir": "../../node_modules/.cache/tsgo/declarations/packages__shared-contracts", "tsBuildInfoFile": "../../node_modules/.cache/tsgo/packages__shared-contracts.tsbuildinfo", - "types": ["node"] + "types": [ + "node" + ] }, "include": [ "src" diff --git a/app/patches/@module-federation__bridge-react@2.8.0.patch b/app/patches/@module-federation__bridge-react@2.9.0.patch similarity index 51% rename from app/patches/@module-federation__bridge-react@2.8.0.patch rename to app/patches/@module-federation__bridge-react@2.9.0.patch index d736aff33..0f8107f18 100644 --- a/app/patches/@module-federation__bridge-react@2.8.0.patch +++ b/app/patches/@module-federation__bridge-react@2.9.0.patch @@ -1,141 +1,3 @@ -diff --git a/dist/lazy-load-component-plugin-FKp6nQa-.js b/dist/lazy-load-component-plugin-FKp6nQa-.js ---- a/dist/lazy-load-component-plugin-FKp6nQa-.js -+++ b/dist/lazy-load-component-plugin-FKp6nQa-.js -@@ -236,6 +236,41 @@ function getTargetModuleInfo(id, instance) { - remoteEntry - }; - } -+function normalizeHref(href) { -+ if (typeof document === "undefined") { -+ return href; -+ } -+ try { -+ return new URL(href, document.baseURI).href; -+ } catch { -+ return href; -+ } -+} -+function isStylesheetLink(link) { -+ return link.relList.contains("stylesheet") || link.rel.toLowerCase().split(/\s+/u).includes("stylesheet"); -+} -+function hasStylesheetLinkInHead(href, ignoredLink) { -+ if (typeof document === "undefined" || !document.head) { -+ return false; -+ } -+ const normalizedHref = normalizeHref(href); -+ return Array.from( -+ document.head.querySelectorAll("link[href]") -+ ).some( -+ (link) => link !== ignoredLink && isStylesheetLink(link) && normalizeHref(link.href) === normalizedHref -+ ); -+} -+function StylesheetAsset({ href }) { -+ const [shouldRender, setShouldRender] = React.useState(true); -+ const linkRef = React.useRef(null); -+ React.useEffect(() => { -+ setShouldRender(!hasStylesheetLinkInHead(href, linkRef.current)); -+ }, [href]); -+ if (!shouldRender) { -+ return null; -+ } -+ return /* @__PURE__ */ React.createElement("link", { ref: linkRef, href, rel: "stylesheet", type: "text/css" }); -+} - function collectSSRAssets(options) { - const { - id, -@@ -254,15 +289,19 @@ function collectSSRAssets(options) { - } - const { module: targetModule, publicPath, remoteEntry } = moduleAndPublicPath; - if (injectLink) { -+ const stylesheetHrefs = /* @__PURE__ */ new Set(); - [...targetModule.assets.css.sync, ...targetModule.assets.css.async].sort().forEach((file, index) => { -+ const href = `${publicPath}${file}`; -+ if (stylesheetHrefs.has(href)) { -+ return; -+ } -+ stylesheetHrefs.add(href); - links.push( - /* @__PURE__ */ React.createElement( -- "link", -+ StylesheetAsset, - { - key: `${file.split(".")[0]}_${index}`, -- href: `${publicPath}${file}`, -- rel: "stylesheet", -- type: "text/css" -+ href - } - ) - ); -diff --git a/dist/lazy-load-component-plugin-DEu-DfZt.mjs b/dist/lazy-load-component-plugin-DEu-DfZt.mjs ---- a/dist/lazy-load-component-plugin-DEu-DfZt.mjs -+++ b/dist/lazy-load-component-plugin-DEu-DfZt.mjs -@@ -235,6 +235,41 @@ function getTargetModuleInfo(id, instance) { - remoteEntry - }; - } -+function normalizeHref(href) { -+ if (typeof document === "undefined") { -+ return href; -+ } -+ try { -+ return new URL(href, document.baseURI).href; -+ } catch { -+ return href; -+ } -+} -+function isStylesheetLink(link) { -+ return link.relList.contains("stylesheet") || link.rel.toLowerCase().split(/\s+/u).includes("stylesheet"); -+} -+function hasStylesheetLinkInHead(href, ignoredLink) { -+ if (typeof document === "undefined" || !document.head) { -+ return false; -+ } -+ const normalizedHref = normalizeHref(href); -+ return Array.from( -+ document.head.querySelectorAll("link[href]") -+ ).some( -+ (link) => link !== ignoredLink && isStylesheetLink(link) && normalizeHref(link.href) === normalizedHref -+ ); -+} -+function StylesheetAsset({ href }) { -+ const [shouldRender, setShouldRender] = useState(true); -+ const linkRef = useRef(null); -+ useEffect(() => { -+ setShouldRender(!hasStylesheetLinkInHead(href, linkRef.current)); -+ }, [href]); -+ if (!shouldRender) { -+ return null; -+ } -+ return /* @__PURE__ */ React__default.createElement("link", { ref: linkRef, href, rel: "stylesheet", type: "text/css" }); -+} - function collectSSRAssets(options) { - const { - id, -@@ -253,15 +288,19 @@ function collectSSRAssets(options) { - } - const { module: targetModule, publicPath, remoteEntry } = moduleAndPublicPath; - if (injectLink) { -+ const stylesheetHrefs = /* @__PURE__ */ new Set(); - [...targetModule.assets.css.sync, ...targetModule.assets.css.async].sort().forEach((file, index) => { -+ const href = `${publicPath}${file}`; -+ if (stylesheetHrefs.has(href)) { -+ return; -+ } -+ stylesheetHrefs.add(href); - links.push( - /* @__PURE__ */ React__default.createElement( -- "link", -+ StylesheetAsset, - { - key: `${file.split(".")[0]}_${index}`, -- href: `${publicPath}${file}`, -- rel: "stylesheet", -- type: "text/css" -+ href - } - ) - ); diff --git a/dist/lazy/wrapNoSSR.d.ts b/dist/lazy/wrapNoSSR.d.ts --- a/dist/lazy/wrapNoSSR.d.ts +++ b/dist/lazy/wrapNoSSR.d.ts diff --git a/app/patches/@module-federation__dts-plugin@2.9.0.patch b/app/patches/@module-federation__dts-plugin@2.9.0.patch new file mode 100644 index 000000000..64e3bf19b --- /dev/null +++ b/app/patches/@module-federation__dts-plugin@2.9.0.patch @@ -0,0 +1,26 @@ +diff --git a/dist/esm/expose-rpc-B0rqtOKP.mjs b/dist/esm/expose-rpc-B0rqtOKP.mjs +index 1692a3e..1c99630 100644 +--- a/dist/esm/expose-rpc-B0rqtOKP.mjs ++++ b/dist/esm/expose-rpc-B0rqtOKP.mjs +@@ -1415,7 +1415,7 @@ const formatCompilerError = (error) => { + const getDependentFilesWithTsc = (rootFiles, rootDir, resolvedTsConfigPath, compilerOptions, context, typeScriptContext) => { + if (!rootFiles.length) return []; + const typeScriptPackageInfo = getTypeScriptPackageInfo(typeScriptContext); +- const listFilesTsConfigPath = writeListFilesTsConfig(rootFiles, resolvedTsConfigPath, context, compilerOptions); ++ const listFilesTsConfigPath = writeListFilesTsConfig(rootFiles, resolvedTsConfigPath, context, { ...compilerOptions, rootDir }); + try { + const dependentFiles = execFileSync(process.execPath, [ + typeScriptPackageInfo.tscBinPath, +diff --git a/dist/expose-rpc-jaGpsAVL.js b/dist/expose-rpc-jaGpsAVL.js +index 55f07d8..7828615 100644 +--- a/dist/expose-rpc-jaGpsAVL.js ++++ b/dist/expose-rpc-jaGpsAVL.js +@@ -1404,7 +1404,7 @@ const formatCompilerError = (error) => { + const getDependentFilesWithTsc = (rootFiles, rootDir, resolvedTsConfigPath, compilerOptions, context, typeScriptContext) => { + if (!rootFiles.length) return []; + const typeScriptPackageInfo = getTypeScriptPackageInfo(typeScriptContext); +- const listFilesTsConfigPath = writeListFilesTsConfig(rootFiles, resolvedTsConfigPath, context, compilerOptions); ++ const listFilesTsConfigPath = writeListFilesTsConfig(rootFiles, resolvedTsConfigPath, context, { ...compilerOptions, rootDir }); + try { + const dependentFiles = (0, child_process.execFileSync)(process.execPath, [ + typeScriptPackageInfo.tscBinPath, diff --git a/app/patches/@module-federation__modern-js-v3@2.8.0.patch b/app/patches/@module-federation__modern-js-v3@2.8.0.patch deleted file mode 100644 index e06bda04d..000000000 --- a/app/patches/@module-federation__modern-js-v3@2.8.0.patch +++ /dev/null @@ -1,268 +0,0 @@ -diff --git a/dist/cjs/cli/configPlugin.js b/dist/cjs/cli/configPlugin.js ---- a/dist/cjs/cli/configPlugin.js -+++ b/dist/cjs/cli/configPlugin.js -@@ -216,9 +216,12 @@ var __webpack_exports__ = {}; - if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); - const splitChunkConfig = chain.optimization.splitChunks.entries(); - if (!isServer) (0, utils_namespaceObject.autoDeleteSplitChunkCacheGroups)(mfConfig, splitChunkConfig); -- if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups) { -+ if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups && splitChunkConfig.chunks !== undefined && splitChunkConfig.chunks !== 'async') { -+ const previousChunks = splitChunkConfig.chunks; - splitChunkConfig.chunks = 'async'; -- external_logger_js_default().warn('splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"'); -+ if (void 0 !== previousChunks) { -+ external_logger_js_default().warn(`splitChunks.chunks = ${previousChunks} is not allowed with stream SSR mode, it will auto changed to "async"`); -+ } - } - if ((0, external_utils_js_namespaceObject.isDev)() && 'auto' === chain.output.get('publicPath')) { - var _modernjsConfig_server; -@@ -294,9 +297,12 @@ var __webpack_exports__ = {}; - if ('object' != typeof devServerConfig || !('headers' in devServerConfig)) corsWarnMsgs.unshift('Detect devServer.headers is empty, mf modern plugin will add default cors header: devServer.headers["Access-Control-Allow-Headers"] = "*". It is recommended to specify an allowlist of trusted origins instead.'); - const exposes = null == (_userConfig_csrConfig = userConfig.csrConfig) ? void 0 : _userConfig_csrConfig.exposes; - const hasExposes = exposes && Array.isArray(exposes) ? exposes.length > 0 : Object.keys(null != exposes ? exposes : {}).length > 0; -- const lazyCompilationDisabledByPlugin = hasExposes && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; -+ const remotes = null == userConfig.csrConfig ? void 0 : userConfig.csrConfig.remotes; -+ const hasRemotes = remotes && Array.isArray(remotes) ? remotes.length > 0 : Object.keys(null != remotes ? remotes : {}).length > 0; -+ const hasFederationEntries = hasExposes || hasRemotes; -+ const lazyCompilationDisabledByPlugin = hasFederationEntries && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; - if (corsWarnMsgs.length > 1 && hasExposes) external_logger_js_default().warn(corsWarnMsgs.join('\n')); -- if (lazyCompilationDisabledByPlugin) external_logger_js_default().warn('Detected exposes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer apps.'); -+ if (lazyCompilationDisabledByPlugin) external_logger_js_default().warn('Detected exposes or remotes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer and consumer apps.'); - const corsHeaders = hasExposes ? { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', -@@ -318,7 +324,7 @@ var __webpack_exports__ = {}; - }, - dev: { - assetPrefix: (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev1 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev1.assetPrefix) ? modernjsConfig.dev.assetPrefix : 'auto', -- lazyCompilation: hasExposes ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation -+ lazyCompilation: hasFederationEntries ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation - } - }; - }); -diff --git a/dist/esm/cli/configPlugin.mjs b/dist/esm/cli/configPlugin.mjs ---- a/dist/esm/cli/configPlugin.mjs -+++ b/dist/esm/cli/configPlugin.mjs -@@ -185,9 +185,12 @@ function patchBundlerConfig(options) { - if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); - var splitChunkConfig = chain.optimization.splitChunks.entries(); - if (!isServer) autoDeleteSplitChunkCacheGroups(mfConfig, splitChunkConfig); -- if (!isServer && enableSSR && splitChunkConfig && (void 0 === splitChunkConfig ? "undefined" : _type_of__(splitChunkConfig)) === 'object' && splitChunkConfig.cacheGroups) { -+ if (!isServer && enableSSR && splitChunkConfig && (void 0 === splitChunkConfig ? "undefined" : _type_of__(splitChunkConfig)) === 'object' && splitChunkConfig.cacheGroups && void 0 !== splitChunkConfig.chunks && splitChunkConfig.chunks !== 'async') { -+ var previousChunks = splitChunkConfig.chunks; - splitChunkConfig.chunks = 'async'; -- logger.warn('splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"'); -+ if (void 0 !== previousChunks) { -+ logger.warn("splitChunks.chunks = ".concat(previousChunks, " is not allowed with stream SSR mode, it will auto changed to \"async\"")); -+ } - } - if (isDev() && 'auto' === chain.output.get('publicPath')) { - var _modernjsConfig_server; -@@ -272,9 +275,12 @@ var configPlugin_moduleFederationConfigPlugin = function(userConfig) { - if ((void 0 === devServerConfig ? "undefined" : _type_of__(devServerConfig)) !== 'object' || !('headers' in devServerConfig)) corsWarnMsgs.unshift('Detect devServer.headers is empty, mf modern plugin will add default cors header: devServer.headers["Access-Control-Allow-Headers"] = "*". It is recommended to specify an allowlist of trusted origins instead.'); - var exposes = null == (_userConfig_csrConfig = userConfig.csrConfig) ? void 0 : _userConfig_csrConfig.exposes; - var hasExposes = exposes && Array.isArray(exposes) ? exposes.length > 0 : Object.keys(null != exposes ? exposes : {}).length > 0; -- var lazyCompilationDisabledByPlugin = hasExposes && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; -+ var remotes = null == userConfig.csrConfig ? void 0 : userConfig.csrConfig.remotes; -+ var hasRemotes = remotes && Array.isArray(remotes) ? remotes.length > 0 : Object.keys(null != remotes ? remotes : {}).length > 0; -+ var hasFederationEntries = hasExposes || hasRemotes; -+ var lazyCompilationDisabledByPlugin = hasFederationEntries && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; - if (corsWarnMsgs.length > 1 && hasExposes) logger.warn(corsWarnMsgs.join('\n')); -- if (lazyCompilationDisabledByPlugin) logger.warn('Detected exposes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer apps.'); -+ if (lazyCompilationDisabledByPlugin) logger.warn('Detected exposes or remotes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer and consumer apps.'); - var corsHeaders = hasExposes ? { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', -@@ -296,7 +302,7 @@ var configPlugin_moduleFederationConfigPlugin = function(userConfig) { - }, - dev: { - assetPrefix: (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev1 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev1.assetPrefix) ? modernjsConfig.dev.assetPrefix : 'auto', -- lazyCompilation: hasExposes ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation -+ lazyCompilation: hasFederationEntries ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation - } - }; - }); -diff --git a/dist/esm-node/cli/configPlugin.mjs b/dist/esm-node/cli/configPlugin.mjs ---- a/dist/esm-node/cli/configPlugin.mjs -+++ b/dist/esm-node/cli/configPlugin.mjs -@@ -156,9 +156,12 @@ function patchBundlerConfig(options) { - if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); - const splitChunkConfig = chain.optimization.splitChunks.entries(); - if (!isServer) autoDeleteSplitChunkCacheGroups(mfConfig, splitChunkConfig); -- if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups) { -+ if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups && splitChunkConfig.chunks !== undefined && splitChunkConfig.chunks !== 'async') { -+ const previousChunks = splitChunkConfig.chunks; - splitChunkConfig.chunks = 'async'; -- logger.warn('splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"'); -+ if (void 0 !== previousChunks) { -+ logger.warn(`splitChunks.chunks = ${previousChunks} is not allowed with stream SSR mode, it will auto changed to "async"`); -+ } - } - if (isDev() && 'auto' === chain.output.get('publicPath')) { - var _modernjsConfig_server; -@@ -234,9 +237,12 @@ const moduleFederationConfigPlugin = (userConfig)=>({ - if ('object' != typeof devServerConfig || !('headers' in devServerConfig)) corsWarnMsgs.unshift('Detect devServer.headers is empty, mf modern plugin will add default cors header: devServer.headers["Access-Control-Allow-Headers"] = "*". It is recommended to specify an allowlist of trusted origins instead.'); - const exposes = null == (_userConfig_csrConfig = userConfig.csrConfig) ? void 0 : _userConfig_csrConfig.exposes; - const hasExposes = exposes && Array.isArray(exposes) ? exposes.length > 0 : Object.keys(null != exposes ? exposes : {}).length > 0; -- const lazyCompilationDisabledByPlugin = hasExposes && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; -+ const remotes = null == userConfig.csrConfig ? void 0 : userConfig.csrConfig.remotes; -+ const hasRemotes = remotes && Array.isArray(remotes) ? remotes.length > 0 : Object.keys(null != remotes ? remotes : {}).length > 0; -+ const hasFederationEntries = hasExposes || hasRemotes; -+ const lazyCompilationDisabledByPlugin = hasFederationEntries && (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev.lazyCompilation) !== false; - if (corsWarnMsgs.length > 1 && hasExposes) logger.warn(corsWarnMsgs.join('\n')); -- if (lazyCompilationDisabledByPlugin) logger.warn('Detected exposes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer apps.'); -+ if (lazyCompilationDisabledByPlugin) logger.warn('Detected exposes or remotes in the Module Federation config. The Modern.js v3 Module Federation plugin will set dev.lazyCompilation to false for producer and consumer apps.'); - const corsHeaders = hasExposes ? { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', -@@ -258,7 +264,7 @@ const moduleFederationConfigPlugin = (userConfig)=>({ - }, - dev: { - assetPrefix: (null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev1 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev1.assetPrefix) ? modernjsConfig.dev.assetPrefix : 'auto', -- lazyCompilation: hasExposes ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation -+ lazyCompilation: hasFederationEntries ? false : null == modernjsConfig ? void 0 : null == (_modernjsConfig_dev2 = modernjsConfig.dev) ? void 0 : _modernjsConfig_dev2.lazyCompilation - } - }; - }); -diff --git a/dist/cjs/cli/configPlugin.js b/dist/cjs/cli/configPlugin.js ---- a/dist/cjs/cli/configPlugin.js -+++ b/dist/cjs/cli/configPlugin.js -@@ -76,8 +76,8 @@ var __webpack_exports__ = {}; - const utils_namespaceObject = require("@module-federation/rsbuild-plugin/utils"); - const external_logger_js_namespaceObject = require("../logger.js"); - var external_logger_js_default = /*#__PURE__*/ __webpack_require__.n(external_logger_js_namespaceObject); - const defaultPath = external_path_default().resolve(process.cwd(), 'module-federation.config.ts'); -- const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ -- const packageEntry = require.resolve(packageName); -+ const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath, resolveFrom)=>{ -+ const packageEntry = require.resolve(packageName, resolveFrom ? { paths: [resolveFrom] } : void 0); - let packageRoot = external_path_default().dirname(packageEntry); - while(!external_fs_default().existsSync(external_path_default().join(packageRoot, 'package.json'))){ -@@ -85,6 +85,7 @@ var __webpack_exports__ = {}; - }; - const resolveSharedStrategyPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/shared-strategy.mjs', 'dist/cjs/cli/mfRuntimePlugins/shared-strategy.js'); - const resolveInjectNodeFetchPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); -+ const resolveManifestRecoveryPlugin = ()=>resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); - const resolveNodeRuntimePlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); - const resolveNodeRecordRemoteHashPlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/recordDynamicRemoteEntryHashPlugin.mjs', 'dist/src/recordDynamicRemoteEntryHashPlugin.js'); - function setEnv(enableSSR) { -@@ -147,6 +148,7 @@ var __webpack_exports__ = {}; - patchDTSConfig(mfConfig, isServer); - injectRuntimePlugins(resolveSharedStrategyPlugin(), runtimePlugins); - if (isServer) { -+ injectRuntimePlugins(resolveManifestRecoveryPlugin(), runtimePlugins); - injectRuntimePlugins(resolveNodeRuntimePlugin(), runtimePlugins); - if ((0, external_utils_js_namespaceObject.isDev)()) injectRuntimePlugins(resolveNodeRecordRemoteHashPlugin(), runtimePlugins); - injectRuntimePlugins(resolveInjectNodeFetchPlugin(), runtimePlugins); -diff --git a/dist/esm/cli/configPlugin.mjs b/dist/esm/cli/configPlugin.mjs ---- a/dist/esm/cli/configPlugin.mjs -+++ b/dist/esm/cli/configPlugin.mjs -@@ -18,7 +18,7 @@ __webpack_require__.add({ - } - }); - var defaultPath = path.resolve(process.cwd(), 'module-federation.config.ts'); --var configPlugin_resolvePackageFile = function(packageName, esmRelativePath, cjsRelativePath) { -- var packageEntry = require.resolve(packageName); -+var configPlugin_resolvePackageFile = function(packageName, esmRelativePath, cjsRelativePath, resolveFrom) { -+ var packageEntry = require.resolve(packageName, void 0 !== resolveFrom ? { paths: [resolveFrom] } : void 0); - var packageRoot = path.dirname(packageEntry); - while(!fs.existsSync(path.join(packageRoot, 'package.json'))){ -@@ -35,5 +35,8 @@ var configPlugin_resolveInjectNodeFetchPlugin = function() { - return configPlugin_resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); - }; -+var configPlugin_resolveManifestRecoveryPlugin = function() { -+ return configPlugin_resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); -+}; - var configPlugin_resolveNodeRuntimePlugin = function() { - return configPlugin_resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); - }; -@@ -117,6 +120,7 @@ var configPlugin_patchMFConfig = function(mfConfig, isServer) { - configPlugin_patchDTSConfig(mfConfig, isServer); - configPlugin_injectRuntimePlugins(configPlugin_resolveSharedStrategyPlugin(), runtimePlugins); - if (isServer) { -+ configPlugin_injectRuntimePlugins(configPlugin_resolveManifestRecoveryPlugin(), runtimePlugins); - configPlugin_injectRuntimePlugins(configPlugin_resolveNodeRuntimePlugin(), runtimePlugins); - if (isDev()) configPlugin_injectRuntimePlugins(configPlugin_resolveNodeRecordRemoteHashPlugin(), runtimePlugins); - configPlugin_injectRuntimePlugins(configPlugin_resolveInjectNodeFetchPlugin(), runtimePlugins); -diff --git a/dist/esm-node/cli/configPlugin.mjs b/dist/esm-node/cli/configPlugin.mjs ---- a/dist/esm-node/cli/configPlugin.mjs -+++ b/dist/esm-node/cli/configPlugin.mjs -@@ -14,7 +14,7 @@ __webpack_require__.add({ - } - }); - const defaultPath = path.resolve(process.cwd(), 'module-federation.config.ts'); --const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ -- const packageEntry = require.resolve(packageName); -+const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath, resolveFrom)=>{ -+ const packageEntry = require.resolve(packageName, resolveFrom ? { paths: [resolveFrom] } : undefined); - let packageRoot = path.dirname(packageEntry); - while(!fs.existsSync(path.join(packageRoot, 'package.json'))){ -@@ -25,6 +25,7 @@ const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ - }; - const resolveSharedStrategyPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/shared-strategy.mjs', 'dist/cjs/cli/mfRuntimePlugins/shared-strategy.js'); - const resolveInjectNodeFetchPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); -+const resolveManifestRecoveryPlugin = ()=>resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); - const resolveNodeRuntimePlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); - const resolveNodeRecordRemoteHashPlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/recordDynamicRemoteEntryHashPlugin.mjs', 'dist/src/recordDynamicRemoteEntryHashPlugin.js'); - function setEnv(enableSSR) { -@@ -86,6 +87,7 @@ const patchMFConfig = (mfConfig, isServer)=>{ - patchDTSConfig(mfConfig, isServer); - injectRuntimePlugins(resolveSharedStrategyPlugin(), runtimePlugins); - if (isServer) { -+ injectRuntimePlugins(resolveManifestRecoveryPlugin(), runtimePlugins); - injectRuntimePlugins(resolveNodeRuntimePlugin(), runtimePlugins); - if (isDev()) injectRuntimePlugins(resolveNodeRecordRemoteHashPlugin(), runtimePlugins); - injectRuntimePlugins(resolveInjectNodeFetchPlugin(), runtimePlugins); -diff --git a/dist/esm/react/data-fetch.mjs b/dist/esm/react/data-fetch.mjs ---- a/dist/esm/react/data-fetch.mjs -+++ b/dist/esm/react/data-fetch.mjs -@@ -1 +1,6 @@ --export * from "@module-federation/bridge-react/data-fetch"; -+import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react/data-fetch"; -+export * from "@module-federation/bridge-react/data-fetch"; -+export const createLazyComponent = (options)=>createBridgeLazyComponent({ -+ injectLink: false, -+ ...options, -+ }); -diff --git a/dist/cjs/server/index.js b/dist/cjs/server/index.js ---- a/dist/cjs/server/index.js -+++ b/dist/cjs/server/index.js -@@ -74,2 +74,5 @@ Object.defineProperty(exports, '__esModule', { - value: true - }); -+module.exports = Object.assign(__webpack_exports__["default"], { -+ staticServePlugin: __webpack_exports__.staticServePlugin -+}); -diff --git a/dist/esm/react/index.mjs b/dist/esm/react/index.mjs ---- a/dist/esm/react/index.mjs -+++ b/dist/esm/react/index.mjs -@@ -1 +1,6 @@ --export * from "@module-federation/bridge-react"; -+import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react"; -+export * from "@module-federation/bridge-react"; -+export const createLazyComponent = (options)=>createBridgeLazyComponent({ -+ injectLink: false, -+ ...options, -+ }); -diff --git a/dist/esm/react/v18.mjs b/dist/esm/react/v18.mjs ---- a/dist/esm/react/v18.mjs -+++ b/dist/esm/react/v18.mjs -@@ -1 +1,6 @@ --export * from "@module-federation/bridge-react/v18"; -+import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react/v18"; -+export * from "@module-federation/bridge-react/v18"; -+export const createLazyComponent = (options)=>createBridgeLazyComponent({ -+ injectLink: false, -+ ...options, -+ }); -diff --git a/dist/esm/react/v19.mjs b/dist/esm/react/v19.mjs ---- a/dist/esm/react/v19.mjs -+++ b/dist/esm/react/v19.mjs -@@ -1 +1,6 @@ --export * from "@module-federation/bridge-react/v19"; -+import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react/v19"; -+export * from "@module-federation/bridge-react/v19"; -+export const createLazyComponent = (options)=>createBridgeLazyComponent({ -+ injectLink: false, -+ ...options, -+ }); diff --git a/app/patches/@module-federation__modern-js-v3@2.9.0.patch b/app/patches/@module-federation__modern-js-v3@2.9.0.patch new file mode 100644 index 000000000..622561752 --- /dev/null +++ b/app/patches/@module-federation__modern-js-v3@2.9.0.patch @@ -0,0 +1,268 @@ +diff --git a/dist/cjs/cli/configPlugin.js b/dist/cjs/cli/configPlugin.js +index 247cf19..d3e6a21b 100644 +--- a/dist/cjs/cli/configPlugin.js ++++ b/dist/cjs/cli/configPlugin.js +@@ -74,8 +74,8 @@ var __webpack_exports__ = {}; + const external_logger_js_namespaceObject = require("../logger.js"); + var external_logger_js_default = /*#__PURE__*/ __webpack_require__.n(external_logger_js_namespaceObject); + const defaultPath = external_path_default().resolve(process.cwd(), 'module-federation.config.ts'); +- const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ +- const packageEntry = require.resolve(packageName); ++ const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath, resolveFrom)=>{ ++ const packageEntry = require.resolve(packageName, resolveFrom ? { paths: [resolveFrom] } : void 0); + let packageRoot = external_path_default().dirname(packageEntry); + while(!external_fs_default().existsSync(external_path_default().join(packageRoot, 'package.json'))){ + const parentDir = external_path_default().dirname(packageRoot); +@@ -86,6 +86,7 @@ var __webpack_exports__ = {}; + }; + const resolveSharedStrategyPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/shared-strategy.mjs', 'dist/cjs/cli/mfRuntimePlugins/shared-strategy.js'); + const resolveInjectNodeFetchPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); ++ const resolveManifestRecoveryPlugin = ()=>resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); + const resolveNodeRuntimePlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); + const resolveNodeRecordRemoteHashPlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/recordDynamicRemoteEntryHashPlugin.mjs', 'dist/src/recordDynamicRemoteEntryHashPlugin.js'); + function setEnv(enableSSR) { +@@ -137,16 +138,49 @@ var __webpack_exports__ = {}; + } + } + }; ++ const patchReactRuntimeSharing = (mfConfig)=>{ ++ let shared = mfConfig.shared; ++ if (!shared) return; ++ const entries = Array.isArray(shared) ? shared : [ ++ shared ++ ]; ++ const hasReact = entries.some((entry)=>'string' == typeof entry ? entry === 'react' : entry && Object.prototype.hasOwnProperty.call(entry, 'react')); ++ if (!hasReact) return; ++ for (const request of [ ++ 'react/jsx-runtime', ++ 'react/jsx-dev-runtime' ++ ]){ ++ const configured = entries.some((entry)=>'string' == typeof entry ? entry === request : entry && Object.prototype.hasOwnProperty.call(entry, request)); ++ if (configured) continue; ++ if (Array.isArray(shared)) shared = [ ++ ...shared, ++ { ++ [request]: { ++ singleton: true ++ } ++ } ++ ]; ++ else shared = { ++ ...shared, ++ [request]: { ++ singleton: true ++ } ++ }; ++ } ++ mfConfig.shared = shared; ++ }; + const patchMFConfig = (mfConfig, isServer)=>{ + (0, utils_namespaceObject.addDataFetchExposes)(mfConfig.exposes, isServer); + if (void 0 === mfConfig.remoteType) mfConfig.remoteType = "script"; + if (!mfConfig.name) throw new Error(`${external_constant_js_namespaceObject.PLUGIN_IDENTIFIER} mfConfig.name can not be empty!`); ++ patchReactRuntimeSharing(mfConfig); + const runtimePlugins = [ + ...mfConfig.runtimePlugins || [] + ]; + patchDTSConfig(mfConfig, isServer); + injectRuntimePlugins(resolveSharedStrategyPlugin(), runtimePlugins); + if (isServer) { ++ injectRuntimePlugins(resolveManifestRecoveryPlugin(), runtimePlugins); + injectRuntimePlugins(resolveNodeRuntimePlugin(), runtimePlugins); + if ((0, external_utils_js_namespaceObject.isDev)()) injectRuntimePlugins(resolveNodeRecordRemoteHashPlugin(), runtimePlugins); + injectRuntimePlugins(resolveInjectNodeFetchPlugin(), runtimePlugins); +@@ -224,7 +258,7 @@ var __webpack_exports__ = {}; + if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); + const splitChunkConfig = chain.optimization.splitChunks.entries(); + if (!isServer) (0, utils_namespaceObject.autoDeleteSplitChunkCacheGroups)(mfConfig, splitChunkConfig); +- if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups) { ++ if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups && splitChunkConfig.chunks !== undefined && splitChunkConfig.chunks !== 'async') { + const previousChunks = splitChunkConfig.chunks; + splitChunkConfig.chunks = 'async'; + if (previousChunks && 'async' !== previousChunks) external_logger_js_default().warn(`splitChunks.chunks = "${previousChunks}" is not allowed with stream SSR mode; forcing "async"`); +diff --git a/dist/esm/cli/configPlugin.mjs b/dist/esm/cli/configPlugin.mjs +index a390de85925ec6f5abff007aa7ba7da8890942d1..b45f6bae8b19d08c50367d2b31783cd68a997773 100644 +--- a/dist/esm/cli/configPlugin.mjs ++++ b/dist/esm/cli/configPlugin.mjs +@@ -19,8 +19,8 @@ __webpack_require__.add({ + } + }); + var defaultPath = path.resolve(process.cwd(), 'module-federation.config.ts'); +-var configPlugin_resolvePackageFile = function(packageName, esmRelativePath, cjsRelativePath) { +- var packageEntry = require.resolve(packageName); ++var configPlugin_resolvePackageFile = function(packageName, esmRelativePath, cjsRelativePath, resolveFrom) { ++ var packageEntry = require.resolve(packageName, void 0 !== resolveFrom ? { paths: [resolveFrom] } : void 0); + var packageRoot = path.dirname(packageEntry); + while(!fs.existsSync(path.join(packageRoot, 'package.json'))){ + var parentDir = path.dirname(packageRoot); +@@ -35,6 +35,9 @@ var configPlugin_resolveSharedStrategyPlugin = function() { + var configPlugin_resolveInjectNodeFetchPlugin = function() { + return configPlugin_resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); + }; ++var configPlugin_resolveManifestRecoveryPlugin = function() { ++ return configPlugin_resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); ++}; + var configPlugin_resolveNodeRuntimePlugin = function() { + return configPlugin_resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); + }; +@@ -109,14 +112,47 @@ var configPlugin_patchDTSConfig = function(mfConfig, isServer) { + } + } + }; ++var configPlugin_patchReactRuntimeSharing = function(mfConfig) { ++ var shared = mfConfig.shared; ++ if (!shared) return; ++ var entries = Array.isArray(shared) ? shared : [ ++ shared ++ ]; ++ var hasReact = entries.some(function(entry) { ++ return 'string' == typeof entry ? entry === 'react' : entry && Object.prototype.hasOwnProperty.call(entry, 'react'); ++ }); ++ if (!hasReact) return; ++ var requests = [ ++ 'react/jsx-runtime', ++ 'react/jsx-dev-runtime' ++ ]; ++ for(var index = 0; index < requests.length; index++){ ++ var request = requests[index]; ++ var configured = entries.some(function(entry) { ++ return 'string' == typeof entry ? entry === request : entry && Object.prototype.hasOwnProperty.call(entry, request); ++ }); ++ if (configured) continue; ++ var runtimeEntry = {}; ++ runtimeEntry[request] = { ++ singleton: true ++ }; ++ if (Array.isArray(shared)) shared = shared.concat([ ++ runtimeEntry ++ ]); ++ else shared = Object.assign({}, shared, runtimeEntry); ++ } ++ mfConfig.shared = shared; ++}; + var configPlugin_patchMFConfig = function(mfConfig, isServer) { + addDataFetchExposes(mfConfig.exposes, isServer); + if (void 0 === mfConfig.remoteType) mfConfig.remoteType = "script"; + if (!mfConfig.name) throw new Error("".concat(PLUGIN_IDENTIFIER, " mfConfig.name can not be empty!")); ++ configPlugin_patchReactRuntimeSharing(mfConfig); + var runtimePlugins = _to_consumable_array__(mfConfig.runtimePlugins || []); + configPlugin_patchDTSConfig(mfConfig, isServer); + configPlugin_injectRuntimePlugins(configPlugin_resolveSharedStrategyPlugin(), runtimePlugins); + if (isServer) { ++ configPlugin_injectRuntimePlugins(configPlugin_resolveManifestRecoveryPlugin(), runtimePlugins); + configPlugin_injectRuntimePlugins(configPlugin_resolveNodeRuntimePlugin(), runtimePlugins); + if (isDev()) configPlugin_injectRuntimePlugins(configPlugin_resolveNodeRecordRemoteHashPlugin(), runtimePlugins); + configPlugin_injectRuntimePlugins(configPlugin_resolveInjectNodeFetchPlugin(), runtimePlugins); +@@ -193,7 +229,7 @@ function patchBundlerConfig(options) { + if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); + var splitChunkConfig = chain.optimization.splitChunks.entries(); + if (!isServer) autoDeleteSplitChunkCacheGroups(mfConfig, splitChunkConfig); +- if (!isServer && enableSSR && splitChunkConfig && (void 0 === splitChunkConfig ? "undefined" : _type_of__(splitChunkConfig)) === 'object' && splitChunkConfig.cacheGroups) { ++ if (!isServer && enableSSR && splitChunkConfig && (void 0 === splitChunkConfig ? "undefined" : _type_of__(splitChunkConfig)) === 'object' && splitChunkConfig.cacheGroups && void 0 !== splitChunkConfig.chunks && splitChunkConfig.chunks !== 'async') { + var previousChunks = splitChunkConfig.chunks; + splitChunkConfig.chunks = 'async'; + if (previousChunks && 'async' !== previousChunks) logger.warn('splitChunks.chunks = "'.concat(previousChunks, '" is not allowed with stream SSR mode; forcing "async"')); +diff --git a/dist/esm/react/data-fetch.mjs b/dist/esm/react/data-fetch.mjs +index afb5a8f6a5d1f008382e26b047c7f85ac8f9153a..7cc37e9c7dde5c3ea007ebe547cd71f6239b7447 100644 +--- a/dist/esm/react/data-fetch.mjs ++++ b/dist/esm/react/data-fetch.mjs +@@ -1 +1,6 @@ ++import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react/data-fetch"; + export * from "@module-federation/bridge-react/data-fetch"; ++export const createLazyComponent = (options)=>createBridgeLazyComponent({ ++ injectLink: false, ++ ...options, ++ }); +diff --git a/dist/esm/react/index.mjs b/dist/esm/react/index.mjs +index 7135f3a48be78722f2a46f8b2d366ef5b34d90ed..8454a2b3f7296d829df01cf3894f5912729e37fe 100644 +--- a/dist/esm/react/index.mjs ++++ b/dist/esm/react/index.mjs +@@ -1 +1,6 @@ ++import { createLazyComponent as createBridgeLazyComponent } from "@module-federation/bridge-react"; + export * from "@module-federation/bridge-react"; ++export const createLazyComponent = (options)=>createBridgeLazyComponent({ ++ injectLink: false, ++ ...options, ++ }); +diff --git a/dist/esm-node/cli/configPlugin.mjs b/dist/esm-node/cli/configPlugin.mjs +index 41356987ccc29a62903944cfb4a559dd46d1cb90..ccd7d5889d2ddf5b29d25c0edb078efe7783dcbe 100644 +--- a/dist/esm-node/cli/configPlugin.mjs ++++ b/dist/esm-node/cli/configPlugin.mjs +@@ -13,8 +13,8 @@ __webpack_require__.add({ + } + }); + const defaultPath = path.resolve(process.cwd(), 'module-federation.config.ts'); +-const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ +- const packageEntry = require.resolve(packageName); ++const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath, resolveFrom)=>{ ++ const packageEntry = require.resolve(packageName, resolveFrom ? { paths: [resolveFrom] } : undefined); + let packageRoot = path.dirname(packageEntry); + while(!fs.existsSync(path.join(packageRoot, 'package.json'))){ + const parentDir = path.dirname(packageRoot); +@@ -25,6 +25,7 @@ const resolvePackageFile = (packageName, esmRelativePath, cjsRelativePath)=>{ + }; + const resolveSharedStrategyPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/shared-strategy.mjs', 'dist/cjs/cli/mfRuntimePlugins/shared-strategy.js'); + const resolveInjectNodeFetchPlugin = ()=>resolvePackageFile('@module-federation/modern-js-v3', 'dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs', 'dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js'); ++const resolveManifestRecoveryPlugin = ()=>resolvePackageFile('@modern-js/runtime', 'dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs', 'dist/cjs/module-federation/manifest-recovery-runtime-plugin.js', process.cwd()); + const resolveNodeRuntimePlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/runtimePlugin.mjs', 'dist/src/runtimePlugin.js'); + const resolveNodeRecordRemoteHashPlugin = ()=>resolvePackageFile('@module-federation/node', 'dist/src/recordDynamicRemoteEntryHashPlugin.mjs', 'dist/src/recordDynamicRemoteEntryHashPlugin.js'); + function setEnv(enableSSR) { +@@ -76,16 +77,49 @@ const patchDTSConfig = (mfConfig, isServer)=>{ + } + } + }; ++const patchReactRuntimeSharing = (mfConfig)=>{ ++ let shared = mfConfig.shared; ++ if (!shared) return; ++ const entries = Array.isArray(shared) ? shared : [ ++ shared ++ ]; ++ const hasReact = entries.some((entry)=>'string' == typeof entry ? entry === 'react' : entry && Object.prototype.hasOwnProperty.call(entry, 'react')); ++ if (!hasReact) return; ++ for (const request of [ ++ 'react/jsx-runtime', ++ 'react/jsx-dev-runtime' ++ ]){ ++ const configured = entries.some((entry)=>'string' == typeof entry ? entry === request : entry && Object.prototype.hasOwnProperty.call(entry, request)); ++ if (configured) continue; ++ if (Array.isArray(shared)) shared = [ ++ ...shared, ++ { ++ [request]: { ++ singleton: true ++ } ++ } ++ ]; ++ else shared = { ++ ...shared, ++ [request]: { ++ singleton: true ++ } ++ }; ++ } ++ mfConfig.shared = shared; ++}; + const patchMFConfig = (mfConfig, isServer)=>{ + addDataFetchExposes(mfConfig.exposes, isServer); + if (void 0 === mfConfig.remoteType) mfConfig.remoteType = "script"; + if (!mfConfig.name) throw new Error(`${PLUGIN_IDENTIFIER} mfConfig.name can not be empty!`); ++ patchReactRuntimeSharing(mfConfig); + const runtimePlugins = [ + ...mfConfig.runtimePlugins || [] + ]; + patchDTSConfig(mfConfig, isServer); + injectRuntimePlugins(resolveSharedStrategyPlugin(), runtimePlugins); + if (isServer) { ++ injectRuntimePlugins(resolveManifestRecoveryPlugin(), runtimePlugins); + injectRuntimePlugins(resolveNodeRuntimePlugin(), runtimePlugins); + if (isDev()) injectRuntimePlugins(resolveNodeRecordRemoteHashPlugin(), runtimePlugins); + injectRuntimePlugins(resolveInjectNodeFetchPlugin(), runtimePlugins); +@@ -163,7 +197,7 @@ function patchBundlerConfig(options) { + if (!chain.output.get('uniqueName')) chain.output.uniqueName(mfConfig.name); + const splitChunkConfig = chain.optimization.splitChunks.entries(); + if (!isServer) autoDeleteSplitChunkCacheGroups(mfConfig, splitChunkConfig); +- if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups) { ++ if (!isServer && enableSSR && splitChunkConfig && 'object' == typeof splitChunkConfig && splitChunkConfig.cacheGroups && splitChunkConfig.chunks !== undefined && splitChunkConfig.chunks !== 'async') { + const previousChunks = splitChunkConfig.chunks; + splitChunkConfig.chunks = 'async'; + if (previousChunks && 'async' !== previousChunks) logger.warn(`splitChunks.chunks = "${previousChunks}" is not allowed with stream SSR mode; forcing "async"`); diff --git a/app/patches/@module-federation__runtime-core@2.9.0.patch b/app/patches/@module-federation__runtime-core@2.9.0.patch new file mode 100644 index 000000000..fc15b64d0 --- /dev/null +++ b/app/patches/@module-federation__runtime-core@2.9.0.patch @@ -0,0 +1,13 @@ +diff --git a/dist/remote/index.d.ts b/dist/remote/index.d.ts +index 3bde403c13ae782e9abfbc579c5e61db40aa130f..3a1f44032c3ed8b289b87beb1dbf30d5db33184e 100644 +--- a/dist/remote/index.d.ts ++++ b/dist/remote/index.d.ts +@@ -6,7 +6,7 @@ import { AsyncWaterfallHook } from "../utils/hooks/asyncWaterfallHooks.js"; + import { PluginSystem } from "../utils/hooks/pluginSystem.js"; + import { ModuleFederation } from "../core.js"; + import { CallFrom, Options, Remote, RemoteEntryExports, RemoteInfo, UserOptions } from "../type/config.js"; +-import { PreloadAssets, PreloadOptions, PreloadRemoteArgs, PreloadRemoteResult } from "../type/preload.js"; ++import { PreloadAssets, PreloadOptions, PreloadRemoteArgs, PreloadRemoteResult, ResourceLoadContext } from "../type/preload.js"; + import { GlobalModuleInfo, ModuleInfo } from "@module-federation/sdk"; + + //#region src/remote/index.d.ts diff --git a/app/patches/@tanstack__router-core@1.171.21.patch b/app/patches/@tanstack__router-core@1.171.21.patch deleted file mode 100644 index 42b32ce7b..000000000 --- a/app/patches/@tanstack__router-core@1.171.21.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/dist/cjs/ssr/types.d.cts b/dist/cjs/ssr/types.d.cts -index 9050041c8dc7c5086f3b02126fab850d3d22c078..e1484446a3a699f5f49146a47bcf8e5d69041821 100644 ---- a/dist/cjs/ssr/types.d.cts -+++ b/dist/cjs/ssr/types.d.cts -@@ -2,7 +2,7 @@ import { Manifest } from '../manifest.cjs'; - import { MakeRouteMatch } from '../Matches.cjs'; - export interface DehydratedMatch { - i: MakeRouteMatch['id']; -- b?: MakeRouteMatch['__beforeLoadContext']; -+ b?: Record; - l?: MakeRouteMatch['loaderData']; - e?: MakeRouteMatch['error']; - u: MakeRouteMatch['updatedAt']; -diff --git a/dist/esm/ssr/types.d.ts b/dist/esm/ssr/types.d.ts -index 9f57d89eef903ea2c14adfa5ddc2e8083c721411..f71eaac2f7477f70d1b0f9dc91971432776e72cc 100644 ---- a/dist/esm/ssr/types.d.ts -+++ b/dist/esm/ssr/types.d.ts -@@ -2,7 +2,7 @@ import { Manifest } from '../manifest.js'; - import { MakeRouteMatch } from '../Matches.js'; - export interface DehydratedMatch { - i: MakeRouteMatch['id']; -- b?: MakeRouteMatch['__beforeLoadContext']; -+ b?: Record; - l?: MakeRouteMatch['loaderData']; - e?: MakeRouteMatch['error']; - u: MakeRouteMatch['updatedAt']; -diff --git a/src/ssr/types.ts b/src/ssr/types.ts -index 27bf111843058dcd08c5a6136692413f03302df5..b24c7800aec7fa9811be7fe5c744b8de7e7b40fe 100644 ---- a/src/ssr/types.ts -+++ b/src/ssr/types.ts -@@ -3,7 +3,7 @@ import type { MakeRouteMatch } from '../Matches' - - export interface DehydratedMatch { - i: MakeRouteMatch['id'] -- b?: MakeRouteMatch['__beforeLoadContext'] -+ b?: Record - l?: MakeRouteMatch['loaderData'] - e?: MakeRouteMatch['error'] - u: MakeRouteMatch['updatedAt'] diff --git a/app/patches/effect-cli-metadata@4.0.0-rc.112.patch b/app/patches/effect-cli-metadata@4.0.0-rc.112.patch new file mode 100644 index 000000000..9908e9f30 --- /dev/null +++ b/app/patches/effect-cli-metadata@4.0.0-rc.112.patch @@ -0,0 +1,20 @@ +diff --git a/dist/unstable/cli/Param.d.ts b/dist/unstable/cli/Param.d.ts +--- a/dist/unstable/cli/Param.d.ts ++++ b/dist/unstable/cli/Param.d.ts +@@ -2310,5 +2310,16 @@ + */ + (self: Param, orElse: LazyArg>): Param>; + }; ++/** ++ * Gets param metadata by traversing the structure. ++ * ++ * @internal ++ */ ++export declare const getParamMetadata: (param: Param) => { ++ readonly isOptional: boolean; ++ readonly isVariadic: boolean; ++ readonly variadicMin: Option.Option; ++ readonly variadicMax: Option.Option; ++}; + export {}; + //# sourceMappingURL=Param.d.ts.map \ No newline at end of file diff --git a/app/patches/eslint-plugin-perfectionist@5.10.1.patch b/app/patches/eslint-plugin-perfectionist@5.10.1.patch new file mode 100644 index 000000000..93600dbd9 --- /dev/null +++ b/app/patches/eslint-plugin-perfectionist@5.10.1.patch @@ -0,0 +1,1158 @@ +diff --git a/dist/rules/sort-array-includes.js b/dist/rules/sort-array-includes.js +index 79f4ec229c..585fa165d1 100644 +--- a/dist/rules/sort-array-includes.js ++++ b/dist/rules/sort-array-includes.js +@@ -18,7 +18,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { additionalCustomGroupMatchOptionsJsonSchema } from './sort-arrays/types.js' + import { sortArray } from './sort-arrays/sort-array.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-arrays/compute-array-elements.js b/dist/rules/sort-arrays/compute-array-elements.js +index 9ab801256a..3dd907cf62 100644 +--- a/dist/rules/sort-arrays/compute-array-elements.js ++++ b/dist/rules/sort-arrays/compute-array-elements.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes array elements for the given expression. + * +diff --git a/dist/rules/sort-arrays/compute-node-name.js b/dist/rules/sort-arrays/compute-node-name.js +index 592c8740bc..f8aecb3362 100644 +--- a/dist/rules/sort-arrays/compute-node-name.js ++++ b/dist/rules/sort-arrays/compute-node-name.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an array member. + * +diff --git a/dist/rules/sort-arrays/sort-array.js b/dist/rules/sort-arrays/sort-array.js +index e063c8f44d..80d37a5b03 100644 +--- a/dist/rules/sort-arrays/sort-array.js ++++ b/dist/rules/sort-arrays/sort-array.js +@@ -19,7 +19,7 @@ import { allSelectors } from './types.js' + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' + import { computeArrayElements } from './compute-array-elements.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function sortArray({ + cachedGroupsByModifiersAndSelectors, + mustHaveMatchedContextOptions, +diff --git a/dist/rules/sort-arrays.js b/dist/rules/sort-arrays.js +index 3852050c16..7e83dfa31e 100644 +--- a/dist/rules/sort-arrays.js ++++ b/dist/rules/sort-arrays.js +@@ -18,7 +18,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { additionalCustomGroupMatchOptionsJsonSchema } from './sort-arrays/types.js' + import { sortArray } from './sort-arrays/sort-array.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-classes/compute-dependencies-by-sorting-node.js b/dist/rules/sort-classes/compute-dependencies-by-sorting-node.js +index 4340686505..fb4576f808 100644 +--- a/dist/rules/sort-classes/compute-dependencies-by-sorting-node.js ++++ b/dist/rules/sort-classes/compute-dependencies-by-sorting-node.js +@@ -3,7 +3,7 @@ import { matches } from '../../utils/matches.js' + import { computeDependenciesBySortingNode as computeDependenciesBySortingNode$1 } from '../../utils/compute-dependencies-by-sorting-node.js' + import { computeParentNodesWithTypes } from '../../utils/compute-parent-nodes-with-types.js' + import { computeIdentifierNameDetails } from './compute-identifier-name-details.js' +-import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/types' + function computeDependenciesBySortingNode({ + ignoreCallbackDependenciesPatterns, + sortingNodes, +diff --git a/dist/rules/sort-classes/compute-dependencies.js b/dist/rules/sort-classes/compute-dependencies.js +index 3e000595fb..ced91e7496 100644 +--- a/dist/rules/sort-classes/compute-dependencies.js ++++ b/dist/rules/sort-classes/compute-dependencies.js +@@ -1,7 +1,7 @@ + import { matches } from '../../utils/matches.js' + import { computeIdentifierNameDetails } from './compute-identifier-name-details.js' + import { computeDependencyName } from './compute-dependency-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the dependencies of a class member AST node. + * +diff --git a/dist/rules/sort-classes/compute-identifier-name-details.js b/dist/rules/sort-classes/compute-identifier-name-details.js +index 91b71a1476..40f3f4ee87 100644 +--- a/dist/rules/sort-classes/compute-identifier-name-details.js ++++ b/dist/rules/sort-classes/compute-identifier-name-details.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name details of an identifier. + * +diff --git a/dist/rules/sort-classes/compute-matched-context-options.js b/dist/rules/sort-classes/compute-matched-context-options.js +index 64e9f7ee25..3fc513ba30 100644 +--- a/dist/rules/sort-classes/compute-matched-context-options.js ++++ b/dist/rules/sort-classes/compute-matched-context-options.js +@@ -1,7 +1,7 @@ + import { passesAllNamesMatchPatternFilter } from '../../utils/context-matching/passes-all-names-match-pattern-filter.js' + import { passesAstSelectorFilter } from '../../utils/context-matching/passes-ast-selector-filter.js' + import { computeMethodOrPropertyNameDetails } from './node-info/compute-method-or-property-name-details.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for a given class node. + * +diff --git a/dist/rules/sort-classes/compute-overload-signature-groups.js b/dist/rules/sort-classes/compute-overload-signature-groups.js +index 8824b0a12f..c40da3eaba 100644 +--- a/dist/rules/sort-classes/compute-overload-signature-groups.js ++++ b/dist/rules/sort-classes/compute-overload-signature-groups.js +@@ -1,7 +1,7 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' + import { isSortable } from '../../utils/is-sortable.js' + import { OverloadSignatureGroup } from '../../utils/overload-signature/overload-signature-group.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Returns a list of groups of overload signatures. + * +diff --git a/dist/rules/sort-classes/is-known-class-element.js b/dist/rules/sort-classes/is-known-class-element.js +index 8105ef40fe..8f5bbef6b4 100644 +--- a/dist/rules/sort-classes/is-known-class-element.js ++++ b/dist/rules/sort-classes/is-known-class-element.js +@@ -1,5 +1,5 @@ + import '../../utils/assert-is-never.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks whether a class element is supported by the sort-classes rule. + * +diff --git a/dist/rules/sort-classes/node-info/common-modifiers.js b/dist/rules/sort-classes/node-info/common-modifiers.js +index c5504f4ebd..d002c0efe3 100644 +--- a/dist/rules/sort-classes/node-info/common-modifiers.js ++++ b/dist/rules/sort-classes/node-info/common-modifiers.js +@@ -1,6 +1,6 @@ + import { UnreachableCaseError } from '../../../utils/unreachable-case-error.js' + import '../../../utils/assert-is-never.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeAccessibilityModifier({ hasPrivateHash, node }) { + if (hasPrivateHash) { + return ['private'] +diff --git a/dist/rules/sort-classes/node-info/compute-method-details.js b/dist/rules/sort-classes/node-info/compute-method-details.js +index af38f82777..a06f4d7cc8 100644 +--- a/dist/rules/sort-classes/node-info/compute-method-details.js ++++ b/dist/rules/sort-classes/node-info/compute-method-details.js +@@ -9,7 +9,7 @@ import { + computeStaticModifier, + } from './common-modifiers.js' + import { computeMethodOrPropertyNameDetails } from './compute-method-or-property-name-details.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes details related to a method. + * +diff --git a/dist/rules/sort-classes/node-info/compute-method-or-property-name-details.js b/dist/rules/sort-classes/node-info/compute-method-or-property-name-details.js +index 599c80cd25..c5824426e1 100644 +--- a/dist/rules/sort-classes/node-info/compute-method-or-property-name-details.js ++++ b/dist/rules/sort-classes/node-info/compute-method-or-property-name-details.js +@@ -1,5 +1,5 @@ + import { computeIdentifierNameDetails } from '../compute-identifier-name-details.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name details of a method or property node. + * +diff --git a/dist/rules/sort-classes/node-info/is-function-expression.js b/dist/rules/sort-classes/node-info/is-function-expression.js +index c8eb4f405e..0877ab6aec 100644 +--- a/dist/rules/sort-classes/node-info/is-function-expression.js ++++ b/dist/rules/sort-classes/node-info/is-function-expression.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks whether a node is a function expression or an arrow function + * expression. +diff --git a/dist/rules/sort-classes/sort-class.js b/dist/rules/sort-classes/sort-class.js +index ab75d20907..cc218036bd 100644 +--- a/dist/rules/sort-classes/sort-class.js ++++ b/dist/rules/sort-classes/sort-class.js +@@ -41,7 +41,7 @@ import { computePropertyDetails } from './node-info/compute-property-details.js' + import { computeAccessorDetails } from './node-info/compute-accessor-details.js' + import { computeMethodDetails } from './node-info/compute-method-details.js' + import { isKnownClassElement } from './is-known-class-element.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-classes.js b/dist/rules/sort-classes.js +index 831f893a16..576709a748 100644 +--- a/dist/rules/sort-classes.js ++++ b/dist/rules/sort-classes.js +@@ -31,7 +31,7 @@ import { + additionalCustomGroupMatchOptionsJsonSchema, + } from './sort-classes/types.js' + import { defaultOptions, sortClass } from './sort-classes/sort-class.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_classes_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-decorators.js b/dist/rules/sort-decorators.js +index dfcd8e5d39..8f9a8b2ee5 100644 +--- a/dist/rules/sort-decorators.js ++++ b/dist/rules/sort-decorators.js +@@ -29,7 +29,7 @@ import { complete } from '../utils/complete.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { getNodeDecorators } from '../utils/get-node-decorators.js' + import { getDecoratorName } from '../utils/get-decorator-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedDecoratorsOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedDecoratorsGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenDecorators' +diff --git a/dist/rules/sort-enums/compute-dependencies-by-sorting-node.js b/dist/rules/sort-enums/compute-dependencies-by-sorting-node.js +index 6210ed9f7b..aac8e7fd54 100644 +--- a/dist/rules/sort-enums/compute-dependencies-by-sorting-node.js ++++ b/dist/rules/sort-enums/compute-dependencies-by-sorting-node.js +@@ -1,7 +1,7 @@ + import { computeDependenciesBySortingNode as computeDependenciesBySortingNode$1 } from '../../utils/compute-dependencies-by-sorting-node.js' + import { computeParentNodesWithTypes } from '../../utils/compute-parent-nodes-with-types.js' + import { doesSortingNodeHaveOneOfDependencyNames } from '../../utils/does-sorting-node-have-one-of-dependency-names.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeDependenciesBySortingNode({ + sortingNodes, + sourceCode, +diff --git a/dist/rules/sort-enums/compute-dependencies.js b/dist/rules/sort-enums/compute-dependencies.js +index ba822be2d7..c52275c945 100644 +--- a/dist/rules/sort-enums/compute-dependencies.js ++++ b/dist/rules/sort-enums/compute-dependencies.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Extract dependencies from an enum. + * +diff --git a/dist/rules/sort-enums/compute-expression-number-value.js b/dist/rules/sort-enums/compute-expression-number-value.js +index 99cb641d3d..c015ea2b74 100644 +--- a/dist/rules/sort-enums/compute-expression-number-value.js ++++ b/dist/rules/sort-enums/compute-expression-number-value.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Extracts a numeric value from an AST expression node. + * +diff --git a/dist/rules/sort-enums/compute-node-name.js b/dist/rules/sort-enums/compute-node-name.js +index 9b3fc7c0d8..02bfaf629b 100644 +--- a/dist/rules/sort-enums/compute-node-name.js ++++ b/dist/rules/sort-enums/compute-node-name.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an enum member node. + * +diff --git a/dist/rules/sort-enums/sort-enum.js b/dist/rules/sort-enums/sort-enum.js +index 5f67268981..48ca40376e 100644 +--- a/dist/rules/sort-enums/sort-enum.js ++++ b/dist/rules/sort-enums/sort-enum.js +@@ -29,7 +29,7 @@ import { computeExpressionNumberValue } from './compute-expression-number-value. + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' + import { computeDependencies } from './compute-dependencies.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var defaultOptions = { + useExperimentalDependencyDetection: true, + fallbackSort: { type: 'unsorted' }, +diff --git a/dist/rules/sort-enums.js b/dist/rules/sort-enums.js +index 17bdf99938..6dba46cdf5 100644 +--- a/dist/rules/sort-enums.js ++++ b/dist/rules/sort-enums.js +@@ -27,7 +27,7 @@ import { + additionalCustomGroupMatchOptionsJsonSchema, + } from './sort-enums/types.js' + import { defaultOptions, sortEnum } from './sort-enums/sort-enum.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_enums_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-export-attributes.js b/dist/rules/sort-export-attributes.js +index b39aedf042..938a171a23 100644 +--- a/dist/rules/sort-export-attributes.js ++++ b/dist/rules/sort-export-attributes.js +@@ -8,7 +8,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortImportOrExportAttributes } from './sort-import-attributes/sort-import-or-export-attributes.js' + import { jsonSchema } from './sort-import-attributes.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedExportAttributesOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedExportAttributesGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenExportAttributes' +diff --git a/dist/rules/sort-exports.js b/dist/rules/sort-exports.js +index 10485817eb..0e3bb50790 100644 +--- a/dist/rules/sort-exports.js ++++ b/dist/rules/sort-exports.js +@@ -35,7 +35,7 @@ import { + allModifiers, + allSelectors, + } from './sort-exports/types.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-heritage-clauses/compute-node-name.js b/dist/rules/sort-heritage-clauses/compute-node-name.js +index d67bd3abe3..00b011c5f0 100644 +--- a/dist/rules/sort-heritage-clauses/compute-node-name.js ++++ b/dist/rules/sort-heritage-clauses/compute-node-name.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Extracts the name from a heritage clause expression. + * +diff --git a/dist/rules/sort-heritage-clauses/sort-heritage-clause.js b/dist/rules/sort-heritage-clauses/sort-heritage-clause.js +index 137849c1ba..48025567e3 100644 +--- a/dist/rules/sort-heritage-clauses/sort-heritage-clause.js ++++ b/dist/rules/sort-heritage-clauses/sort-heritage-clause.js +@@ -22,7 +22,7 @@ import { + } from './types.js' + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var defaultOptions = { + fallbackSort: { type: 'unsorted' }, + newlinesInside: 'newlinesBetween', +diff --git a/dist/rules/sort-heritage-clauses.js b/dist/rules/sort-heritage-clauses.js +index 255ac05bde..0518b75320 100644 +--- a/dist/rules/sort-heritage-clauses.js ++++ b/dist/rules/sort-heritage-clauses.js +@@ -26,7 +26,7 @@ import { + defaultOptions, + sortHeritageClause, + } from './sort-heritage-clauses/sort-heritage-clause.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_heritage_clauses_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-import-attributes/compute-node-name.js b/dist/rules/sort-import-attributes/compute-node-name.js +index 9c8a989e40..b40d684b54 100644 +--- a/dist/rules/sort-import-attributes/compute-node-name.js ++++ b/dist/rules/sort-import-attributes/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Extracts the name of an import attribute for sorting purposes. + * +diff --git a/dist/rules/sort-import-attributes.js b/dist/rules/sort-import-attributes.js +index c6b8959f1f..6d107302f6 100644 +--- a/dist/rules/sort-import-attributes.js ++++ b/dist/rules/sort-import-attributes.js +@@ -17,7 +17,7 @@ import { + import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortImportOrExportAttributes } from './sort-import-attributes/sort-import-or-export-attributes.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedImportAttributesOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedImportAttributesGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenImportAttributes' +diff --git a/dist/rules/sort-imports/compute-dependencies.js b/dist/rules/sort-imports/compute-dependencies.js +index 5982968376..5da84aa816 100644 +--- a/dist/rules/sort-imports/compute-dependencies.js ++++ b/dist/rules/sort-imports/compute-dependencies.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the dependencies of an import-like AST node. + * +diff --git a/dist/rules/sort-imports/compute-dependency-names.js b/dist/rules/sort-imports/compute-dependency-names.js +index 9248e7658d..7c0670e3a6 100644 +--- a/dist/rules/sort-imports/compute-dependency-names.js ++++ b/dist/rules/sort-imports/compute-dependency-names.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the dependency names of an import-like AST node. + * +diff --git a/dist/rules/sort-imports/compute-node-name.js b/dist/rules/sort-imports/compute-node-name.js +index 6ca311c474..2d477cf8f5 100644 +--- a/dist/rules/sort-imports/compute-node-name.js ++++ b/dist/rules/sort-imports/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an import-like AST node. + * +diff --git a/dist/rules/sort-imports/compute-specifier-modifiers.js b/dist/rules/sort-imports/compute-specifier-modifiers.js +index f948b386b3..2d171472c1 100644 +--- a/dist/rules/sort-imports/compute-specifier-modifiers.js ++++ b/dist/rules/sort-imports/compute-specifier-modifiers.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the specifier modifiers of an import-like AST node. + * +diff --git a/dist/rules/sort-imports/compute-specifier-name.js b/dist/rules/sort-imports/compute-specifier-name.js +index acb8a82401..eb47acf975 100644 +--- a/dist/rules/sort-imports/compute-specifier-name.js ++++ b/dist/rules/sort-imports/compute-specifier-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeSpecifierName({ sourceCode, node }) { + switch (node.type) { + case AST_NODE_TYPES.TSImportEqualsDeclaration: +diff --git a/dist/rules/sort-imports/is-non-external-reference-ts-import-equals.js b/dist/rules/sort-imports/is-non-external-reference-ts-import-equals.js +index f78378394e..2af4de4357 100644 +--- a/dist/rules/sort-imports/is-non-external-reference-ts-import-equals.js ++++ b/dist/rules/sort-imports/is-non-external-reference-ts-import-equals.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Determines whether the given AST node is a non-external-reference TS import + * equals declaration. +diff --git a/dist/rules/sort-imports/is-side-effect-import.js b/dist/rules/sort-imports/is-side-effect-import.js +index dbb5f5168d..703558e7ab 100644 +--- a/dist/rules/sort-imports/is-side-effect-import.js ++++ b/dist/rules/sort-imports/is-side-effect-import.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Determines whether the given AST node is a side-effect import. + * +diff --git a/dist/rules/sort-imports.js b/dist/rules/sort-imports.js +index 1b827e2f88..f6dae75e0b 100644 +--- a/dist/rules/sort-imports.js ++++ b/dist/rules/sort-imports.js +@@ -57,7 +57,7 @@ import { computeSpecifierName } from './sort-imports/compute-specifier-name.js' + import { computeDependencies } from './sort-imports/compute-dependencies.js' + import { isSideEffectImport } from './sort-imports/is-side-effect-import.js' + import { computeNodeName } from './sort-imports/compute-node-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-interfaces.js b/dist/rules/sort-interfaces.js +index 622f22a87b..6ea763c739 100644 +--- a/dist/rules/sort-interfaces.js ++++ b/dist/rules/sort-interfaces.js +@@ -8,7 +8,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortObjectTypeElements } from './sort-object-types/sort-object-type-elements.js' + import { defaultOptions, jsonSchema } from './sort-object-types.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedInterfacePropertiesOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedInterfacePropertiesGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenInterfaceMembers' +diff --git a/dist/rules/sort-intersection-types.js b/dist/rules/sort-intersection-types.js +index 0d53136389..febd11cc49 100644 +--- a/dist/rules/sort-intersection-types.js ++++ b/dist/rules/sort-intersection-types.js +@@ -8,7 +8,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortUnionOrIntersectionTypes } from './sort-union-or-intersection-types/sort-union-or-intersection-types.js' + import { buildJsonSchema } from './sort-union-or-intersection-types/build-json-schema.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-jsx-props/compute-matched-context-options.js b/dist/rules/sort-jsx-props/compute-matched-context-options.js +index 336e3ffcee..8555494c73 100644 +--- a/dist/rules/sort-jsx-props/compute-matched-context-options.js ++++ b/dist/rules/sort-jsx-props/compute-matched-context-options.js +@@ -2,7 +2,7 @@ import { matches } from '../../utils/matches.js' + import { passesAllNamesMatchPatternFilter } from '../../utils/context-matching/passes-all-names-match-pattern-filter.js' + import { passesAstSelectorFilter } from '../../utils/context-matching/passes-ast-selector-filter.js' + import { computeNodeName } from './compute-node-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for a given JSX element node. + * +diff --git a/dist/rules/sort-jsx-props/compute-node-name.js b/dist/rules/sort-jsx-props/compute-node-name.js +index 94d97b0250..b95a285644 100644 +--- a/dist/rules/sort-jsx-props/compute-node-name.js ++++ b/dist/rules/sort-jsx-props/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of a JSX attribute node. + * +diff --git a/dist/rules/sort-jsx-props/sort-jsx-object.js b/dist/rules/sort-jsx-props/sort-jsx-object.js +index 2a08d6021d..3b16ffb49f 100644 +--- a/dist/rules/sort-jsx-props/sort-jsx-object.js ++++ b/dist/rules/sort-jsx-props/sort-jsx-object.js +@@ -26,7 +26,7 @@ import { + } from './types.js' + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-jsx-props.js b/dist/rules/sort-jsx-props.js +index ad5f069892..aade3f4534 100644 +--- a/dist/rules/sort-jsx-props.js ++++ b/dist/rules/sort-jsx-props.js +@@ -25,7 +25,7 @@ import { + defaultOptions, + sortJsxObject, + } from './sort-jsx-props/sort-jsx-object.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_jsx_props_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-maps/compute-matched-context-options.js b/dist/rules/sort-maps/compute-matched-context-options.js +index 0c0f0b693c..c1c25a539a 100644 +--- a/dist/rules/sort-maps/compute-matched-context-options.js ++++ b/dist/rules/sort-maps/compute-matched-context-options.js +@@ -1,7 +1,7 @@ + import { passesAllNamesMatchPatternFilter } from '../../utils/context-matching/passes-all-names-match-pattern-filter.js' + import { passesAstSelectorFilter } from '../../utils/context-matching/passes-ast-selector-filter.js' + import { computeNodeName } from './compute-node-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for a given map node. + * +diff --git a/dist/rules/sort-maps/compute-node-name.js b/dist/rules/sort-maps/compute-node-name.js +index 0f3540ada6..b83dff2bf4 100644 +--- a/dist/rules/sort-maps/compute-node-name.js ++++ b/dist/rules/sort-maps/compute-node-name.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Extracts the name of a Map element for sorting purposes. + * +diff --git a/dist/rules/sort-maps/sort-potential-map.js b/dist/rules/sort-maps/sort-potential-map.js +index 2ca140ba39..5123b14f88 100644 +--- a/dist/rules/sort-maps/sort-potential-map.js ++++ b/dist/rules/sort-maps/sort-potential-map.js +@@ -21,7 +21,7 @@ import { + } from './types.js' + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var defaultOptions = { + fallbackSort: { type: 'unsorted' }, + newlinesInside: 'newlinesBetween', +diff --git a/dist/rules/sort-maps.js b/dist/rules/sort-maps.js +index b52e1053ca..790c016f52 100644 +--- a/dist/rules/sort-maps.js ++++ b/dist/rules/sort-maps.js +@@ -26,7 +26,7 @@ import { + defaultOptions, + sortPotentialMap, + } from './sort-maps/sort-potential-map.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_maps_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-modules/compute-dependencies-by-sorting-node.js b/dist/rules/sort-modules/compute-dependencies-by-sorting-node.js +index 788761427a..a34db37618 100644 +--- a/dist/rules/sort-modules/compute-dependencies-by-sorting-node.js ++++ b/dist/rules/sort-modules/compute-dependencies-by-sorting-node.js +@@ -4,7 +4,7 @@ import { computeParentNodesWithTypes } from '../../utils/compute-parent-nodes-wi + import { doesSortingNodeHaveOneOfDependencyNames } from '../../utils/does-sorting-node-have-one-of-dependency-names.js' + import { isPropertyOrAccessorNode } from './is-property-or-accessor-node.js' + import { isArrowFunctionNode } from './is-arrow-function-node.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeDependenciesBySortingNode({ + dependencyDetection, + sortingNodes, +diff --git a/dist/rules/sort-modules/compute-dependencies.js b/dist/rules/sort-modules/compute-dependencies.js +index 27dbc05f9a..f62b19ebf8 100644 +--- a/dist/rules/sort-modules/compute-dependencies.js ++++ b/dist/rules/sort-modules/compute-dependencies.js +@@ -1,7 +1,7 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' + import { isArrowFunctionNode } from './is-arrow-function-node.js' + import { getEnumMembers } from '../../utils/get-enum-members.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the dependencies of a given AST node. + * +diff --git a/dist/rules/sort-modules/compute-node-details.js b/dist/rules/sort-modules/compute-node-details.js +index 7f0d8e50a0..9de9f69078 100644 +--- a/dist/rules/sort-modules/compute-node-details.js ++++ b/dist/rules/sort-modules/compute-node-details.js +@@ -2,7 +2,7 @@ import { getNodeDecorators } from '../../utils/get-node-decorators.js' + import { isPropertyOrAccessorNode } from './is-property-or-accessor-node.js' + import { isArrowFunctionNode } from './is-arrow-function-node.js' + import { computeDependencies } from './compute-dependencies.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Compute details about a module-related node. + * +diff --git a/dist/rules/sort-modules/compute-overload-signature-groups.js b/dist/rules/sort-modules/compute-overload-signature-groups.js +index f6c11d1af9..00ac7a87eb 100644 +--- a/dist/rules/sort-modules/compute-overload-signature-groups.js ++++ b/dist/rules/sort-modules/compute-overload-signature-groups.js +@@ -1,6 +1,6 @@ + import { isSortable } from '../../utils/is-sortable.js' + import { OverloadSignatureGroup } from '../../utils/overload-signature/overload-signature-group.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Returns a list of groups of overload signatures. + * +diff --git a/dist/rules/sort-modules/is-arrow-function-node.js b/dist/rules/sort-modules/is-arrow-function-node.js +index f9590605b9..691c7eae4f 100644 +--- a/dist/rules/sort-modules/is-arrow-function-node.js ++++ b/dist/rules/sort-modules/is-arrow-function-node.js +@@ -1,5 +1,5 @@ + import { isPropertyOrAccessorNode } from './is-property-or-accessor-node.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks whether a node is a property or accessor node with an + * ArrowFunctionExpression value. +diff --git a/dist/rules/sort-modules/is-property-or-accessor-node.js b/dist/rules/sort-modules/is-property-or-accessor-node.js +index c85be07fbf..285322fabd 100644 +--- a/dist/rules/sort-modules/is-property-or-accessor-node.js ++++ b/dist/rules/sort-modules/is-property-or-accessor-node.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks whether a node is a PropertyDefinition or AccessorProperty. + * +diff --git a/dist/rules/sort-modules.js b/dist/rules/sort-modules.js +index c5516f62f9..f7a9c5359a 100644 +--- a/dist/rules/sort-modules.js ++++ b/dist/rules/sort-modules.js +@@ -50,7 +50,7 @@ import { computeDependenciesBySortingNode } from './sort-modules/compute-depende + import { buildComparatorByOptionsComputer } from './sort-modules/build-comparator-by-options-computer.js' + import { computeOverloadSignatureGroups } from './sort-modules/compute-overload-signature-groups.js' + import { computeNodeDetails } from './sort-modules/compute-node-details.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-named-exports/compute-node-name.js b/dist/rules/sort-named-exports/compute-node-name.js +index 022b1f12ce..6742fa5c3d 100644 +--- a/dist/rules/sort-named-exports/compute-node-name.js ++++ b/dist/rules/sort-named-exports/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an export specifier node. + * +diff --git a/dist/rules/sort-named-exports.js b/dist/rules/sort-named-exports.js +index 5ee52bc084..fc10141cc8 100644 +--- a/dist/rules/sort-named-exports.js ++++ b/dist/rules/sort-named-exports.js +@@ -27,7 +27,7 @@ import { + defaultOptions, + sortNamedExport, + } from './sort-named-exports/sort-named-export.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_named_exports_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-named-imports/compute-matched-context-options.js b/dist/rules/sort-named-imports/compute-matched-context-options.js +index 0ea41e17b2..efcb07cf1b 100644 +--- a/dist/rules/sort-named-imports/compute-matched-context-options.js ++++ b/dist/rules/sort-named-imports/compute-matched-context-options.js +@@ -1,7 +1,7 @@ + import { passesAllNamesMatchPatternFilter } from '../../utils/context-matching/passes-all-names-match-pattern-filter.js' + import { passesAstSelectorFilter } from '../../utils/context-matching/passes-ast-selector-filter.js' + import { computeNodeName } from './compute-node-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for a given named import node. + * +diff --git a/dist/rules/sort-named-imports/compute-node-name.js b/dist/rules/sort-named-imports/compute-node-name.js +index 0c2b193921..0fb85dff29 100644 +--- a/dist/rules/sort-named-imports/compute-node-name.js ++++ b/dist/rules/sort-named-imports/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an import specifier node. + * +diff --git a/dist/rules/sort-named-imports/sort-named-import.js b/dist/rules/sort-named-imports/sort-named-import.js +index b9ec4578fa..cd8730f7b0 100644 +--- a/dist/rules/sort-named-imports/sort-named-import.js ++++ b/dist/rules/sort-named-imports/sort-named-import.js +@@ -26,7 +26,7 @@ import { + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' + import { computeImportKindModifier } from './compute-import-kind-modifier.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-named-imports.js b/dist/rules/sort-named-imports.js +index 704064a4c2..15d95d690f 100644 +--- a/dist/rules/sort-named-imports.js ++++ b/dist/rules/sort-named-imports.js +@@ -27,7 +27,7 @@ import { + defaultOptions, + sortNamedImport, + } from './sort-named-imports/sort-named-import.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_named_imports_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-object-types/compute-matched-context-options.js b/dist/rules/sort-object-types/compute-matched-context-options.js +index c13175b319..961e63db0e 100644 +--- a/dist/rules/sort-object-types/compute-matched-context-options.js ++++ b/dist/rules/sort-object-types/compute-matched-context-options.js +@@ -4,7 +4,7 @@ import { passesAstSelectorFilter } from '../../utils/context-matching/passes-ast + import { passesDeclarationMatchesPatternFilter } from './passes-declaration-matches-pattern-filter.js' + import { passesDeclarationCommentMatchesFilter } from './passes-declaration-comment-matches-filter.js' + import { computeNodeName } from './compute-node-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for the given nodes. + * +diff --git a/dist/rules/sort-object-types/compute-node-name.js b/dist/rules/sort-object-types/compute-node-name.js +index 74929882d1..9bcd042fc0 100644 +--- a/dist/rules/sort-object-types/compute-node-name.js ++++ b/dist/rules/sort-object-types/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an object-type-like node. + * +diff --git a/dist/rules/sort-object-types/compute-node-parent-name.js b/dist/rules/sort-object-types/compute-node-parent-name.js +index 2c508490f4..36b9d3f45b 100644 +--- a/dist/rules/sort-object-types/compute-node-parent-name.js ++++ b/dist/rules/sort-object-types/compute-node-parent-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of an object-type-like parent node. + * +diff --git a/dist/rules/sort-object-types/is-member-optional.js b/dist/rules/sort-object-types/is-member-optional.js +index a7be1025aa..2420c1f8c5 100644 +--- a/dist/rules/sort-object-types/is-member-optional.js ++++ b/dist/rules/sort-object-types/is-member-optional.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if a TypeScript type member is marked as optional. + * +diff --git a/dist/rules/sort-object-types/is-node-function-type.js b/dist/rules/sort-object-types/is-node-function-type.js +index 2ae7b6e3ab..e3e817b06b 100644 +--- a/dist/rules/sort-object-types/is-node-function-type.js ++++ b/dist/rules/sort-object-types/is-node-function-type.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Determines if an AST node represents a function type. + * +diff --git a/dist/rules/sort-object-types/passes-declaration-comment-matches-filter.js b/dist/rules/sort-object-types/passes-declaration-comment-matches-filter.js +index b5eeb291b9..a38e4b0ea1 100644 +--- a/dist/rules/sort-object-types/passes-declaration-comment-matches-filter.js ++++ b/dist/rules/sort-object-types/passes-declaration-comment-matches-filter.js +@@ -1,7 +1,7 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' + import { objectTypeParentTypes } from './types.js' + import { matchesScopedExpressions } from '../../utils/scoped-regex/matches-scoped-expressions.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if the object passes the declaration comment matches filter. + * +diff --git a/dist/rules/sort-object-types/sort-object-type-elements.js b/dist/rules/sort-object-types/sort-object-type-elements.js +index 31ac275a42..bbfa204052 100644 +--- a/dist/rules/sort-object-types/sort-object-type-elements.js ++++ b/dist/rules/sort-object-types/sort-object-type-elements.js +@@ -23,7 +23,7 @@ import { isNodeOnSingleLine } from '../../utils/is-node-on-single-line.js' + import { isNodeFunctionType } from './is-node-function-type.js' + import { isMemberOptional } from './is-member-optional.js' + import { defaultOptions } from '../sort-object-types.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-object-types/types.js b/dist/rules/sort-object-types/types.js +index 5aa6fa3498..db0d7def1d 100644 +--- a/dist/rules/sort-object-types/types.js ++++ b/dist/rules/sort-object-types/types.js +@@ -3,7 +3,7 @@ import { + buildCustomGroupModifiersJsonSchema, + buildCustomGroupSelectorJsonSchema, + } from '../../utils/json-schemas/common-groups-json-schemas.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var objectTypeParentTypes = [ + AST_NODE_TYPES.TSTypeAliasDeclaration, + AST_NODE_TYPES.TSInterfaceDeclaration, +diff --git a/dist/rules/sort-object-types.js b/dist/rules/sort-object-types.js +index 87fcb7609d..71bb95b430 100644 +--- a/dist/rules/sort-object-types.js ++++ b/dist/rules/sort-object-types.js +@@ -24,7 +24,7 @@ import { + } from './sort-object-types/types.js' + import { scopedRegexJsonSchema } from '../utils/json-schemas/scoped-regex-json-schema.js' + import { sortObjectTypeElements } from './sort-object-types/sort-object-type-elements.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedObjectTypesOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedObjectTypesGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenObjectTypeMembers' +diff --git a/dist/rules/sort-objects/compute-dependencies.js b/dist/rules/sort-objects/compute-dependencies.js +index 20e0962c46..a842dca4de 100644 +--- a/dist/rules/sort-objects/compute-dependencies.js ++++ b/dist/rules/sort-objects/compute-dependencies.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeDependencies(node) { + if (node.value.type !== AST_NODE_TYPES.AssignmentPattern) { + return [] +diff --git a/dist/rules/sort-objects/compute-dependency-names.js b/dist/rules/sort-objects/compute-dependency-names.js +index 4bd80e8318..c62f84ce15 100644 +--- a/dist/rules/sort-objects/compute-dependency-names.js ++++ b/dist/rules/sort-objects/compute-dependency-names.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeDependencyNames(pattern) { + let currentPattern = pattern + while (currentPattern.type === AST_NODE_TYPES.AssignmentPattern) { +diff --git a/dist/rules/sort-objects/compute-matched-context-options.js b/dist/rules/sort-objects/compute-matched-context-options.js +index 2b2807a2e5..9ee3eebd43 100644 +--- a/dist/rules/sort-objects/compute-matched-context-options.js ++++ b/dist/rules/sort-objects/compute-matched-context-options.js +@@ -7,7 +7,7 @@ import { computePropertyOrVariableDeclaratorName } from './compute-property-or-v + import { passesCallingFunctionNamePatternFilter } from './passes-calling-function-name-pattern-filter.js' + import { passesDeclarationMatchesPatternFilter } from './passes-declaration-matches-pattern-filter.js' + import { passesDeclarationCommentMatchesFilter } from './passes-declaration-comment-matches-filter.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the matched context options for a given object node. + * +diff --git a/dist/rules/sort-objects/compute-node-value.js b/dist/rules/sort-objects/compute-node-value.js +index 12b33e9fb8..e9e6b6e218 100644 +--- a/dist/rules/sort-objects/compute-node-value.js ++++ b/dist/rules/sort-objects/compute-node-value.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeNodeValue({ isDestructuredObject, sourceCode, property }) { + switch (property.value.type) { + case AST_NODE_TYPES.ArrowFunctionExpression: +diff --git a/dist/rules/sort-objects/compute-property-or-variable-declarator-name.js b/dist/rules/sort-objects/compute-property-or-variable-declarator-name.js +index 36705b8435..f4dcebb491 100644 +--- a/dist/rules/sort-objects/compute-property-or-variable-declarator-name.js ++++ b/dist/rules/sort-objects/compute-property-or-variable-declarator-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Compute the name for a property-like node. + * +diff --git a/dist/rules/sort-objects/is-style-component.js b/dist/rules/sort-objects/is-style-component.js +index 1147a008e8..b20666fd2f 100644 +--- a/dist/rules/sort-objects/is-style-component.js ++++ b/dist/rules/sort-objects/is-style-component.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if a node represents a style component. + * +diff --git a/dist/rules/sort-objects/passes-calling-function-name-pattern-filter.js b/dist/rules/sort-objects/passes-calling-function-name-pattern-filter.js +index c3fad546bb..89fc35e91b 100644 +--- a/dist/rules/sort-objects/passes-calling-function-name-pattern-filter.js ++++ b/dist/rules/sort-objects/passes-calling-function-name-pattern-filter.js +@@ -1,5 +1,5 @@ + import { matchesScopedExpressions } from '../../utils/scoped-regex/matches-scoped-expressions.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if the object passes the calling function name pattern filter. + * +diff --git a/dist/rules/sort-objects/passes-declaration-comment-matches-filter.js b/dist/rules/sort-objects/passes-declaration-comment-matches-filter.js +index 9e7017b8ee..7853b7d2da 100644 +--- a/dist/rules/sort-objects/passes-declaration-comment-matches-filter.js ++++ b/dist/rules/sort-objects/passes-declaration-comment-matches-filter.js +@@ -1,7 +1,7 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' + import { matchesScopedExpressions } from '../../utils/scoped-regex/matches-scoped-expressions.js' + import { objectParentTypes } from './types.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if the object passes the declaration comment matches filter. + * +diff --git a/dist/rules/sort-objects/passes-declaration-matches-pattern-filter.js b/dist/rules/sort-objects/passes-declaration-matches-pattern-filter.js +index 87e39954ba..760f03bf51 100644 +--- a/dist/rules/sort-objects/passes-declaration-matches-pattern-filter.js ++++ b/dist/rules/sort-objects/passes-declaration-matches-pattern-filter.js +@@ -1,6 +1,6 @@ + import { matchesScopedExpressions } from '../../utils/scoped-regex/matches-scoped-expressions.js' + import { computePropertyOrVariableDeclaratorName } from './compute-property-or-variable-declarator-name.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var allowedTypes = [AST_NODE_TYPES.VariableDeclarator, AST_NODE_TYPES.Property] + /** + * Checks whether the node parent names match the given pattern. +diff --git a/dist/rules/sort-objects/sort-object.js b/dist/rules/sort-objects/sort-object.js +index 950c4e1944..84437c9e78 100644 +--- a/dist/rules/sort-objects/sort-object.js ++++ b/dist/rules/sort-objects/sort-object.js +@@ -34,7 +34,7 @@ import { computeDependencyNames } from './compute-dependency-names.js' + import { computeDependencies } from './compute-dependencies.js' + import { isStyleComponent } from './is-style-component.js' + import { computeNodeValue } from './compute-node-value.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-objects/types.js b/dist/rules/sort-objects/types.js +index ae92ba15ee..7b9b2266ec 100644 +--- a/dist/rules/sort-objects/types.js ++++ b/dist/rules/sort-objects/types.js +@@ -3,7 +3,7 @@ import { + buildCustomGroupModifiersJsonSchema, + buildCustomGroupSelectorJsonSchema, + } from '../../utils/json-schemas/common-groups-json-schemas.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedObjectsOrder' + var GROUP_ORDER_ERROR_ID = 'unexpectedObjectsGroupOrder' + var EXTRA_SPACING_ERROR_ID = 'extraSpacingBetweenObjectMembers' +diff --git a/dist/rules/sort-objects.js b/dist/rules/sort-objects.js +index 9c3f70e535..146fca40e8 100644 +--- a/dist/rules/sort-objects.js ++++ b/dist/rules/sort-objects.js +@@ -29,7 +29,7 @@ import { + additionalSortOptionsJsonSchema, + } from './sort-objects/types.js' + import { defaultOptions, sortObject } from './sort-objects/sort-object.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_objects_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/rules/sort-sets.js b/dist/rules/sort-sets.js +index f82c1da54f..2ea5e1d727 100644 +--- a/dist/rules/sort-sets.js ++++ b/dist/rules/sort-sets.js +@@ -8,7 +8,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortArray } from './sort-arrays/sort-array.js' + import { defaultOptions, jsonSchema } from './sort-array-includes.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-switch-case/is-condition-expression.js b/dist/rules/sort-switch-case/is-condition-expression.js +index d17aaf0dde..349f3024e3 100644 +--- a/dist/rules/sort-switch-case/is-condition-expression.js ++++ b/dist/rules/sort-switch-case/is-condition-expression.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if an expression is condition-shaped. + * +diff --git a/dist/rules/sort-switch-case.js b/dist/rules/sort-switch-case.js +index 3b2369dd72..f6fe23e8a5 100644 +--- a/dist/rules/sort-switch-case.js ++++ b/dist/rules/sort-switch-case.js +@@ -18,7 +18,7 @@ import { isSortable } from '../utils/is-sortable.js' + import { complete } from '../utils/complete.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { isConditionExpression } from './sort-switch-case/is-condition-expression.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var ORDER_ERROR_ID = 'unexpectedSwitchCaseOrder' + var defaultOptions = { + fallbackSort: { type: 'unsorted' }, +diff --git a/dist/rules/sort-union-or-intersection-types/compute-node-name.js b/dist/rules/sort-union-or-intersection-types/compute-node-name.js +index 3d547f8d57..846f47e6cd 100644 +--- a/dist/rules/sort-union-or-intersection-types/compute-node-name.js ++++ b/dist/rules/sort-union-or-intersection-types/compute-node-name.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of a union/intersection member. + * +diff --git a/dist/rules/sort-union-or-intersection-types/sort-union-or-intersection-types.js b/dist/rules/sort-union-or-intersection-types/sort-union-or-intersection-types.js +index 70728334a5..eec0edd691 100644 +--- a/dist/rules/sort-union-or-intersection-types/sort-union-or-intersection-types.js ++++ b/dist/rules/sort-union-or-intersection-types/sort-union-or-intersection-types.js +@@ -18,7 +18,7 @@ import { allSelectors } from '../sort-union-types/types.js' + import { computeNodeName } from './compute-node-name.js' + import { computeMatchedContextOptions } from './compute-matched-context-options.js' + import { typeContainsCallableType } from './type-contains-callable-type.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function sortUnionOrIntersectionTypes({ + cachedGroupsByModifiersAndSelectors, + tokenValueToIgnoreBefore, +diff --git a/dist/rules/sort-union-or-intersection-types/type-contains-callable-type.js b/dist/rules/sort-union-or-intersection-types/type-contains-callable-type.js +index db6f128c0a..c64832c977 100644 +--- a/dist/rules/sort-union-or-intersection-types/type-contains-callable-type.js ++++ b/dist/rules/sort-union-or-intersection-types/type-contains-callable-type.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks if a type node contains a callable type (function or constructor). + * +diff --git a/dist/rules/sort-union-types.js b/dist/rules/sort-union-types.js +index a18fa14eb6..afbcd84e40 100644 +--- a/dist/rules/sort-union-types.js ++++ b/dist/rules/sort-union-types.js +@@ -8,7 +8,7 @@ import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' + import { sortUnionOrIntersectionTypes } from './sort-union-or-intersection-types/sort-union-or-intersection-types.js' + import { buildJsonSchema } from './sort-union-or-intersection-types/build-json-schema.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Cache computed groups by modifiers and selectors for performance. + */ +diff --git a/dist/rules/sort-variable-declarations/compute-dependencies.js b/dist/rules/sort-variable-declarations/compute-dependencies.js +index 1bfb9fc53a..e6ccbe7431 100644 +--- a/dist/rules/sort-variable-declarations/compute-dependencies.js ++++ b/dist/rules/sort-variable-declarations/compute-dependencies.js +@@ -1,5 +1,5 @@ + import { isNodeImmediatelyCalled } from '../../utils/is-node-immediately-called.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the dependencies of a variable declaration node. + * +diff --git a/dist/rules/sort-variable-declarations/compute-node-name.js b/dist/rules/sort-variable-declarations/compute-node-name.js +index 783093d4d8..c32a79c0ae 100644 +--- a/dist/rules/sort-variable-declarations/compute-node-name.js ++++ b/dist/rules/sort-variable-declarations/compute-node-name.js +@@ -1,5 +1,5 @@ + import { UnreachableCaseError } from '../../utils/unreachable-case-error.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Computes the name of a variable declaration. + * +diff --git a/dist/rules/sort-variable-declarations.js b/dist/rules/sort-variable-declarations.js +index 6e5ef6b495..a3b16ae144 100644 +--- a/dist/rules/sort-variable-declarations.js ++++ b/dist/rules/sort-variable-declarations.js +@@ -30,7 +30,7 @@ import { + } from './sort-variable-declarations/sort-variable-declaration.js' + import { buildAstListeners } from '../utils/build-ast-listeners.js' + import { createEslintRule } from '../utils/create-eslint-rule.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + var sort_variable_declarations_default = createEslintRule({ + meta: { + schema: { +diff --git a/dist/utils/compute-dependencies-outside-functions-by-sorting-node.js b/dist/utils/compute-dependencies-outside-functions-by-sorting-node.js +index 83b7b1345b..a7aaf976b9 100644 +--- a/dist/utils/compute-dependencies-outside-functions-by-sorting-node.js ++++ b/dist/utils/compute-dependencies-outside-functions-by-sorting-node.js +@@ -1,7 +1,7 @@ + import { computeDependenciesBySortingNode } from './compute-dependencies-by-sorting-node.js' + import { computeParentNodesWithTypes } from './compute-parent-nodes-with-types.js' + import { isNodeImmediatelyCalled } from './is-node-immediately-called.js' +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + function computeDependenciesOutsideFunctionsBySortingNode({ + sortingNodes, + sourceCode, +diff --git a/dist/utils/create-eslint-rule.js b/dist/utils/create-eslint-rule.js +index 7253d86e12..d10e38bf9f 100644 +--- a/dist/utils/create-eslint-rule.js ++++ b/dist/utils/create-eslint-rule.js +@@ -1,4 +1,4 @@ +-import { ESLintUtils } from '@typescript-eslint/utils' ++import * as ESLintUtils from '@typescript-eslint/utils/eslint-utils' + /** + * Factory function for creating ESLint rules with consistent structure and + * documentation. +diff --git a/dist/utils/get-node-range.js b/dist/utils/get-node-range.js +index 1ac032221d..c2773c1618 100644 +--- a/dist/utils/get-node-range.js ++++ b/dist/utils/get-node-range.js +@@ -1,7 +1,7 @@ + import { getEslintDisabledRules } from './get-eslint-disabled-rules.js' + import { isPartitionComment } from './is-partition-comment.js' + import { getCommentsBefore } from './get-comments-before.js' +-import { ASTUtils } from '@typescript-eslint/utils' ++import * as ASTUtils from '@typescript-eslint/utils/ast-utils' + /** + * Determines the complete range of a node including its associated comments. + * +diff --git a/dist/utils/is-node-immediately-called.js b/dist/utils/is-node-immediately-called.js +index dc4ded4e3e..9c10c2ecf0 100644 +--- a/dist/utils/is-node-immediately-called.js ++++ b/dist/utils/is-node-immediately-called.js +@@ -1,4 +1,4 @@ +-import { AST_NODE_TYPES } from '@typescript-eslint/utils' ++import { AST_NODE_TYPES } from '@typescript-eslint/types' + /** + * Checks whether a node is the callee of a call or `new` expression. + * diff --git a/app/patches/msgpackr@2.1.0.patch b/app/patches/msgpackr@2.1.0.patch new file mode 100644 index 000000000..462dc2f6c --- /dev/null +++ b/app/patches/msgpackr@2.1.0.patch @@ -0,0 +1,30 @@ +diff --git a/unpack.js b/unpack.js +index 2e8fc87..75305e1 100644 +--- a/unpack.js ++++ b/unpack.js +@@ -491,25 +491,8 @@ export function read() { + } + } + } +-const validName = /^[a-zA-Z_$][a-zA-Z\d_$]*$/; + function createStructureReader(structure, firstId) { + function readObject() { +- // This initial function is quick to instantiate, but runs slower. After several iterations pay the cost to build the faster function +- if (readObject.count++ > inlineObjectReadThreshold) { +- let optimizedReadObject; +- try { +- optimizedReadObject = structure.read = (new Function('r', 'return function(){return ' + (currentUnpackr.freezeData ? 'Object.freeze' : '') + +- '({' + structure.map(key => key === '__proto__' ? '__proto_:r()' : validName.test(key) ? key + ':r()' : ('[' + JSON.stringify(key) + ']:r()')).join(',') + '})}'))(read); +- } catch(error) { +- // in CF workers, the new Function call could begin to fail at any point in time +- inlineObjectReadThreshold = Infinity; // disable going forward +- return readObject(); // recursively try again +- } +- structure.read0 = optimizedReadObject; // keep the un-wrapped body reader in sync +- if (structure.highByte === 0) +- structure.read = createSecondByteReader(firstId, structure.read); +- return optimizedReadObject(); // second byte is already read, if there is one so immediately read object +- } + let object = {}; + for (let i = 0, l = structure.length; i < l; i++) { + let key = structure[i]; diff --git a/app/patches/zod@4.5.4.patch b/app/patches/zod@4.5.4.patch new file mode 100644 index 000000000..430808efd --- /dev/null +++ b/app/patches/zod@4.5.4.patch @@ -0,0 +1,56 @@ +diff --git a/v4/core/util.cjs b/v4/core/util.cjs +index 6c74bff..6999a5d 100644 +--- a/v4/core/util.cjs ++++ b/v4/core/util.cjs +@@ -213,22 +213,7 @@ function isObject(data) { + return typeof data === "object" && data !== null && !Array.isArray(data); + } + exports.allowsEval = cached(() => { +- // Skip the probe under `jitless`: strict CSPs report the caught `new Function` as a `securitypolicyviolation` even though the throw is swallowed. +- if (core_js_1.globalConfig.jitless) { +- return false; +- } +- // @ts-ignore +- if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { +- return false; +- } +- try { +- const F = Function; +- new F(""); +- return true; +- } +- catch (_) { +- return false; +- } ++ return false; + }); + function isPlainObject(o) { + if (isObject(o) === false) +diff --git a/v4/core/util.js b/v4/core/util.js +index a7029b8..ac36de1 100644 +--- a/v4/core/util.js ++++ b/v4/core/util.js +@@ -146,22 +146,7 @@ export function isObject(data) { + return typeof data === "object" && data !== null && !Array.isArray(data); + } + export const allowsEval = /* @__PURE__*/ cached(() => { +- // Skip the probe under `jitless`: strict CSPs report the caught `new Function` as a `securitypolicyviolation` even though the throw is swallowed. +- if (globalConfig.jitless) { +- return false; +- } +- // @ts-ignore +- if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { +- return false; +- } +- try { +- const F = Function; +- new F(""); +- return true; +- } +- catch (_) { +- return false; +- } ++ return false; + }); + export function isPlainObject(o) { + if (isObject(o) === false) diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 4cdf46c4a..169a4cce3 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -6,27 +6,31 @@ settings: overrides: react-server-dom-rspack: 0.1.0 - '@tanstack/react-router': 1.170.25 - '@tanstack/router-core': 1.171.21 - '@effect/opentelemetry': 4.0.0-beta.107 - effect: 4.0.0-beta.107 + '@tanstack/react-router': 1.170.33 + '@tanstack/router-core': 1.171.28 + '@effect/opentelemetry': 4.0.0-rc.112 + effect: 4.0.0-rc.112 node-fetch: ^3.3.2 + '@tanstack/history': 1.162.2 + msgpackr: 2.1.0 + zod: 4.5.4 + '@effect/vitest': 4.0.0-rc.112 + +packageExtensionsChecksum: sha256-F3whp1/Z67s2/nCP/MLbO5ghyRkhOXgl4TpHpshbulg= patchedDependencies: '@better-fetch/fetch@1.3.1': 9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747 - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': 8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b - '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12': c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': 227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110 - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': fe09c7875888067ae4ab161d302024d95f45d52844f6fb9268db1214a35f84f5 - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12': e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0 - '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12': 254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d - '@module-federation/bridge-react@2.8.0': 54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be - '@module-federation/modern-js-v3@2.8.0': 56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3 - '@tanstack/router-core@1.171.21': 413c2453d06aa521ed65ab7fcfb16bac8700e58e97693c2ba4d40727d7c9790d + '@module-federation/bridge-react@2.9.0': 8c084f41790295af8fd015b897c6298bbc13d927b796c624ac96cb2bdb4bc87c + '@module-federation/dts-plugin@2.9.0': b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b + '@module-federation/modern-js-v3@2.9.0': ba5049c43645a4337e1a74857b2dcef5f5330692cc5d2186bcef236dc122dae8 + '@module-federation/runtime-core@2.9.0': b241be221397f0e07dbe6c515725e12eaf3b418469bb7dd21750e6e4b215dd8d '@vercel/nft@0.29.2': c0ed4897b98e9055716031187bb8ea16739f6ae0843d35e4873f1a177472cac7 drizzle-orm@1.0.0-rc.5-ab785fc: b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe effect-rstest@0.1.0: aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138 - effect@4.0.0-beta.107: 88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98 + effect@4.0.0-rc.112: b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada + eslint-plugin-perfectionist@5.10.1: 9e69fb6189199155ccf29de79c5127492dcebff0b0a1fdf79bb3cd72704501a7 + msgpackr@2.1.0: de5c91fa6cfd894d171ed06673af40046ba97c7eb604409caf5f510e1a8a5b7a + zod@4.5.4: 30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6 importers: @@ -36,54 +40,60 @@ importers: specifier: 1.6.1 version: 1.6.1 '@effect/sql-pg': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) better-auth: specifier: 1.7.2 - version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) pg: specifier: 8.22.0 version: 8.22.0 devDependencies: + '@effect/opentelemetry': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@effect/platform-node': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(redis@6.2.1(@opentelemetry/api@1.9.1)) '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 + specifier: 0.41.0 + version: 0.41.0 '@modern-js/app-tools': - specifier: npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(1de7912af751fa74e815801433c09f46)' + specifier: npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/app-tools-extensions': + specifier: npm:@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' '@modern-js/code-tools': - specifier: npm:@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110)(oxlint-tsgolint@7.0.2001)' + specifier: npm:@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4(oxlint-tsgolint@7.0.2001)' '@modern-js/codesmith': specifier: 2.6.9 version: 2.6.9(supports-color@10.2.2) - '@modern-js/create': - specifier: npm:@bleedingdev/modern-js-create@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=fe09c7875888067ae4ab161d302024d95f45d52844f6fb9268db1214a35f84f5)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' '@modern-js/plugin-bff': - specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(3a233a8c5baa0ff66c13c170d030459d)' + specifier: npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4(4ee1d8b8442de2cfc24c5713107ff8c2)' + '@modern-js/ultramodern-create': + specifier: npm:@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))(prettier@3.9.6)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' '@nkzw/eslint-plugin': specifier: 2.0.0 - version: 2.0.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + version: 2.0.0 '@noble/hashes': specifier: 2.2.0 version: 2.2.0 '@oxlint/plugins': - specifier: 1.79.0 - version: 1.79.0 + specifier: 1.81.0 + version: 1.81.0 '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: ^26.4.1 + version: 26.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 @@ -91,29 +101,32 @@ importers: specifier: npm:typescript@7.0.2 version: typescript@7.0.2 '@typescript/native-preview': - specifier: npm:typescript@7.0.2 - version: typescript@7.0.2 + specifier: 7.0.0-dev.20260707.2 + version: 7.0.0-dev.20260707.2 '@vercel/nft': specifier: 0.29.2 version: 0.29.2(patch_hash=c0ed4897b98e9055716031187bb8ea16739f6ae0843d35e4873f1a177472cac7)(supports-color@10.2.2) + cross-env: + specifier: 10.1.0 + version: 10.1.0 effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) esbuild: specifier: 0.28.1 version: 0.28.1 eslint-plugin-github: specifier: 6.1.2 - version: 6.1.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + version: 6.1.2(supports-color@10.2.2) eslint-plugin-perfectionist: specifier: 5.10.1 - version: 5.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2) + version: 5.10.1(patch_hash=9e69fb6189199155ccf29de79c5127492dcebff0b0a1fdf79bb3cd72704501a7)(supports-color@10.2.2)(typescript@7.0.2) eslint-plugin-sonarjs: specifier: 4.2.0 - version: 4.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + version: 4.2.0 fallow: specifier: 3.22.0 version: 3.22.0 @@ -131,19 +144,19 @@ importers: version: 6.34.0 lefthook: specifier: ^2.1.10 - version: 2.1.10 + version: 2.1.12 miniflare: - specifier: 4.20260708.1 - version: 4.20260708.1 + specifier: 4.20260730.0 + version: 4.20260730.0 oxc-parser: specifier: 0.147.0 version: 0.147.0 oxfmt: - specifier: 0.64.0 - version: 0.64.0 + specifier: 0.66.0 + version: 0.66.0 oxlint: - specifier: 1.79.0 - version: 1.79.0(oxlint-tsgolint@7.0.2001) + specifier: 1.81.0 + version: 1.81.0(oxlint-tsgolint@7.0.2001) oxlint-plugin-react-doctor: specifier: 0.9.12 version: 0.9.12 @@ -151,8 +164,8 @@ importers: specifier: 7.0.2001 version: 7.0.2001 ultracite: - specifier: 7.10.7 - version: 7.10.7(oxfmt@0.64.0)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)) + specifier: 7.11.0 + version: 7.11.0(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))(prettier@3.9.6) apps/shell-super-app: dependencies: @@ -173,46 +186,52 @@ importers: version: 1.6.1 '@better-auth/api-key': specifier: 1.7.2 - version: 1.7.2(831f340a6e103a07b479cd8fecebd372) + version: 1.7.2(79cba0b50135889b6f65abdf41476109) '@better-auth/drizzle-adapter': specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3)) + version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))) + '@effect/opentelemetry': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@effect/sql-pg': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@modern-js/plugin-bff': - specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' + specifier: npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4(4ee1d8b8442de2cfc24c5713107ff8c2)' '@modern-js/plugin-i18n': - specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(i18next@26.4.2(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@modern-js/plugin-tanstack': - specifier: npm:@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12(@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893))(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4(@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)))(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@modern-js/runtime': - specifier: npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': + specifier: npm:@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' '@module-federation/modern-js-v3': - specifier: 2.8.0 - version: 2.8.0(patch_hash=56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3)(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + specifier: 2.9.0 + version: 2.9.0(patch_hash=ba5049c43645a4337e1a74857b2dcef5f5330692cc5d2186bcef236dc122dae8)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) '@tanstack/react-router': - specifier: 1.170.25 - version: 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 1.170.33 + version: 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@techsio/ui-kit': specifier: 0.25.1 - version: 0.25.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 0.25.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) better-auth: specifier: 1.7.2 - version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) i18next: - specifier: 26.3.6 - version: 26.3.6(typescript@7.0.2) + specifier: 26.4.2 + version: 26.4.2(typescript@7.0.2) jose: specifier: 6.2.5 version: 6.2.5 @@ -225,73 +244,73 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - react-router: - specifier: 7.18.1 - version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@cloudflare/workers-types': specifier: 5.20260810.1 version: 5.20260810.1 '@modern-js/adapter-rstest': - specifier: npm:@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12(2ed8dc7978631e6ad66c75a70c01a221)' + specifier: npm:@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' '@modern-js/app-tools': - specifier: npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893)' + specifier: npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' '@playwright/test': specifier: 1.61.0 version: 1.61.0 '@rsbuild/plugin-tailwindcss': specifier: ^2.0.3 - version: 2.0.3(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + version: 2.0.3(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': specifier: 14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': - specifier: ^26.2.0 - version: 26.4.1 + specifier: ^26.4.1 + version: 26.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 '@types/react': - specifier: ^19.2.17 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.7 + version: 19.2.7(@types/react@19.2.18) bun-types: specifier: 1.4.0 version: 1.4.0 + cross-env: + specifier: 10.1.0 + version: 10.1.0 drizzle-kit: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) happy-dom: specifier: 20.8.3 version: 20.8.3 tailwindcss: - specifier: ^4.3.2 + specifier: ^4.3.3 version: 4.3.3 typescript: specifier: 7.0.2 version: 7.0.2 wrangler: - specifier: 4.110.0 - version: 4.110.0(@cloudflare/workers-types@5.20260810.1) + specifier: 4.116.0 + version: 4.116.0(@cloudflare/workers-types@5.20260810.1) zephyr-rspack-plugin: specifier: 1.2.4 - version: 1.2.4(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + version: 1.2.4(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) packages/core-runtime: dependencies: @@ -299,27 +318,27 @@ importers: specifier: 1.6.1 version: 1.6.1 '@effect/platform-node': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(redis@6.2.1(@opentelemetry/api@1.9.1)) '@effect/sql-pg': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) pg: specifier: 8.22.0 version: 8.22.0 devDependencies: '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@types/node': - specifier: ^20.19.43 - version: 20.19.43 + specifier: ^26.4.1 + version: 26.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 @@ -328,7 +347,7 @@ importers: version: 1.0.0-rc.5-ab785fc effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) packages/gateway-principal-verifier: dependencies: @@ -339,43 +358,46 @@ importers: specifier: workspace:* version: link:../shared-contracts effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) jose: specifier: 6.2.5 version: 6.2.5 devDependencies: '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: ^26.4.1 + version: 26.5.0 effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) packages/shared-contracts: dependencies: '@app/core-runtime': specifier: workspace:* version: link:../core-runtime + '@effect/opentelemetry': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@modern-js/plugin-bff': - specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' + specifier: npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4(4ee1d8b8442de2cfc24c5713107ff8c2)' effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) devDependencies: '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: ^26.4.1 + version: 26.5.0 effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) packages/shared-design-tokens: {} @@ -394,41 +416,44 @@ importers: specifier: workspace:* version: link:../../packages/shared-design-tokens '@effect/opentelemetry': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@effect/sql-pg': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@modern-js/plugin-bff': - specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' + specifier: npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4(4ee1d8b8442de2cfc24c5713107ff8c2)' '@modern-js/plugin-i18n': - specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(i18next@26.4.2(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@modern-js/plugin-tanstack': - specifier: npm:@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12(@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893))(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4(@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)))(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@modern-js/runtime': - specifier: npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + specifier: npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': + specifier: npm:@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' '@module-federation/modern-js-v3': - specifier: 2.8.0 - version: 2.8.0(patch_hash=56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3)(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + specifier: 2.9.0 + version: 2.9.0(patch_hash=ba5049c43645a4337e1a74857b2dcef5f5330692cc5d2186bcef236dc122dae8)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) '@module-federation/runtime': - specifier: 2.8.0 - version: 2.8.0 + specifier: 2.9.0 + version: 2.9.0 '@tanstack/react-router': - specifier: 1.170.25 - version: 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 1.170.33 + version: 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) i18next: - specifier: 26.3.6 - version: 26.3.6(typescript@7.0.2) + specifier: 26.4.2 + version: 26.4.2(typescript@7.0.2) pg: specifier: 8.22.0 version: 8.22.0 @@ -438,52 +463,52 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - react-router: - specifier: 7.18.1 - version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 + specifier: 0.41.0 + version: 0.41.0 '@modern-js/adapter-rstest': - specifier: npm:@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12(2ed8dc7978631e6ad66c75a70c01a221)' + specifier: npm:@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' '@modern-js/app-tools': - specifier: npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893)' + specifier: npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4 + version: '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' '@rsbuild/plugin-tailwindcss': specifier: ^2.0.3 - version: 2.0.3(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + version: 2.0.3(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) '@rstest/core': specifier: 0.11.11 - version: 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + version: 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': - specifier: ^20 - version: 20.19.43 + specifier: ^26.4.1 + version: 26.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 '@types/react': - specifier: ^19.2.17 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.7 + version: 19.2.7(@types/react@19.2.18) '@typescript/native': specifier: npm:typescript@7.0.2 version: typescript@7.0.2 + cross-env: + specifier: 10.1.0 + version: 10.1.0 drizzle-kit: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc - version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) fast-check: specifier: 4.9.0 version: 4.9.0 @@ -494,34 +519,30 @@ importers: specifier: 6.2.5 version: 6.2.5 tailwindcss: - specifier: ^4.3.2 + specifier: ^4.3.3 version: 4.3.3 typescript: specifier: 7.0.2 version: 7.0.2 wrangler: - specifier: 4.110.0 - version: 4.110.0(@cloudflare/workers-types@5.20260810.1) + specifier: 4.116.0 + version: 4.116.0(@cloudflare/workers-types@5.20260810.1) zephyr-rspack-plugin: specifier: 1.2.4 - version: 1.2.4(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + version: 1.2.4(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) packages: - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + '@alloc/quick-lru@5.3.0': + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@antfu/install-pkg@2.0.1': + resolution: {integrity: sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ==} '@authzed/authzed-node@1.6.1': resolution: {integrity: sha512-Rj3rMtWOjo3igxY/2fpPrIedCTfDq3e+weykuxNBzV/y6azBCoXp8SzpjCEJXVcWyBD8bu/EKY3cye3kOLsKpQ==} - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -538,8 +559,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/generator@8.0.0': @@ -592,8 +613,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -614,16 +635,16 @@ packages: resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/traverse@8.0.4': resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@babel/types@8.0.4': @@ -720,33 +741,43 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} - '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12': - resolution: {integrity: sha512-mtDxN1lBqAOCrCA719fvCASjQytuJY5DJvPYldseV0DLyh6+pfvuarGXOmO5lxgJ+b7m7Le4P7tBX9ys3XNKQw==} + '@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4': + resolution: {integrity: sha512-Xg2KlNTR2ykZBSV+YC43YFnTNN4KpR1S4fCGiNpAdTF+AwhQEehuK0Tao5pZISkF1CzAZG9o/qsNHz4J0bCT8g==} - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': - resolution: {integrity: sha512-1mKQE+ufSnE/50kk69KUgLk+C3o65cJjAIKqKsAQPztgGImKF80RGR71+N7P9GB792d7pTy9SKmvOGBklzu+qQ==} - engines: {node: '>=20'} + '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4': + resolution: {integrity: sha512-RxME4ICgMzcHvkdeSED2uKBT++nkaIyX4vyqrtAN6Q4HdLeNP0bb95lZ1O8gH/ietY/Bn9R4UnuquMktbpNCbA==} + engines: {node: '>=26.7.0'} + peerDependencies: + '@rsbuild/core': 2.2.3 + + '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4': + resolution: {integrity: sha512-ZGI0+bkhtTf8yOoBnZ51eOYHVjUieEmr7VNYFkfFnn0y/ovBNgCW2UC6xdyakYIzwCwKwgbD+Rn6v28FHk5fNw==} + engines: {node: '>=26.7.0'} hasBin: true + + '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4': + resolution: {integrity: sha512-fw2DMByG8JxqQ7L7TzUHbK0LHFZiKqY4bqTxRxkjc2XNTob3ExzUw9/7V41xNLmwMgWA6i3C3w4x7DMd+kMKDA==} peerDependencies: - ts-node: ^10.9.2 tsconfig-paths: ^4.2.0 + zod: 4.5.4 peerDependenciesMeta: - ts-node: - optional: true - tsconfig-paths: + zod: optional: true - '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12': - resolution: {integrity: sha512-z7HNnkelLycG9skIXG0GiA3OL7G8mAltfGI6/8Yi6F5k/A0rAEmPtP4BIoLo+YImMyXe8/8vbPX+EFOxrGv6MA==} + '@bleedingdev/modern-js-bff-effect@3.9.0-ultramodern.4': + resolution: {integrity: sha512-tRGbFCs1gDpOJxGbeVe3wgDc+s1LsXoJF4HqtL3nehnsqvDvzDy2ZyCVLcvUbou/S3atw9e1Xtpx3Uh/DnZBtQ==} + engines: {node: '>=26.7.0'} peerDependencies: - tsconfig-paths: ^4.2.0 - zod: ^4.4.3 + '@effect/opentelemetry': 4.0.0-rc.112 + effect: 4.0.0-rc.112 peerDependenciesMeta: - zod: + '@effect/opentelemetry': + optional: true + effect: optional: true - '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12': - resolution: {integrity: sha512-U495muXo9KMx4zJmBpV0fz0g/oeqLuMYKjWkY/1CqjghkttDcU8EC0WHzsobyH2v8J2hckL6lVTnAePFczyW9Q==} + '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4': + resolution: {integrity: sha512-U1EO7uUcAApDVs2m0kv3KAH3cmHZjWknhzrg9ki7yXf7/POSqDiOWZQmkSW969crS2IiCBDFQKPzB6+IObSgyQ==} peerDependencies: react-server-dom-rspack: 0.1.0 rsbuild-plugin-rsc: 0.1.1 @@ -756,43 +787,61 @@ packages: rsbuild-plugin-rsc: optional: true - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': - resolution: {integrity: sha512-uE+B6XobpR1REg3Oxv0eIq8VP7/MyRQ/tU78vVO74EOaxxlPOC5G97ykbHTr316CEhusLWxf4qM1v50ly/vPQA==} + '@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4': + resolution: {integrity: sha512-WQHy3Wz6gu+FUwTUUzG+/hgtpUjUwgFSzz+8tIwcnB/+aIw+4OqS0GNR2Iz5JH2Sp0mbDiIBFAxIF/o4JZ3E/Q==} engines: {node: '>=20'} - '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12': - resolution: {integrity: sha512-n80cg2w4LoUnCJ9XN/bviK0y4MQDuHFfPkbzwIEcCV3QIOyfGvsHQZYBlquDCecIIimR0B7tYmtaxN46GFGGwA==} + '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4': + resolution: {integrity: sha512-RNas92s2c9CtyKr1ielTyq+Ysr3YKzXg2UMAQiag1TvYoC078+TR3mGozCYoyrFXV11wU27Gxer4UMf6nIbmKA==} - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': - resolution: {integrity: sha512-xqx65FiG8iIIKvtJ2FYF4y7r53g3BhTG92UJFrdYlAoehtNfWB4RDj9NaFuAwI1sYKIBuzBQKpq5kUYa4759Uw==} - engines: {node: '>=20'} - hasBin: true + '@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.4': + resolution: {integrity: sha512-3SNWl0+ZIPk1fL6Gt0bdXvjm7/yI3Z4JIKXJ75720lQDHXsRmpw/JD3gt+0dD5dcpr9J/YAIHljItV/IFe1qqA==} + engines: {node: '>=26.7.0'} + peerDependencies: + '@modern-js/runtime': 3.9.0-ultramodern.4 + react: ^19.2.8 - '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12': - resolution: {integrity: sha512-gUN9ApQTxJdtcHrfkuph3ICUxzTt1eFB2Mz/tZpgeXgz4J+NxtApF2YsfF9bUr7jXN41vMWbA+UgpaEDu5fTTA==} + '@bleedingdev/modern-js-i18n-utils@3.9.0-ultramodern.4': + resolution: {integrity: sha512-4enCKpMHz8e0Bi54AOR9wE62zX+GuJnet6/c6fdQMv6yf15aFB+G5TIRhEDr2gbN2DHjWSZVPrq64K/WAz3C2A==} + + '@bleedingdev/modern-js-plugin-bff-extensions@3.9.0-ultramodern.4': + resolution: {integrity: sha512-AN1bkOIkLVePVl7stoDwX5ers/h+UuGLoEYRAmuWsKK9da01d4ash9ce0GRfwX5vevvY+vad/OOgOyPsBA8LjQ==} + engines: {node: '>=26.7.0'} + peerDependencies: + '@effect/opentelemetry': 4.0.0-rc.112 + effect: 4.0.0-rc.112 + peerDependenciesMeta: + '@effect/opentelemetry': + optional: true + effect: + optional: true - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12': - resolution: {integrity: sha512-DrBWF0bjLexLgxVi9YC/YhaXpn2mB7hUMzTKFYE/3xIen8BtGCiM+yZhh5BUOiFjWg5XE7tE0Y/pO/P7UEgCIQ==} + '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4': + resolution: {integrity: sha512-+XIVwqe51Y46IAeN6Lj4N1B8TCZl35VQEnQBBcFWr5xP2TCbsIrexZcOIC9JQjCb4EWLQDbVLMfBFlzwKeDuEQ==} + engines: {node: '>=26.7.0'} peerDependencies: - '@effect/opentelemetry': 4.0.0-beta.107 - effect: 4.0.0-beta.107 + '@effect/opentelemetry': 4.0.0-rc.112 + '@modern-js/app-tools': 3.9.0-ultramodern.4 + effect: 4.0.0-rc.112 peerDependenciesMeta: '@effect/opentelemetry': optional: true + '@modern-js/app-tools': + optional: true effect: optional: true - '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12': - resolution: {integrity: sha512-d3zeFy20bxnCnL7gjdW3Zzh48MY0WcFAbGVHsku8QFrmj4Vj1/2wGpWreMTplFzz8UPL7WmT5KA2QhFJI0ZiDA==} + '@bleedingdev/modern-js-plugin-data-loader@3.9.0-ultramodern.4': + resolution: {integrity: sha512-iDkITek5Nv3y7XaRE4oJN+oPXji1XyzYlEepD5eopgzfwyLD7xo+isT7RtizhHoh6AyGFkyN95okpMfZqRSKUw==} engines: {node: '>=20'} peerDependencies: react: ^19.2.8 - '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12': - resolution: {integrity: sha512-ml+iuoWF9JfN+EJ1ERvXGEU1L5msGXq6rPPHvS56CkGztq49nDzzGRBdM4R0l5k1AgsvUPf9tXdfZbdddPHnVg==} + '@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4': + resolution: {integrity: sha512-avKxWn66RHafi6heoAF047XtfIwdxKTWz4qxoBu2YR7zzh5HuTAD47N+iZnhK13CEfWAyz9J2Q2r5ahr+crsmw==} engines: {node: '>=20'} peerDependencies: - '@modern-js/runtime': 3.8.2-ultramodern.12 + '@modern-js/runtime': 3.9.0-ultramodern.4 i18next: '>=25.7.4' react: ^19.2.8 react-dom: ^19.2.8 @@ -803,12 +852,12 @@ packages: react-i18next: optional: true - '@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12': - resolution: {integrity: sha512-Tr0We2IK7MqkpDcvE6WVtdjp6E+mXPrk1m9euJdUcyNq48kVll34Ksq1Kqz3pFIOxB3n+b7MD3mO0ZrE6oJS0A==} + '@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4': + resolution: {integrity: sha512-TWuieEetEA3DmhTjz0XVaGN+ooXfKqS4bRWpmywdWoDU26BORogYqDR1wm6SsRT60OT0lvoeAs6TobrqVeuENA==} engines: {node: '>=20'} peerDependencies: - '@modern-js/app-tools': 3.8.2-ultramodern.12 - '@modern-js/runtime': 3.8.2-ultramodern.12 + '@modern-js/app-tools': 3.9.0-ultramodern.4 + '@modern-js/runtime': 3.9.0-ultramodern.4 react: ^19.2.8 react-dom: ^19.2.8 react-server-dom-rspack: 0.1.0 @@ -816,15 +865,15 @@ packages: react-server-dom-rspack: optional: true - '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12': - resolution: {integrity: sha512-vAl0plFGRTxP4kQHk3GXmOFiWW6stR/MMmTy/Ws4zLri6jDDlIXD0IhsYt1lWcrIN7SGBInHlsGYlLu+sbMZMg==} + '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4': + resolution: {integrity: sha512-ARzPCkH+s/H4EsKuuKjUkiPsmlqTSlkCg+u295qX5KqBSckKSHq5Z4PE90NQ8PM+tpE9igWVxr1bp9thJLWE0g==} - '@bleedingdev/modern-js-prod-server@3.8.2-ultramodern.12': - resolution: {integrity: sha512-oyo3dSR9boiHWj1BdE5CJHV3BYdbZK/VIWVghUkydCWa44s1ek/ImDOX4B9X48PmQyuy0Dbfywp6nuI1YCgV3A==} + '@bleedingdev/modern-js-prod-server@3.9.0-ultramodern.4': + resolution: {integrity: sha512-5WEXTLw7VCcXN4ATmsOZ7hd37bnVhLSGUaB1W4+Y9HXMSEQbh6E1qI+5wCUI3flLT8pZG+0mR191gwDNwkfXKA==} engines: {node: '>=20'} - '@bleedingdev/modern-js-render@3.8.2-ultramodern.12': - resolution: {integrity: sha512-XTpDr00/naWTCrPYey7LzRqT7mCiePjbZlb8nH24auyPDt5ZzGsKTGVdBE/ranWJcukRCPHL+DpOebQOSnSGlA==} + '@bleedingdev/modern-js-render@3.9.0-ultramodern.4': + resolution: {integrity: sha512-LRsHwTJvWyP2AdlV2m4Ojxn3bLqeQxmKZQw4vOQwI/CuEBIEBFL7GnK952GQwQmDr86v8vKhyi5+XsDomtUA0g==} peerDependencies: react: ^19.2.8 react-dom: ^19.2.8 @@ -833,8 +882,12 @@ packages: react-server-dom-rspack: optional: true - '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12': - resolution: {integrity: sha512-cZ2cPpjnxm3/82OrV+kagA5hLo6EtQJLKK+opKztn4myo/MIqUTYRmQhGGYCeQ+yhXjs7vW4af736j72pZZ9Ig==} + '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4': + resolution: {integrity: sha512-c0UEnZOffwjcyCsKbkuoGWsD8/NaUr7dURi0NqU98/P47mVUbPiKV+zSXoypB2NcK7ABm2wCxIwWfM+Qr0umNA==} + engines: {node: '>=26.7.0'} + + '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4': + resolution: {integrity: sha512-0e9OyZ+5oT3NG5ReJxnzp5C8FOqPBjmtF0PbjyxKsA4dkv9ej6p8tzj0VcVz92u21M9xuB7bxqQVzuen9bCQKA==} peerDependencies: react: ^19.2.8 react-dom: ^19.2.8 @@ -844,34 +897,34 @@ packages: react-dom: optional: true - '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12': - resolution: {integrity: sha512-BL0/Llj2GTLLEHXAz6NMg90udvkfsdihc5c3i6rQoy8e6VNt3vQSURGIPVT8RulP3UatGrzazra4nTvVd2Ww1Q==} + '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4': + resolution: {integrity: sha512-g1YesNplAqN1qn7DIaepYNFrfjfFTjp5NafoKkGgwgEF4yj/HosMtwWA5m9DwqmrfYDdFINULAlBf3pm8XsYEw==} engines: {node: '>=20'} peerDependencies: react: ^19.2.8 react-dom: ^19.2.8 - '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12': - resolution: {integrity: sha512-FVjAMn9ZzR+vkBVQ+Ucwjxg39SYnVuHw5lcLRqig8l+2DCFsOcfgSmJjhLcbYh92jgDqH9k/hITTIYfIsvd3Cw==} + '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4': + resolution: {integrity: sha512-pOav4EwRat20kTq9BAnYu+uQ5++nV5Xe6tYoYkmLmIkrM6lrF/rVpPQ7i3IkA/DLTXna8yT8/8LykXIVddDqiw==} engines: {node: '>=20'} - '@bleedingdev/modern-js-server-runtime-extensions@3.8.2-ultramodern.12': - resolution: {integrity: sha512-O540pqKNT4nNTNpQvdXMeRy15PJsOtscUVEQO7kJfqjpm8iO0TqOsmidNQR8m1js8DFA/BDTrnsaZ4zgdphrnw==} - engines: {node: '>=20'} + '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4': + resolution: {integrity: sha512-YiA+plT14La8TICMUtMRJQj8Ef5e8LmDBNRiRlZKJjx3ByuUha65vII8Kq1NoyafUhHTuM6zxKYUYtoQpdrYVw==} + engines: {node: '>=26.7.0'} - '@bleedingdev/modern-js-server-runtime@3.8.2-ultramodern.12': - resolution: {integrity: sha512-KxfsF/bcFD+opZlguWJhbquzEyO0pN/QMDnLzxPGHHfMVALIiIN1Brnbg19Mp56elq4UcXcWRayjSl5ZvXe3Hw==} + '@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.4': + resolution: {integrity: sha512-QByd/4/4INGmBbQLWMl5JNWOMZPJfYLlyX2pg4bkkcaDKkGa6jkxYI/L8RttTKK4qQx8suaiG70fchKgyuus1g==} - '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12': - resolution: {integrity: sha512-hAJDnNSuE/jLyrnmZla+W0eriLblgF7B0IbiIjvVSfCmVHf4sa4VaRzsZkF0sX+MxN/UWUqpCCuQFtOsTS58Mg==} + '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4': + resolution: {integrity: sha512-Bw8Nneq2xhyhaHGp7PYyMvMcoPsiRDXRhthj9oIMljfdAm89RCqhS5nG5uyWwPy6VOG4MmlDgMc5Fh44YypP0w==} peerDependencies: '@typescript/native-preview': '>=7.0.0-dev.20260628.1' peerDependenciesMeta: '@typescript/native-preview': optional: true - '@bleedingdev/modern-js-server@3.8.2-ultramodern.12': - resolution: {integrity: sha512-BhUNvWC4znDSqoIfX/7Jwc53XYETvcvk3a7pKpRo40FZwTKFPBs/AxSuR3N6PBoaocYhZxac6vhzUO4MfgxoEQ==} + '@bleedingdev/modern-js-server@3.9.0-ultramodern.4': + resolution: {integrity: sha512-Gn8A4mW3bzJ8U2KHBUNMJlExsEutz/0iKvtLmScyl95wW+rdxVTKhrU6hf0GOipJpzF2bmkCKUsiGKjEXgl+gw==} peerDependencies: devcert: ^1.2.3 tsconfig-paths: '>=4.2.0' @@ -881,11 +934,16 @@ packages: tsconfig-paths: optional: true - '@bleedingdev/modern-js-types@3.8.2-ultramodern.12': - resolution: {integrity: sha512-8Yb8ahUGrkznfHzH24s3QnhLeOfjpyy2eN6a89YhR3XfK4nJHgOH6rnw3K3XPEXNSpcaO8Xn2TrZ5eUs6T+5rA==} + '@bleedingdev/modern-js-types@3.9.0-ultramodern.4': + resolution: {integrity: sha512-2jOpqNfbZmTWHm1LkSsXNPTvNm7j2ZkRFr6QR1173ooHiFOfTMP/xGd8W5x4auBm0PCRQVhnTSSg33KTO4dpwA==} + + '@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4': + resolution: {integrity: sha512-OmATAYsm8+c47kGxJkz8E2sWS2e++m5NgX/2VIgtRYoeCuVHoGEYy/2eBT3kWt2Z8v/7vh/gyjp+W/7i2OrEDg==} + engines: {node: '>=26.7.0'} + hasBin: true - '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12': - resolution: {integrity: sha512-JkEYRbNyfO+ppUtHEd78OcpmVf9MFJipCWzbKEgzl1vWr7VniNOxOWDAymmKokdjK7TNXpRK9iiMhJhoUHZsWQ==} + '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4': + resolution: {integrity: sha512-EMNhZTfgSvUts+3y/CVvNAEfboRmcI/6+ARKCCxuG+UjXOeL4N2v4A+yQweSL3q9q64o/0hCW7JfQv3Qwl50qw==} peerDependencies: react: ^19.2.8 react-dom: ^19.2.8 @@ -895,15 +953,15 @@ packages: react-dom: optional: true - '@bufbuild/protobuf@2.13.0': - resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} + '@bufbuild/protobuf@2.14.1': + resolution: {integrity: sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw==} - '@clack/core@1.4.3': - resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + '@clack/core@1.5.0': + resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.7.0': - resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + '@clack/prompts@1.8.0': + resolution: {integrity: sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==} engines: {node: '>= 20.12.0'} '@cloudflare/kv-asset-handler@0.5.0': @@ -919,32 +977,32 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260708.1': - resolution: {integrity: sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==} + '@cloudflare/workerd-darwin-64@1.20260730.1': + resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260708.1': - resolution: {integrity: sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==} + '@cloudflare/workerd-darwin-arm64@1.20260730.1': + resolution: {integrity: sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260708.1': - resolution: {integrity: sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==} + '@cloudflare/workerd-linux-64@1.20260730.1': + resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260708.1': - resolution: {integrity: sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==} + '@cloudflare/workerd-linux-arm64@1.20260730.1': + resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260708.1': - resolution: {integrity: sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==} + '@cloudflare/workerd-windows-64@1.20260730.1': + resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -952,8 +1010,11 @@ packages: '@cloudflare/workers-types@5.20260810.1': resolution: {integrity: sha512-aD0hEU6HaiG4wy4hDoPoLXMH963nHD4MsIY566pGkWO2dL/yeYEiMN7SeJ24t+wBmLDnh16yQ7IPos5opGkIoA==} - '@colordx/core@5.5.0': - resolution: {integrity: sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==} + '@colordx/core@5.8.0': + resolution: {integrity: sha512-cG0QJAO6VkaRUlIb0zOzX9gfJgs1pjoOL9gZ/PK1kfvBs0GCkADu5oQh6gPxXgQnZ3gV575h+lQRC0NlMDTclA==} + + '@colordx/core@6.4.0': + resolution: {integrity: sha512-KJf2x955gXdCXTVQVd4kh4V89UJq/iUg366DHL1dv5+Bdugehak9lK2veBvOBwOqlusCCo+hKpVXDG437EW2/g==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -976,8 +1037,8 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@csstools/selector-resolve-nested@4.0.0': - resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + '@csstools/selector-resolve-nested@4.0.1': + resolution: {integrity: sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==} engines: {node: '>=20.19.0'} peerDependencies: postcss-selector-parser: ^7.1.1 @@ -997,11 +1058,11 @@ packages: '@cyberalien/svg-utils@1.2.19': resolution: {integrity: sha512-paDJoDu+LhuH5Ma1q0BDqBpg6d16Xk22cZZrlg/s/J9oB8KgnnpU/snaEETJu2G1pwYcWQINUz6WUqu0Wm9k6A==} - '@drizzle-team/brocli@0.12.0': - resolution: {integrity: sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ==} + '@drizzle-team/brocli@0.12.1': + resolution: {integrity: sha512-yHwomvolVafvUXhy5TdiJVkuNCxXn+bKOs2aWiWEQh/5YaV97Oa/abJz3dmx7lxgIUsUlS1Jl4mEfvJPo3YsVw==} - '@effect/opentelemetry@4.0.0-beta.107': - resolution: {integrity: sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg==} + '@effect/opentelemetry@4.0.0-rc.112': + resolution: {integrity: sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ==} engines: {node: '>=18.0.0'} peerDependencies: '@opentelemetry/api': '>=1.9.0 <2.0.0' @@ -1013,7 +1074,7 @@ packages: '@opentelemetry/sdk-trace-node': '>=2.0.0 <3.0.0' '@opentelemetry/sdk-trace-web': '>=2.0.0 <3.0.0' '@opentelemetry/semantic-conventions': '>=1.33.0 <2.0.0' - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 peerDependenciesMeta: '@opentelemetry/api': optional: true @@ -1036,57 +1097,57 @@ packages: resolution: {integrity: sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 - '@effect/platform-node@4.0.0-beta.107': - resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==} + '@effect/platform-node@4.0.0-rc.112': + resolution: {integrity: sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: 4.0.0-beta.107 - ioredis: '>=5.7.0 <6.0.0' + effect: 4.0.0-rc.112 + redis: '>=5.0.0 <7.0.0' - '@effect/sql-pg@4.0.0-beta.107': - resolution: {integrity: sha512-y5RWMhdLhFqn0picXqZIR4Bevo4Fo+aT2xHNiyZoAR86d8CnbXp6Agq9HmXIGWS/nd5f7Bs4d7Aif7QUTsUofg==} + '@effect/sql-pg@4.0.0-rc.112': + resolution: {integrity: sha512-UYUA3LAGH1Pg88Yau7eTlTLHRrIb/uTdxdPGxVcRdIjjrTzxtA7iKHR4hcmUeq5lQEcGLCopN7E4ffsAKF8vEQ==} peerDependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 - '@effect/tsgo-darwin-arm64@0.19.0': - resolution: {integrity: sha512-tieZTHXZlqOtm3YbGxclXNsyxILFtZqMC1fZEy2PAoEGKBHfwNc+r0gR3k3SU9dsSNj3KV8wLsXxfQr/VH1pAA==} + '@effect/tsgo-darwin-arm64@0.41.0': + resolution: {integrity: sha512-3iEPtcHF72yDjv7T5YpnX+Io1LDLywJb1oGG3Fp/Fth4xmqiT1Vacyz5wnPCfEWGQ6CyrlyCZSSjUxPyJ70+DQ==} cpu: [arm64] os: [darwin] - '@effect/tsgo-darwin-x64@0.19.0': - resolution: {integrity: sha512-gTxeWR5xkL+UIdrTDcnGDj0dFPnk2AxvHzrA8j3U9OQZ5yHaFK4DA8W2hVxbu2lvGoHeRlyrArBf8YomKnNRdQ==} + '@effect/tsgo-darwin-x64@0.41.0': + resolution: {integrity: sha512-rrVVNacJ/Qh8DfdfgNJDuzcVmR3xPnqes3z+F1kio99umJwPIRB8iPWRAY0SrO+Y84A/y2SSb0AjMGqA5V3Aiw==} cpu: [x64] os: [darwin] - '@effect/tsgo-linux-arm64@0.19.0': - resolution: {integrity: sha512-XEMv3PA8hNkSXAwAXYA5/FPYqV7eVrZS032P/3AuZTHr1jJTcesTK2ANfBHwwEKekmb7y3yBC7f73yhpXah4sg==} + '@effect/tsgo-linux-arm64@0.41.0': + resolution: {integrity: sha512-RVI+7pG3tP56LfH06a2aq4KLp2RQ0HohW8mmoGmrBPyNEL9DnzFEGB98ZoMJ+ERYUJoIp74Jy3nKRVbnzVhxFw==} cpu: [arm64] os: [linux] - '@effect/tsgo-linux-arm@0.19.0': - resolution: {integrity: sha512-SZlUqv74YZkBDd2czXmeOTPtWPOppWNFmwCN4k11s1K4Y/BfIpHmmv0TUHU5DcPe3IoHIJV1kxXrxhfxO1nQsQ==} + '@effect/tsgo-linux-arm@0.41.0': + resolution: {integrity: sha512-JddS91IaupLa22oc4YQiYppe7iAZAEyx/2iR9sAAAhpVanWACsIAcyUy4RJrs8sPLLoLsZQaKQa+9rbQO1FLsg==} cpu: [arm] os: [linux] - '@effect/tsgo-linux-x64@0.19.0': - resolution: {integrity: sha512-T3KDEWgWuS3XAuJ5eUkEgeOVgC7pYK/8/hxASEtHJR1NDGPPk6gjg+jfcc0Y5O9P6mG4GL9LQmBIw/G1+GFRHA==} + '@effect/tsgo-linux-x64@0.41.0': + resolution: {integrity: sha512-U3+kMDVe2Opa5mxbN4B6PgOizWx5B4LXLN98CZDUl2vYtRGfwrRRhKQiB+de3ELaZ0MuDjPlCvU7S2mXQ8UiuQ==} cpu: [x64] os: [linux] - '@effect/tsgo-win32-arm64@0.19.0': - resolution: {integrity: sha512-raoGQr2lNm0qO8sH3v4TlyUlTePm6c6y2FBIZQrUw8B4N/srHS5aVOUgWqnJ/WjuQKl1xCM5SoehuhRkGnoFzw==} + '@effect/tsgo-win32-arm64@0.41.0': + resolution: {integrity: sha512-H6/xkn+B4B63OPKc0UAzQQFvLs0MXKigiD9dtvQeCU7czWGztOQILuLkyHnM1Dlc+uy4cJ5eXHiMXd+lwl+E8g==} cpu: [arm64] os: [win32] - '@effect/tsgo-win32-x64@0.19.0': - resolution: {integrity: sha512-KRWaWLXWIEm5mR/qPi9UJIRQdNs7uPfstiEv3bNR3qMVhgwndL2e5XYUoQOOYEK4MTjLM0GO+uknlHHRfI5K8A==} + '@effect/tsgo-win32-x64@0.41.0': + resolution: {integrity: sha512-vRN/Mhi381xEGrvV68IN/VO4Lc0pnlVQzHSVmNCJXLwGLmJUvuvPoyZ7Lv4wwHRX9iZe8Rch21xUGu5jSQtUSw==} cpu: [x64] os: [win32] - '@effect/tsgo@0.19.0': - resolution: {integrity: sha512-NDyG/RufHigI0bdvyggbr53J6c1GWcnNkm7rUwQbBDuRI1KRThFh2WtugMLL+i6wrRiMXJtgLHzh9lIMqy9gGA==} + '@effect/tsgo@0.41.0': + resolution: {integrity: sha512-C/U7lFM0AXsfpqRilDOgwu+llBhAo8bcdIlzgbRWf1LWlU4KZKB2UsUvBPL3qPnm+wmbCvQLeTQc4BjV9oJrcg==} hasBin: true '@emnapi/core@1.11.2': @@ -1107,6 +1168,9 @@ packages: '@emnapi/wasi-threads@1.2.3': resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1119,6 +1183,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1131,6 +1201,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1143,6 +1219,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1155,6 +1237,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1167,6 +1255,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1179,6 +1273,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1191,6 +1291,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1203,6 +1309,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -1215,6 +1327,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -1227,6 +1345,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -1239,6 +1363,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -1251,6 +1381,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -1263,6 +1399,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -1275,6 +1417,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -1287,6 +1435,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -1299,6 +1453,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -1311,6 +1471,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1323,6 +1489,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -1335,6 +1507,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1347,6 +1525,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -1359,6 +1543,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -1371,6 +1561,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -1383,6 +1579,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -1395,6 +1597,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -1407,6 +1615,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1419,6 +1633,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1429,8 +1649,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@2.1.0': - resolution: {integrity: sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==} + '@eslint/compat@2.1.1': + resolution: {integrity: sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: ^8.40 || 9 || 10 @@ -1438,18 +1658,6 @@ packages: eslint: optional: true - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@1.2.1': resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1462,14 +1670,6 @@ packages: resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fallow-cli/darwin-arm64@3.22.0': resolution: {integrity: sha512-X74T3osJZqFjNAM4w9OK4PWMPiubNyotDQ5OGYyvU9nNejJ8Ov9MH37H/9HE9hjdw4PxtPWlvl3LbatmuV5WMg==} cpu: [arm64] @@ -1531,26 +1731,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - '@iconify-json/mdi-light@1.2.2': resolution: {integrity: sha512-86UV9uyNve8zRFWiPrOrrDp9GDzsZM7plYV/on4VjgLLqXlyriuy541eHZB7LIOzTUyIPVli7QiUpBbTtBhsFw==} @@ -1565,174 +1745,180 @@ packages: peerDependencies: tailwindcss: '>= 4.0.0' - '@iconify/tools@5.0.12': - resolution: {integrity: sha512-aFPwSFmFphUPVjNLUkgxgUPSPVgTEEjv0mE7NgvfoQBuE5TtsMrKa4HTY65cM5oXZ2a1xKQcW/RKhiOKEiAarw==} + '@iconify/tools@5.0.14': + resolution: {integrity: sha512-wJ4ZE3/HZh2iBqLwG+8FOMHe7CMJAJqwSXZSpPwMdj1gZM3Z4t2hbQMVw4Vm45oQ985qn0g89PUIcDEdKTheBA==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.4': - resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] '@internationalized/number@3.6.7': resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1741,16 +1927,16 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@jest/pattern@30.4.0': - resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + '@jest/pattern@30.5.0': + resolution: {integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/schemas@30.4.1': - resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@30.4.1': - resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + '@jest/types@30.5.1': + resolution: {integrity: sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jridgewell/gen-mapping@0.3.13': @@ -1766,8 +1952,8 @@ packages: '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1818,50 +2004,50 @@ packages: peerDependencies: tslib: '2' - '@jsonjoy.com/fs-core@4.64.0': - resolution: {integrity: sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==} + '@jsonjoy.com/fs-core@4.71.0': + resolution: {integrity: sha512-9DFR/j+bm+tig1abs1CWS1/r0IVjNUdTp/+q6R/PXayXpO1rgbUh7tkanGYP40I0zdxbOreN3tmzBpVHgfMKzg==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-fsa@4.64.0': - resolution: {integrity: sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==} + '@jsonjoy.com/fs-fsa@4.71.0': + resolution: {integrity: sha512-dRw5ojxzep3lntVGVBLzQUMYj1hXLH+DAz9sK0vKU+594x/zA2nYPOiitlyhYtOJ2nv1FXNq7c55rgwANknIWw==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-builtins@4.64.0': - resolution: {integrity: sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==} + '@jsonjoy.com/fs-node-builtins@4.71.0': + resolution: {integrity: sha512-BSzl+QFSxZF58BxGjV1wqCJ+qSn3b2IZnb+z3hDQq6goqOO5RuxF8gRihjsFx17AfpEpa7XjYcP8XpQtDU8RrQ==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-to-fsa@4.64.0': - resolution: {integrity: sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==} + '@jsonjoy.com/fs-node-to-fsa@4.71.0': + resolution: {integrity: sha512-OlXBZKIeDx5bIGcQmn0w+nVkLheCiuQSepKiBAiWSWimSdn8Q6Yc9ih2y8zStcJk31fieqnov9V+o8XTWn/5Rw==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-utils@4.64.0': - resolution: {integrity: sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==} + '@jsonjoy.com/fs-node-utils@4.71.0': + resolution: {integrity: sha512-YtzCL3jbKYx6LHxqS9ymJ9Ob7SO9cucI+kpNO3LijGNFEjFbvNsTlHkvFgocAMAjUk96TpQTCsYkzFQi1CdlAA==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node@4.64.0': - resolution: {integrity: sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==} + '@jsonjoy.com/fs-node@4.71.0': + resolution: {integrity: sha512-yt5Ak0otPdHPIlmMvF16aLG2qSZL52EYJ7HOkYpBcIPWw+kmT1FtTSj4oiV2OUkJbG8pLzA+RhMgrlRl85QuBw==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-print@4.64.0': - resolution: {integrity: sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==} + '@jsonjoy.com/fs-print@4.71.0': + resolution: {integrity: sha512-OhfDSdvyO8uGV0U11OP+mOeVCPgjuP1HqCGR/TdBQLxHoDEWJAK3KOP5fPXo57H7i3G0x0Vmv8xPRHDle4XGzA==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-snapshot@4.64.0': - resolution: {integrity: sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==} + '@jsonjoy.com/fs-snapshot@4.71.0': + resolution: {integrity: sha512-md+Wov365xa9A3nfW9YQTHLcweHHAee/e/0UoVRbldEHxboPJknuO3sEkNVVECO0dTqKra/ERaQylAMRrGWd0Q==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' @@ -1931,11 +2117,11 @@ packages: peerDependencies: webpack: ^5.0.0-beta.16 - '@module-federation/bridge-react-webpack-plugin@2.8.0': - resolution: {integrity: sha512-7AaaiE4YOXFb+st6xlVDK65aNKZYR9S9ykH0q2mkHAHTgquXciAjypS8JuhmKn7Lob/2rkplMOc4YsGxG3Ng9Q==} + '@module-federation/bridge-react-webpack-plugin@2.9.0': + resolution: {integrity: sha512-hv3fuQkGERQ/COBKTVbFV1GWovReSg/bzJzDuxWR1F84h6NYtbYMWQqX8Egvb7HKMtn9Rflzw0GeaLr8F08vDg==} - '@module-federation/bridge-react@2.8.0': - resolution: {integrity: sha512-+MIaWVaXyrFfE2qq7ErZeflSTscJtDV6g4lZ+TQxjup1nC83Zw+Nw0fvfJnScDR5lP5SM9epSvZrrZHxpFeEgw==} + '@module-federation/bridge-react@2.9.0': + resolution: {integrity: sha512-3wEVz9IMsDnbdTZjG+0XA7BHUU2yIvfTqWRrMPR9RciSOVojJUXocZ6Anksu7WhuYnasc7am8szpms7pZGotnA==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' @@ -1947,13 +2133,13 @@ packages: react-router-dom: optional: true - '@module-federation/cli@2.8.0': - resolution: {integrity: sha512-yTxdWkCJPPo+IGASz+NqdW13cw3DhjSBEn9r85aYn1ahckDwB1WcZe0OjDWMqCcR6yi0BMLedk0SWrUeUj0fWw==} + '@module-federation/cli@2.9.0': + resolution: {integrity: sha512-r9RdlLRy3zWxuWQRonif48xdDu1reBGx18QpkZwLQ4JfSrOARjycNIGY2Y7QaUw7dedzxyee4FtKFeX/kOVLYQ==} engines: {node: '>=16.0.0'} hasBin: true - '@module-federation/dts-plugin@2.8.0': - resolution: {integrity: sha512-defjq4jOWMEfeejezPWLP5sc8kw0O6FqTT7/E5rbZPEVyjB1A0U3ynhW6GDE5/6hk9/TzdbWS+fBNi4MqUOY6Q==} + '@module-federation/dts-plugin@2.9.0': + resolution: {integrity: sha512-uMGqEG/p9odG2BVr7WRbBe2OgrvzBd3LPzcp5WP+bm3djBdUJ4PZ3ozqKp6qpy+NFnlh6vnM815Xn6AXkKy1Vw==} peerDependencies: typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vue-tsc: '>=1.0.24' @@ -1961,8 +2147,8 @@ packages: vue-tsc: optional: true - '@module-federation/enhanced@2.8.0': - resolution: {integrity: sha512-h8vkLdhK7tlcSPmyYNGfGyt0pSzfDB0tYVYdyUt2tXwQRfaJAi3bsIpujMXElw3MXtOfSHESa6M/hPKrtWTHBw==} + '@module-federation/enhanced@2.9.0': + resolution: {integrity: sha512-Jd/JHoFL9fNKL4Nnzo/9hf6FD/oQo3gKpE8xt6EGuhxmrvg6wM0radPtqgHiSSWLMW7CiXr2agNjKvquKd83rw==} hasBin: true peerDependencies: typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1976,25 +2162,22 @@ packages: webpack: optional: true - '@module-federation/error-codes@2.8.0': - resolution: {integrity: sha512-Gaog9904EmxYOQV0hli3XQ7jXeFaADfh5bnBtTCtbZ37Qd/Sz9kQfd+gYQRyIj7RGmkv9DPiN/SsmrTMrTymKw==} - - '@module-federation/error-codes@2.8.2': - resolution: {integrity: sha512-8inlDv48QOjA//CLQ3epjoHEiMQGsz1Pmtu2N+s7gQVggn6AYHpjnMe8AsyGxtpaPg3wbX0HmBZtRFggpXUB9A==} + '@module-federation/error-codes@2.9.0': + resolution: {integrity: sha512-IGpd+VRlji3NyGOGGTsk7YEmPRKcDoBK4YHqIuP+OQwyb2YhHCUHO2vW9RXeQxCuKwi+c6xkTweRYG+Umy+0Zw==} - '@module-federation/inject-external-runtime-core-plugin@2.8.0': - resolution: {integrity: sha512-fW3jD1ZVds6r/Ul8TtUA42RsB0LfT1yjo5KjqgirH9QrmEMH21x44e3e6BC6IN820XKawRDSmz2kFA5YHIQp7Q==} + '@module-federation/inject-external-runtime-core-plugin@2.9.0': + resolution: {integrity: sha512-k3wCSZsY21HjYQ61wmIJUQleOgcHgqx/sX4qXYw0XnJnzHuFo64rzMvgr+TuBIgZ1peAQtfILLlTnWyTRjtZHw==} peerDependencies: - '@module-federation/runtime-tools': 2.8.0 + '@module-federation/runtime-tools': 2.9.0 - '@module-federation/managers@2.8.0': - resolution: {integrity: sha512-SnVBCwmi962WGg6hLFElxZUCnrRJdR6glE2ZKPBY/iK07AHUN2ZxuaBCBsVzyws+xLZGHZxBHmVstijTh8dSUA==} + '@module-federation/managers@2.9.0': + resolution: {integrity: sha512-8KhB2PF4g+M0hGaethU1H4GVWabLn5sF5TvnM6VxgAcDL4TfLY8aC/YobAXHG/SV0jpU5lsGe6s6xy6ccV0MQw==} - '@module-federation/manifest@2.8.0': - resolution: {integrity: sha512-wfVeBXc4/C2F70nRFSPqJhkcwbDgo+wQyEn3jbjJTDoUqxxhYBfHFs1ACBYOk3Qm97L7hHclHGtUG0/nvDEfAA==} + '@module-federation/manifest@2.9.0': + resolution: {integrity: sha512-Zhm9luVOw9XPou50eTwsvdgr208CszoQ6cGORQDCPAMhjpGb5oYFtilJ+LKW7hQ1LktD15bEZmGhC1anvgXEiQ==} - '@module-federation/modern-js-v3@2.8.0': - resolution: {integrity: sha512-zmFs0I/E3dLa8Vsj35ep7Ms29SePXFOShjedfFs5VnJPooif6l7fgdIJdxThyI1Rdw5JnRXmJsp4qmGm+naziA==} + '@module-federation/modern-js-v3@2.9.0': + resolution: {integrity: sha512-IzFEJnO53vEVLrm5qlDrCPO1bEmN11TLaPHrZtPqHfy7yjwhBDi/63uLAVwcqKVGZnK5Erbqg9Y3hJkZKxd4AQ==} engines: {node: '>=18.0.0'} peerDependencies: react: '>=17' @@ -2013,16 +2196,16 @@ packages: vue-tsc: optional: true - '@module-federation/node@2.7.47': - resolution: {integrity: sha512-mifMvCjWmLl53GS+badQws0j2bsu1ICpdGzCbez4I6kSpaYA8v86L6dwcHtVHIZtkUC6cjAZBDcgpxs4fK3nFQ==} + '@module-federation/node@2.7.50': + resolution: {integrity: sha512-mbpQRdafyeWgsmYoJfdhOQf76zS6onOGpC2X1ELpWXB1Y4BcZGloL0CLjNMNon9m3ucfpc99tOGAQqFzQVkSBQ==} peerDependencies: webpack: ^5.40.0 peerDependenciesMeta: webpack: optional: true - '@module-federation/rsbuild-plugin@2.8.0': - resolution: {integrity: sha512-rul5OPvLx599rWoAhCtKJ3UYqyM3Dxg0RWEfad3JdnJFqkcSLBojZXgHJE1vbF7DRdGQNmNscKw34iEyn89NwQ==} + '@module-federation/rsbuild-plugin@2.9.0': + resolution: {integrity: sha512-CaWAxZg+zOMw/BfgRXQdNBQZ/e5U7DBZvE13LzG13GEclt7yxaMQgKH0si3uOsq9f/X2NC8UnuhNHLIkloqlqA==} engines: {node: '>=16.0.0'} peerDependencies: '@rsbuild/core': ^1.3.21 || ^2.0.0-0 @@ -2030,8 +2213,8 @@ packages: '@rsbuild/core': optional: true - '@module-federation/rspack@2.8.0': - resolution: {integrity: sha512-TPcrkHpaZgL25Vx3c8oSNwyv7/KktC7uo6HTQdVWlFzbq5RSoMMGkWoir5pY5124isae2/p6v5xAuqICi4r0Zg==} + '@module-federation/rspack@2.9.0': + resolution: {integrity: sha512-9zSlmQYKRHKVWqhlZSMyjstp2A3VVrQV2Of8mrF5NXhngFemNx7Hw35+MTo+gsRfF5glpN8bAzLyIokRUo1Cog==} peerDependencies: '@rspack/core': ^0.7.0 || ^1.0.0 || ^2.0.0-0 typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -2042,32 +2225,23 @@ packages: vue-tsc: optional: true - '@module-federation/runtime-core@2.8.0': - resolution: {integrity: sha512-Tf98+epGGiPSHqmQHuXa2uXZMMvjGf1IqJDR1/FpXfmobv5ECN0mGZCjUHGNSyxvoDyXKIkKwJu7IwEoh0ouQA==} + '@module-federation/runtime-core@2.9.0': + resolution: {integrity: sha512-dLykRYfpbEJBTdk2NlbNoVVTB196O3qujawTBguLABJkPCWETJNk60NR1yZO34eI+DTH+eGwHU3m7FddAWnbnw==} - '@module-federation/runtime-core@2.8.2': - resolution: {integrity: sha512-PEkkK9MUp+nUCeQMS4ox3QGZfwwxgfjGA7P4umEnr5c3y8DLNDR+26tyHf/Gkjen2VsjlcyL+mEAQo1Zi4IE3g==} + '@module-federation/runtime-tools@2.9.0': + resolution: {integrity: sha512-u2puqsaHiw1bVvLNk1uh98iPo6UzaBuci/aTvOFwKvbT/RF18C3vGGnm+ShHPKQEnoN6ctPtnygyOZDPR+8cgg==} - '@module-federation/runtime-tools@2.8.0': - resolution: {integrity: sha512-3yOqjdSHXxX4HA3GhlXg3hghGAXW2RJUsnwXCcik2/lTxOHizKI8f3RM+GGCKPxDVqtw43IShe3tA12jNL5A/A==} + '@module-federation/runtime@2.9.0': + resolution: {integrity: sha512-3cyAav0hWP+dNvB7qrcK9CKxQn+JvkbRvLtZz3zS2g0EyxtDFDjgbHY0Afcf7oHf8IHhKeEdzb/3Kjr1Pjmr7A==} - '@module-federation/runtime@2.8.0': - resolution: {integrity: sha512-cGtUBQ1/TVy7KrXy6xPgy3FEmOGyIYkBA2T4iGH3ZH5PNPPTmqN9jF2AfneTSOj0RtBr7Pxq3CUt81E/UCvK1A==} + '@module-federation/sdk@2.9.0': + resolution: {integrity: sha512-IMjObgBGQTXd33jTTCYcxvz8iOQGLsZ2QIPOj90rDxuBtJsN4WJwYyXqligrhY/e5p/BipUPdbIgKmWL7zFhTw==} - '@module-federation/runtime@2.8.2': - resolution: {integrity: sha512-SUoP+PD5EjSPSi6FxEPGIZoRkFifxdeYcVQbJE9mO0VEjF51gAk3/TgX8k0vzUryOBPmXekLr9SfQXU6DqUtvA==} + '@module-federation/third-party-dts-extractor@2.9.0': + resolution: {integrity: sha512-R1Xuqnqzw6wQxWV3yU9fX1FydXAeZF2TNVFAVo1N1oLBA2j5y7aUO/Fc3VSNgd9/gxMzJgTVBdA+nMiD2SFCgg==} - '@module-federation/sdk@2.8.0': - resolution: {integrity: sha512-yBP+9+0Z8nlvKEXAZS3AsQVy7bFbZf8eMivGk4q4ZdwG3TsLMlsPjb1dQb2i7gcAG6ux9y2LWLkj/0LVk74cnQ==} - - '@module-federation/sdk@2.8.2': - resolution: {integrity: sha512-OPS/lbQjraLXoWniQpCwQ/vqgURHTrhsackSNcOPmcJHM3LyR+DabxUc0pl8jAqExsW2l+uepQq7+/Gkei871w==} - - '@module-federation/third-party-dts-extractor@2.8.0': - resolution: {integrity: sha512-nAMlr74OKIylkfRwlunOhytQbmsgb3gCqdXWnPQhG+ZtqWXGELLfMT4a1Q1ht3cS+sRpWj2SZRqK2M7GadI6tA==} - - '@module-federation/webpack-bundler-runtime@2.8.0': - resolution: {integrity: sha512-82fDy9v+7qV5fiN8TKVhOdrxhmAZnUIX/IKivYX5ulCt8aoOzVFTiwm/P1GQUDD8z6dqR48xgJdZdf0548Mc9w==} + '@module-federation/webpack-bundler-runtime@2.9.0': + resolution: {integrity: sha512-MdU6NQibT57MaJG3KPBjC30IVBrz5eU7IcjHoVDYnMh7OmASw3stagBu7hYjdMTP31luNdtzUn85s9axvdwTlw==} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} @@ -2105,13 +2279,20 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nkzw/eslint-plugin@2.0.0': resolution: {integrity: sha512-IoU8kOqHfnf7se2dGxMD/7RPm6WVjzjOjWSo7FulRx3lJzuZvXioSkT5Nd82l66DH3Pl2whw74lPT1di3KMwFA==} peerDependencies: eslint: '>= 9' - '@noble/ciphers@2.2.0': - resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} engines: {node: '>= 20.19.0'} '@noble/hashes@2.2.0': @@ -2130,64 +2311,64 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@opentelemetry/api-logs@0.220.0': - resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + '@opentelemetry/api-logs@0.222.0': + resolution: {integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@2.9.0': - resolution: {integrity: sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==} + '@opentelemetry/context-async-hooks@2.11.0': + resolution: {integrity: sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.9.0': - resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@2.9.0': - resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.220.0': - resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + '@opentelemetry/sdk-logs@0.222.0': + resolution: {integrity: sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.9.0': - resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + '@opentelemetry/sdk-metrics@2.11.0': + resolution: {integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.9.0': - resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.9.0': - resolution: {integrity: sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==} + '@opentelemetry/sdk-trace-node@2.11.0': + resolution: {integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace-web@2.9.0': - resolution: {integrity: sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==} + '@opentelemetry/sdk-trace-web@2.11.0': + resolution: {integrity: sha512-8hgLI437x8VRKzgUr+WRcHFp9AeaHmveKRsPIMNele9tethgCKprakRRqZCWyt41ilALHATfspUMl1An0OTMUQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace@2.9.0': - resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -2549,246 +2730,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.63.0': - resolution: {integrity: sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm-eabi@0.64.0': - resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} + '@oxfmt/binding-android-arm-eabi@0.66.0': + resolution: {integrity: sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.63.0': - resolution: {integrity: sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-android-arm64@0.64.0': - resolution: {integrity: sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==} + '@oxfmt/binding-android-arm64@0.66.0': + resolution: {integrity: sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.63.0': - resolution: {integrity: sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-arm64@0.64.0': - resolution: {integrity: sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==} + '@oxfmt/binding-darwin-arm64@0.66.0': + resolution: {integrity: sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.63.0': - resolution: {integrity: sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.64.0': - resolution: {integrity: sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==} + '@oxfmt/binding-darwin-x64@0.66.0': + resolution: {integrity: sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.63.0': - resolution: {integrity: sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-freebsd-x64@0.64.0': - resolution: {integrity: sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==} + '@oxfmt/binding-freebsd-x64@0.66.0': + resolution: {integrity: sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': - resolution: {integrity: sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': - resolution: {integrity: sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.63.0': - resolution: {integrity: sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + resolution: {integrity: sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.64.0': - resolution: {integrity: sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==} + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + resolution: {integrity: sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.63.0': - resolution: {integrity: sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-gnu@0.64.0': - resolution: {integrity: sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==} + '@oxfmt/binding-linux-arm64-gnu@0.66.0': + resolution: {integrity: sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.63.0': - resolution: {integrity: sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-arm64-musl@0.64.0': - resolution: {integrity: sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==} + '@oxfmt/binding-linux-arm64-musl@0.66.0': + resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.63.0': - resolution: {integrity: sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-ppc64-gnu@0.64.0': - resolution: {integrity: sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==} + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.63.0': - resolution: {integrity: sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==} + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.64.0': - resolution: {integrity: sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-musl@0.63.0': - resolution: {integrity: sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-riscv64-musl@0.64.0': - resolution: {integrity: sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==} + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.63.0': - resolution: {integrity: sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-s390x-gnu@0.64.0': - resolution: {integrity: sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==} + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.63.0': - resolution: {integrity: sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.64.0': - resolution: {integrity: sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==} + '@oxfmt/binding-linux-x64-gnu@0.66.0': + resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.63.0': - resolution: {integrity: sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-x64-musl@0.64.0': - resolution: {integrity: sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==} + '@oxfmt/binding-linux-x64-musl@0.66.0': + resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.63.0': - resolution: {integrity: sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-openharmony-arm64@0.64.0': - resolution: {integrity: sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==} + '@oxfmt/binding-openharmony-arm64@0.66.0': + resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.63.0': - resolution: {integrity: sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-arm64-msvc@0.64.0': - resolution: {integrity: sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==} + '@oxfmt/binding-win32-arm64-msvc@0.66.0': + resolution: {integrity: sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.63.0': - resolution: {integrity: sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.64.0': - resolution: {integrity: sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==} + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + resolution: {integrity: sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.63.0': - resolution: {integrity: sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.64.0': - resolution: {integrity: sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==} + '@oxfmt/binding-win32-x64-msvc@0.66.0': + resolution: {integrity: sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2823,252 +2882,130 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.78.0': - resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm-eabi@1.79.0': - resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} + '@oxlint/binding-android-arm-eabi@1.81.0': + resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.78.0': - resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-android-arm64@1.79.0': - resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} + '@oxlint/binding-android-arm64@1.81.0': + resolution: {integrity: sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.78.0': - resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-arm64@1.79.0': - resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} + '@oxlint/binding-darwin-arm64@1.81.0': + resolution: {integrity: sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.78.0': - resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.79.0': - resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} + '@oxlint/binding-darwin-x64@1.81.0': + resolution: {integrity: sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.78.0': - resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-freebsd-x64@1.79.0': - resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} + '@oxlint/binding-freebsd-x64@1.81.0': + resolution: {integrity: sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': - resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': - resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.78.0': - resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + resolution: {integrity: sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.79.0': - resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + resolution: {integrity: sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.78.0': - resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-gnu@1.79.0': - resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} + '@oxlint/binding-linux-arm64-gnu@1.81.0': + resolution: {integrity: sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.78.0': - resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-arm64-musl@1.79.0': - resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} + '@oxlint/binding-linux-arm64-musl@1.81.0': + resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.78.0': - resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-ppc64-gnu@1.79.0': - resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.78.0': - resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.79.0': - resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.78.0': - resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-riscv64-musl@1.79.0': - resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} + '@oxlint/binding-linux-riscv64-musl@1.81.0': + resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.78.0': - resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-s390x-gnu@1.79.0': - resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} + '@oxlint/binding-linux-s390x-gnu@1.81.0': + resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.78.0': - resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.79.0': - resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} + '@oxlint/binding-linux-x64-gnu@1.81.0': + resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.78.0': - resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + '@oxlint/binding-linux-x64-musl@1.81.0': + resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-linux-x64-musl@1.79.0': - resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.78.0': - resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-openharmony-arm64@1.79.0': - resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} + '@oxlint/binding-openharmony-arm64@1.81.0': + resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.78.0': - resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-arm64-msvc@1.79.0': - resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} + '@oxlint/binding-win32-arm64-msvc@1.81.0': + resolution: {integrity: sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.78.0': - resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.79.0': - resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} + '@oxlint/binding-win32-ia32-msvc@1.81.0': + resolution: {integrity: sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.78.0': - resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.79.0': - resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} + '@oxlint/binding-win32-x64-msvc@1.81.0': + resolution: {integrity: sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/plugins@1.79.0': - resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} + '@oxlint/plugins@1.81.0': + resolution: {integrity: sha512-HhD8kd3r6XpelZkhxhRty/po8V6yK1VV63Eq4kSsOS2IoHUywG3DV2EX+z/ZHw46aLI74Y6aA604NRiAqLKW9w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@parcel/watcher-android-arm64@2.6.0': @@ -3208,6 +3145,42 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@redis/bloom@6.2.1': + resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/client@6.2.1': + resolution: {integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.2.1': + resolution: {integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/search@6.2.1': + resolution: {integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/time-series@6.2.1': + resolution: {integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + '@rollup/pluginutils@5.4.0': resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} @@ -3217,18 +3190,8 @@ packages: rollup: optional: true - '@rsbuild/core@2.2.0': - resolution: {integrity: sha512-UnBBfxWIDKVdLz2BUBq7hFBatwLclJ4moFhlDFg+pFBPPJ1g34MmCbGUC0c9Mo1DhPGdYJG69qMIldh5MvC74w==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - core-js: '>= 3.0.0' - peerDependenciesMeta: - core-js: - optional: true - - '@rsbuild/core@2.2.0-rc.0': - resolution: {integrity: sha512-f6orjv+wOR1u7KchE/vANGP0Eg7AP0UaiM0qn4n+5I0HOUdkVVbNCA1eZSpyX+TJ9bIdZtkbR6T47vBdoFr1MA==} + '@rsbuild/core@2.2.3': + resolution: {integrity: sha512-oJrYtYDhb7AMwY8HIda2hgMuuxHkYPYar4svdS5uTq0fXY0M1wLfllkTQz0d729pNJ4S//6GUM1WX5LsW2mFLw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3245,14 +3208,6 @@ packages: '@rsbuild/core': optional: true - '@rsbuild/plugin-check-syntax@1.6.1': - resolution: {integrity: sha512-26xtEYN0QjZYoyt0lWnvIztBWjEZJvcfw7MN4f5B4SpNggmnF7F7aNPrgkY3EccXVFx1VGQBhnCkBV//OoS07Q==} - peerDependencies: - '@rsbuild/core': ^1.0.0 || ^2.0.0-0 - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@rsbuild/plugin-check-syntax@2.0.1': resolution: {integrity: sha512-z+NMAUXEbM4fhoQlKJNgTjsf+O1tknBihsXj4JnSFlwpfoY5uDjKC6D+StATATvUilSKXFrk4WDhcIyoxkj1gg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3345,214 +3300,88 @@ packages: '@rsbuild/core': optional: true - '@rsdoctor/client@1.6.1': - resolution: {integrity: sha512-UWc8uyXPtcGZuQtVX+jBZwPxMXrMrrp0GrbVvHoHjzTxWufdn4RcG1g4PFgZLPz5smcCGYOEaNvlNn5WOtVyMw==} - - '@rsdoctor/core@1.6.1': - resolution: {integrity: sha512-PkNe5Z45gR3dEhFdOcN/ZTwUxTltJR66cJA4DHXj1Zuo9s8d6SKWHFBLi5zMqPV3ZyBAQl4tsGv2LoJDQa69wA==} - - '@rsdoctor/graph@1.6.1': - resolution: {integrity: sha512-LHbJKwO31BujQNUsD28kDoQKa5EkRxtSOABuHWRTv62drzz4NF9ZGSIHmrd6/jkbDBdCZEngZIwBkTSi3gtFoA==} - - '@rsdoctor/rspack-plugin@1.6.1': - resolution: {integrity: sha512-P9/8wAF4rBEujzB42Afz9zLUR+DoaIDlsyO9Hk2Zzg4e6A+6PlZjma06zhEDbPgcAIJ3PyeX/xgH+5pcc9HjUQ==} - peerDependencies: - '@rspack/core': '*' - peerDependenciesMeta: - '@rspack/core': - optional: true - - '@rsdoctor/sdk@1.6.1': - resolution: {integrity: sha512-9dBk9SDVcY2Z8mHepUKDpHc3eaAsYNSioLlbNPNVLH4XinzcRe/4kJiX37VNjUVPv93IzZ6GTJhkiax/6O+fGw==} - - '@rsdoctor/types@1.6.1': - resolution: {integrity: sha512-E01VGaDGvXGig3rqQC+YRRPbGQ6tBLi9XkYCl579Hq01iYHeuVYLl1bfnODAt8SkdDYGJDMFuUWj/01A0vVy7g==} - peerDependencies: - '@rspack/core': '*' - webpack: 5.x - peerDependenciesMeta: - '@rspack/core': - optional: true - webpack: - optional: true - - '@rsdoctor/utils@1.6.1': - resolution: {integrity: sha512-Ll+X1nAE3eBq9BpBNZvAT+4hZZMQt/rxrBRzL/I8o6S3CBpo5Kh2A2JaYgCJLiYKh0kFQfuIWuOVpR3/yoFWJg==} - - '@rspack/binding-darwin-arm64@2.2.0': - resolution: {integrity: sha512-KAVVT7hp3NBjtc/RY2UtOjzzc8i+s4pIhW1p52UV+Aev6ywQCu3dXwkHTonpPvJO3hqLXc4zIMH5l4HbMqBm4g==} - cpu: [arm64] - os: [darwin] - - '@rspack/binding-darwin-arm64@2.2.0-rc.0': - resolution: {integrity: sha512-Uvy3YnN1pCLe+2/8hF06KiCPegf8ztOuM3VE/p0MJKRitkL5DPoZVHyc40zl6e+O5WB/9tJ5tnEgRG9pDWxFng==} + '@rspack/binding-darwin-arm64@2.2.3': + resolution: {integrity: sha512-MGLx4lMTY6XEtJi55S+fk83+683GeUDuoqzjixbq6ehHdr/l6nGiHhgvMtV5DYt9sCytUDzyrCkcJ0AWqJxnTA==} cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@2.2.0': - resolution: {integrity: sha512-rzyJCX99aFwl540trsVMNZOgK4+IFm2d5+YeP+RdNo9Uprxloz8vHz0J4dYtaq6MRiCAyM60dAwEa3wJMwqWAQ==} - cpu: [x64] - os: [darwin] - - '@rspack/binding-darwin-x64@2.2.0-rc.0': - resolution: {integrity: sha512-UQO0PgLarsA0lv96uqCTAH1eAnAIPHb+qhMfds5ubWKZgKlr0taGQl253C3DN5pfzbHWfNQgAfVUaVMydm9czw==} + '@rspack/binding-darwin-x64@2.2.3': + resolution: {integrity: sha512-5B/c1YTYEz9m7TyqtGQgTmXMr9PoPewyqsyHB4N0AB7nxDhtgmki0GniP2Lqtx3LJzOvQMjwet+I2lmWxvxwCg==} cpu: [x64] os: [darwin] - '@rspack/binding-linux-arm64-gnu@2.2.0': - resolution: {integrity: sha512-0t8QOiOMcBV7RvPSsTJ5DQ4QCK6FIyUZy77qbxnS6asGTOXPZZn7V5cL26IxEv/wuHdQ6tQOXheau1fi+gGyBQ==} + '@rspack/binding-linux-arm64-gnu@2.2.3': + resolution: {integrity: sha512-sU+jPGD2xz3BxFvsdBQVGce538YHqrSKMB4ZSfKhj6RSKjeGzpJ1BVl8+IndiQsfTgaUxi7N+r5cs/ezGy1TVQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rspack/binding-linux-arm64-gnu@2.2.0-rc.0': - resolution: {integrity: sha512-ZggL6W9ckpvhqIPi7K3zDRiEaQd5Co2qRnN5J8OMmBkQlvYQek0CE/SZHFcZsDM0//6+0mkI+VjaTeWdvqJsaQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rspack/binding-linux-arm64-musl@2.2.0': - resolution: {integrity: sha512-BAvCukqcuHxUdE294ITCohvhVkEklW8RbkKkR36Uo0WyIiMPGrnvPjARPn0/4Q4xMAz7lUmC60sZrvJHlAOKMw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rspack/binding-linux-arm64-musl@2.2.0-rc.0': - resolution: {integrity: sha512-MfDTg9fn/17lRtGMsHLK8BQJs+AEtTsVMWOg1CMou/ls8IrucXoFZ9Ux0i2Jq1ph1ZiKgr4l7pMzdk3SXpNiQg==} + '@rspack/binding-linux-arm64-musl@2.2.3': + resolution: {integrity: sha512-EQuADbqWbVgNAh5ep/u/B7Z/8UUkZjMLzHAOKZJklBpe4Y8mnhFNjieX0xc9NadR+drj9tqmEANxzTOkFtVPyQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rspack/binding-linux-ppc64-gnu@2.2.0': - resolution: {integrity: sha512-nCHqZLv/E8nm2ccGkb00F5DQtXxzGy3W3X73ArA+N0+zXJUnzRcSRSwr7AE8pVgP/FYfX4yMFgUXy0g0YxYGRA==} + '@rspack/binding-linux-ppc64-gnu@2.2.3': + resolution: {integrity: sha512-kyXROzr519a5juBII9ysuc5ZD2apEZ0DJqL5PPCAOVHjzMfThEIBwnX3gicNCmp+kY0CCIOl2LagwbtFaOzoiA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rspack/binding-linux-ppc64-gnu@2.2.0-rc.0': - resolution: {integrity: sha512-n84s99eIMr/4xQ1XCctxtu4AnchukynuyJ4DaJ4vlcRKVdFAnPSpUH669rAxztsby5xa3ftAASmxwXhMUFzMsg==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rspack/binding-linux-riscv64-gnu@2.2.0': - resolution: {integrity: sha512-CA3WEqKFDI6FAZTnCho2n9pmdPWZYAW/S8mqgxd0cx2Jix43at3VyLxhCC7ED5A9WBSFn/AdHaIbVtgoQHVhWA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rspack/binding-linux-riscv64-gnu@2.2.0-rc.0': - resolution: {integrity: sha512-s5bg/LlwnMSVXZfR16KGO3jVWUpobbPp90qE2DXwfaFpcLmMweUnANERM2mCpIiW3MuVnTAXTEjvEcxfB4mXEw==} + '@rspack/binding-linux-riscv64-gnu@2.2.3': + resolution: {integrity: sha512-F3x6Nu0RUSoLtiaMse3hYQ3Nf5A2de6vTWWDXj14Ll6BpGWXGn+U/eXT/nB199fFnDEYf+CJEa7d6fzcYdwdjA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rspack/binding-linux-riscv64-musl@2.2.0': - resolution: {integrity: sha512-kHB960oClkoPRPZ6sdkhRvqbdRIlbpIMYd/Tbxfmn3DWQahiCk1pkUFJbOtFq3EgESxZISV4THl442W2Y57HvQ==} + '@rspack/binding-linux-riscv64-musl@2.2.3': + resolution: {integrity: sha512-O3HK7OnPvoBxZu4MX2j+t3gk4qiUsFYqj/29L3WgU1iQsdUSIN6PInW2JvbiLqyuzt09VltIEXTKayY8EysCpQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rspack/binding-linux-riscv64-musl@2.2.0-rc.0': - resolution: {integrity: sha512-XNjEnkrNv67CZ4/IhkPQVfNkz9JHevelgZspzuuJOOyn+Z9b1m8JN+shv5Kq06sSudN9aQpLLa6slbHlKGv9lA==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rspack/binding-linux-s390x-gnu@2.2.0': - resolution: {integrity: sha512-lVBdiffVo1jq0P0jT36jNou2suLB4ueQI4aWUs+HM+h67YPBtVKWu/mo5Wh59+8nowgcZmYaFM5hdH69963I9w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rspack/binding-linux-s390x-gnu@2.2.0-rc.0': - resolution: {integrity: sha512-OQpj1j6Jfy+YBYDSGwJisZZEXS3g0Y/M5xlb26yqlZBKA/7kamCMDccUPTKNpuJaRQXK1DUerBsA+AK8TELG3g==} + '@rspack/binding-linux-s390x-gnu@2.2.3': + resolution: {integrity: sha512-SIxfGdwBdAjwJ8pDlPTb8MPIZIv/i2lKHn4/ywuDpFOxlUA4Bo/OKqQOenHx2MFD2mN8jigBZQTF0KVe5YP1uQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rspack/binding-linux-x64-gnu@2.2.0': - resolution: {integrity: sha512-M49UaWspE0YJ3268DsquD8idEQTfjBDMvO/I8qccV/Z5T+Q98FJ+kIs5liUaTWb48OIbDEK+8ZKx5QzLbfVN6g==} + '@rspack/binding-linux-x64-gnu@2.2.3': + resolution: {integrity: sha512-IzQGcw09WYn/IzwB1SQpg7FQ211MBr+fJB2/WoRrPS6CNdtlkDHZaI8z2oq+2Oc5Su21jgX75VlWFajirUx5Dw==} cpu: [x64] os: [linux] libc: [glibc] - '@rspack/binding-linux-x64-gnu@2.2.0-rc.0': - resolution: {integrity: sha512-1QoW+7zjApCgL4v5P14AmnxfvOMCk131abSBCl/+u6h/aIainSwpf+O3ar0FqvkQaSPPOQJrg03ys57WyEwlAQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rspack/binding-linux-x64-musl@2.2.0': - resolution: {integrity: sha512-YYbs0wmey+5blhEQDE4Dax3TwJtqfGwe2QBm3OLphlBHo/fcZVvimzKkMV0/pVrZTLy2z5ZAwNhGMY64bNr77w==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rspack/binding-linux-x64-musl@2.2.0-rc.0': - resolution: {integrity: sha512-KLN4bIC3M1dSEuPBX1SPOEkBRyZnnIx8vBjfIFFpKa1bw/CsOHDF7Y6IAAsf9hir7dH3cUd4vaPSCVRM4KiTDw==} + '@rspack/binding-linux-x64-musl@2.2.3': + resolution: {integrity: sha512-9OKSx2ickrR+PFal94XE6Hkj49AQ2HI3KuR9XeRkKOwSxjwrskV/Pn1wc2itDOHkm/QHP7k+MttsYb4FGuNIqw==} cpu: [x64] os: [linux] libc: [musl] - '@rspack/binding-wasm32-wasi@2.2.0': - resolution: {integrity: sha512-rerLPTN/HD4EvLNWs3O2N+Eb37eGvLRIP3dXXc3n+UzTebOepAsahNn44vXeRBsE4m/pHkpDJjwgWTytgQ2gBw==} + '@rspack/binding-wasm32-wasi@2.2.3': + resolution: {integrity: sha512-gici3jWJi0GDy9kbYh57LoA57H8A2EAD9cV2jFKp1YoJ2aX2U9lANh4L+xoKPTvVQYpRvleoA5jkAvyfeLQ5bw==} cpu: [wasm32] - '@rspack/binding-wasm32-wasi@2.2.0-rc.0': - resolution: {integrity: sha512-2Vvtckz5DNghQKQKwghfM4j30MOu7t9J7mbUCCi7mTUccpy7Cnr0HPmSV9Jx4sUj6vgQEV+8lIAIxNDNtr0Vhg==} - cpu: [wasm32] - - '@rspack/binding-win32-arm64-msvc@2.2.0': - resolution: {integrity: sha512-JUAmnbOQYGTRyX28vls/MOMonZWcmcCi5YtEq6YMc8Xqh3Qx0HUwaLM/I1xr/N9BX3b8CV0dQDOpNuBc2ei+CA==} - cpu: [arm64] - os: [win32] - - '@rspack/binding-win32-arm64-msvc@2.2.0-rc.0': - resolution: {integrity: sha512-enPqTT2sdt6HgItyk6SA6ubvZ2UXkFIpwhsCuoL83z9vHoDw/Y8obC8L92nIuK6FDP17tokm/r1SFPhDZJIYcg==} + '@rspack/binding-win32-arm64-msvc@2.2.3': + resolution: {integrity: sha512-1MG66kcuqAGFs9ewiBc+/NJSTQr1jHrkZ2nCVMyhukl1o8embmFZa3BCibYgBYs5P7FRc6Sa+CDXcrfDZOLInQ==} cpu: [arm64] os: [win32] - '@rspack/binding-win32-ia32-msvc@2.2.0': - resolution: {integrity: sha512-wOmQRUaOG0eWH/fnfslA9yK9xKfaq9X+3Xa1TdTJnTqlo0ARJYs6A+Lzjbs7cxdY/o1f12Xe00BG3nQozReUOg==} - cpu: [ia32] - os: [win32] - - '@rspack/binding-win32-ia32-msvc@2.2.0-rc.0': - resolution: {integrity: sha512-qYeZLJDfboqkWmNcqazjUcld2QegyErDJVaCfAPm2mnneMmKhQWsOs/DmlqRfC4ePaQN9uOtu26iLwKy2o1sgw==} + '@rspack/binding-win32-ia32-msvc@2.2.3': + resolution: {integrity: sha512-CAigxxh9DYJmEwH9f6FRkSsmt0GhKVqKaFRVFFrF9WDL7gXP3hyE/h/cCeMdmlTzRt8Mht87AvnDX7PG3neMrA==} cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@2.2.0': - resolution: {integrity: sha512-v6/3bFr9+i7hRpgulL9b5qCvZL0VgR4vQGQNqOWezUzZmPUj9LYpvB0L9xZIVwDQ2ug/xBiA58bfg5IbESgoyw==} + '@rspack/binding-win32-x64-msvc@2.2.3': + resolution: {integrity: sha512-qa5Wc34a+Uux5foZ1Z+9fPQceVRbgTunaIulHezVF/ZxvqKO4DkAC7tFl53shB0YRVBAPg8R78MGd8cByfoxLQ==} cpu: [x64] os: [win32] - '@rspack/binding-win32-x64-msvc@2.2.0-rc.0': - resolution: {integrity: sha512-bvzp27ZvpZW1vcCG/srk/K/6cffcR/kqDUcW8dWEMFlntPSBTml9lOC4ouSBpU7n/qIwG6hKE56FaTWWapc3Lw==} - cpu: [x64] - os: [win32] - - '@rspack/binding@2.2.0': - resolution: {integrity: sha512-nxZzJqqB0EmEKp6qjzFNkBb/SgGt0k0DSENrLvAJgvVvrm3waVsubD0cfxtPlZY/rd5SzadzxWGEHRyFcds5nA==} - - '@rspack/binding@2.2.0-rc.0': - resolution: {integrity: sha512-/Q9ysTd5ajEbIS+Sv61trElR7pWAa5LvcWNrYT+AKI9APsVwy4Y3CIPjEkd7jYeNHl1PJWSLCOUy+BzRUICDUg==} - - '@rspack/core@2.2.0': - resolution: {integrity: sha512-3W7oX0BAHbK4VlknH3lfyfRvupzxdZtyEa+DfKmdjzmIAcqYtHnFd0nLqp5dzitDPyDI1TIKkDhpB0AZJn0pVg==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 - '@swc/helpers': ^0.5.23 - peerDependenciesMeta: - '@module-federation/runtime-tools': - optional: true - '@swc/helpers': - optional: true + '@rspack/binding@2.2.3': + resolution: {integrity: sha512-532+T5N6yIdukChiL85H4NFN2vn9oi8wlLa1ByPNtpNYriE9s24fUhn0RiK7w/xACqnNpYmXCqLYCvjOzyCy+w==} - '@rspack/core@2.2.0-rc.0': - resolution: {integrity: sha512-2LAdAFF/ZdnBRltI+IievGhysby9LESxvELxS1ZVkPc1F6ZAJ1skIcCNOLmfZeoYwQ5nXZjdUCbCf9MFDMWJjg==} + '@rspack/core@2.2.3': + resolution: {integrity: sha512-VHejw+PDd5B5v6VduFyjLSTIFaHBMNGPMKw+Y0zjkMqjYhDD1hjLOBC5frRKjTWcfYdk+Q73auWPp9k/H9PfIQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 @@ -3563,8 +3392,8 @@ packages: '@swc/helpers': optional: true - '@rspack/lite-tapable@1.1.2': - resolution: {integrity: sha512-1OnyWChLGE46YzWyjlmYJssOu/Y0STAnnr2ueKPqDCYTf63GJMs0mxNnCul4dNiVqHYPKv3/fxrTY3IpqoVwZQ==} + '@rspack/lite-tapable@1.1.5': + resolution: {integrity: sha512-uzB782zJbFTM3ta+e2Glikx36dca/6Y+DXyvFN+wb0Tx5ItIW+g03A0t3amP3LGzPHSkb0k81VHCm4jxLQwfag==} '@rspack/plugin-react-refresh@2.0.2': resolution: {integrity: sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==} @@ -3575,65 +3404,8 @@ packages: '@rspack/core': optional: true - '@rspack/resolver-binding-darwin-arm64@0.2.8': - resolution: {integrity: sha512-nTnK17kmxXEvR+WpOIZPSIzUFYeWCHoffgU9tvOLOwuTBH41kWnSQXXWu+AiMVwvJ6wdRO6Vo30hPhlXEG7Pyw==} - cpu: [arm64] - os: [darwin] - - '@rspack/resolver-binding-darwin-x64@0.2.8': - resolution: {integrity: sha512-Aqr4TK2rA6XVYUOmM5YCtYyCMZhOIR53P4cOGgGARg99A7OuMBMzUL4r1n0M0Fx35v6/sSx1OBe+odHmPxksEg==} - cpu: [x64] - os: [darwin] - - '@rspack/resolver-binding-linux-arm64-gnu@0.2.8': - resolution: {integrity: sha512-wGvkxm2G4mNTztslaOzLzx5JuySQSy5DcOWEZxHcjJJzp5L3ODbYLK18HtUc6cvmaVOmjaGrrYPrqJJ0hHTVFg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rspack/resolver-binding-linux-arm64-musl@0.2.8': - resolution: {integrity: sha512-EqRJ9zLQsLAvyDKJKVZ45BSqRIMS12f5HtJdy3KkAHU14ZmsGv8e5IKkwUZN5CNBRad8xVlOMMx3dOfF4whJzg==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rspack/resolver-binding-linux-x64-gnu@0.2.8': - resolution: {integrity: sha512-eXbeotNCTntL4/+mxJRVCxK63YeWzTfp0F3POeHJFSs6Nt0f2J/mZNFlasJmd6xm7zvE80h/HWOwbwjRBLcElA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rspack/resolver-binding-linux-x64-musl@0.2.8': - resolution: {integrity: sha512-KWFHlOWGkT+eMngoUgPGXrDi+rU04VCh9jyk0U6Ot2RTWvhGxwKykjmLS+CWZI/EBrzr9A6g2U3jzKTMNz9oCw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rspack/resolver-binding-wasm32-wasi@0.2.8': - resolution: {integrity: sha512-I6GIhgICFViE88jejIV74oiiWHnpLpQ5ogaZM1ozM9KDnfqcHoX0IVEyrIh5KqA8iLDyhuoFSW+Hf0qN7VTBBQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rspack/resolver-binding-win32-arm64-msvc@0.2.8': - resolution: {integrity: sha512-ZXCt3qUfDAEbtc2sHpvxM7lNFZM+DxfblgXUIl3Jy6BuEZbHe1i6z+t9c34ayHoGTVbVSNCtaYuG/MaWdSnPHw==} - cpu: [arm64] - os: [win32] - - '@rspack/resolver-binding-win32-ia32-msvc@0.2.8': - resolution: {integrity: sha512-2LRymjDK8MpUERD8CL0PPae5y2crU5TAg4T4EzpeL5jLARVq6izsEruiWzB6Y+D15vUYlvmgs2370GXVSB861w==} - cpu: [ia32] - os: [win32] - - '@rspack/resolver-binding-win32-x64-msvc@0.2.8': - resolution: {integrity: sha512-hzRpfbtvv4M4EVrKKIAaHDs5wT8lVcbSUjtwPs5u4IeLEix45nQPQ6ZQjmE4lIH0GP/3L3XQhZroYmTcH/xdsQ==} - cpu: [x64] - os: [win32] - - '@rspack/resolver@0.2.8': - resolution: {integrity: sha512-FBWqdHhzS8mcf/WN4Ktzr7EaeaN+hsxbN98EweegX3924beZuY6H70CSFWCv1fIHAieCUv/9XCjKggHvhCsLwA==} - - '@rstest/adapter-rsbuild@0.11.9': - resolution: {integrity: sha512-i/PSgFGyKkIn5Pk+UUQEH7KWd6hEVsHV5WW3+4Awe684VG+yBabKNWkqIg7EqSZ0jkTqtrcpdQUTX99U61DSrg==} + '@rstest/adapter-rsbuild@0.11.12': + resolution: {integrity: sha512-0XXUMCSxAYK7zuBltAABSIIAcsCrvCywyRLIkicvJwIlvRGAwhRO5DSIZLo77y5lEYQAZl6uAHwczyZC9lZ1DA==} peerDependencies: '@rsbuild/core': ^1.0.0 || ^2.0.0 '@rstest/core': ^0.11.0 @@ -3672,11 +3444,8 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@socket.io/component-emitter@3.1.2': - resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - - '@speed-highlight/core@1.2.17': - resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3755,86 +3524,86 @@ packages: peerDependencies: '@svgr/core': '*' - '@swc/core-darwin-arm64@1.15.43': - resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + '@swc/core-darwin-arm64@1.16.2': + resolution: {integrity: sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.43': - resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + '@swc/core-darwin-x64@1.16.2': + resolution: {integrity: sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.43': - resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + '@swc/core-linux-arm-gnueabihf@1.16.2': + resolution: {integrity: sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.43': - resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + '@swc/core-linux-arm64-gnu@1.16.2': + resolution: {integrity: sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.43': - resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + '@swc/core-linux-arm64-musl@1.16.2': + resolution: {integrity: sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.15.43': - resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + '@swc/core-linux-ppc64-gnu@1.16.2': + resolution: {integrity: sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.15.43': - resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + '@swc/core-linux-s390x-gnu@1.16.2': + resolution: {integrity: sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.15.43': - resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + '@swc/core-linux-x64-gnu@1.16.2': + resolution: {integrity: sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.43': - resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + '@swc/core-linux-x64-musl@1.16.2': + resolution: {integrity: sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.43': - resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + '@swc/core-win32-arm64-msvc@1.16.2': + resolution: {integrity: sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.43': - resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + '@swc/core-win32-ia32-msvc@1.16.2': + resolution: {integrity: sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.43': - resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + '@swc/core-win32-x64-msvc@1.16.2': + resolution: {integrity: sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.43': - resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + '@swc/core@1.16.2': + resolution: {integrity: sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -3854,11 +3623,11 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@swc/plugin-loadable-components@12.0.0': - resolution: {integrity: sha512-3vdbr0k5NQ0W4KgIx6LrFvjKwDyMPWbeHBncfFT7Fo2IKyo7513CXMCkx6KEUEsPzTJZRhHx0pFyt2hPWnfpEA==} + '@swc/plugin-loadable-components@13.0.0': + resolution: {integrity: sha512-xgixniTjdblzaWIDm+CJnhGhjkvgiCwc1n/f8V0gNVmh5n7Wn5+RbqBvLG1+y/tEzcKqeQ55MXNSH85XJklEaQ==} - '@swc/types@0.1.27': - resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -3960,12 +3729,12 @@ packages: webpack: optional: true - '@tanstack/history@1.162.1': - resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + '@tanstack/history@1.162.2': + resolution: {integrity: sha512-Lemp3DJbzNqcin/nZpWxycDaEqySDbnIshDbyHJMMCapD4ZQMe57szRpBXOfzfP6fyWAtHNrLrcBUyANJ6Vlow==} engines: {node: '>=20.19'} - '@tanstack/react-router@1.170.25': - resolution: {integrity: sha512-XiWYvkLAGhcZHhV2xUvicpF/VfTVSnewP6CqviDONAjmD50tBob5x/93u2W8QZAc/JgkPd+jijCmu6t4NCzmew==} + '@tanstack/react-router@1.170.33': + resolution: {integrity: sha512-iNnI98vH3kO/V4dy6YM0CInhqwWBddU0G5wZK5jiMvr3HsK2avDQSRx3RY/y6v+6zQAqb2kD6hUPHIenrJBTSw==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -3977,8 +3746,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.171.21': - resolution: {integrity: sha512-t6xUHBO94nQDrPHPh6ag5UuKHWIkVjq9s63q2ctbxgzq3vtSoGKmAIdLD5o+NU6JUS1ppXRHgL0YGzEHvH32Ng==} + '@tanstack/router-core@1.171.28': + resolution: {integrity: sha512-PvPWSklhw6i9b0rzScVh0btQsK5u/gBYN3mBHyDzhC/U4LrB3WzPXPkUunQUKvQOGXCp16UEb7Htc/ITGm5DkQ==} engines: {node: '>=20.19'} '@tanstack/store@0.9.3': @@ -4029,21 +3798,12 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cors@2.8.19': - resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4068,11 +3828,8 @@ packages: '@types/loadable__component@5.13.10': resolution: {integrity: sha512-2/LjmgG1JcGPj7T3NViq7BB5cvOA0s63gL3Gv+FPULj2L+3ExWfsNQcsFPUIOoGsVUJeZxgNPf320JZDyxjtCQ==} - '@types/node@20.19.43': - resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} - - '@types/node@26.4.1': - resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + '@types/node@26.5.0': + resolution: {integrity: sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==} '@types/parse-path@7.1.0': resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} @@ -4081,19 +3838,16 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 '@types/react-helmet@6.1.11': resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@types/tapable@2.3.0': - resolution: {integrity: sha512-oMnbAXeVo+KUnje3hzdORXUbfnzTfqD0H92mLl19NE5hFqH9Q4ktq+xehNSxcNeeLm1COopYwa0zeP6Iz+oIXg==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -4107,39 +3861,39 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.69.0': - resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.69.0 + '@typescript-eslint/parser': ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.69.0': - resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.69.0': - resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.69.0': - resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.69.0': - resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.69.0': - resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4149,21 +3903,25 @@ packages: resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.69.0': - resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.69.0': - resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.69.0': - resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': @@ -4333,8 +4091,8 @@ packages: cpu: [x64] os: [win32] - '@ungap/structured-clone@1.3.3': - resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} '@vercel/nft@0.29.2': resolution: {integrity: sha512-A/Si4mrTkQqJ6EXJKv5EYCDQ3NL6nJXxG8VGXePsaiQigsomHYQC9xSpX8qGk7AEZk4b1ssbYIqJ0ISQQ7bfcA==} @@ -4414,122 +4172,122 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@zag-js/accordion@1.42.0': - resolution: {integrity: sha512-eTVh6Uz3CqdcJDr5mHWTT+oICSrZFfYCCxIpOKF00nADmb96nCDqOAYFMf8gM3J/4UDS/V27QzEH5GPmFWiHIw==} + '@zag-js/accordion@1.43.3': + resolution: {integrity: sha512-J6rGbMPXhYDa2dLgp66DPpwC3OcNAJH71kuGakz7SfCvQWiR9lIyB26G/QdSjJjf0rVARM8rN90e5PxZjYdWjA==} - '@zag-js/anatomy@1.42.0': - resolution: {integrity: sha512-F6kRAPxgBZRZmuV5SLywcrGNSclKcAcPAAgSrEdr2Z2dzFgQngbvS4dmdQyGau/vtQ42OlCI48Ri4t0hZxXkMw==} + '@zag-js/anatomy@1.43.3': + resolution: {integrity: sha512-KzosWgu0TTtdBNHPUZg2W8E5JsGsExQ8oGkLDmV6Sym9twaATU2YVMCWsfitq3Isss4099wNMsHdjEPK2UyCjw==} - '@zag-js/aria-hidden@1.42.0': - resolution: {integrity: sha512-ao1RyEbHbglJtLLR4sJLPiD3hWF1CacuN5eqvODh/7HcntWULrkUHo8DIdzYgFL9GUHDyDmpaechMX5wL3kdPg==} + '@zag-js/aria-hidden@1.43.3': + resolution: {integrity: sha512-f8mqntgsDQFwPDlClF30oXJolY26sg1lnYe6avQB79kwgUQKvw10aYeiUou2INYN7mACNs4nNERfAf1/p54jHw==} - '@zag-js/carousel@1.42.0': - resolution: {integrity: sha512-8VMPlg5DP8vye4ujyEqzM/EQ0osOVtoG3bUJ6CGWdRIdSkbgjIt1tY8DlS1WzOvSUMnTcTAi/RPxoxYZwTIhsw==} + '@zag-js/carousel@1.43.3': + resolution: {integrity: sha512-hXnG0XL/mpBPCJ3a+H88CZMSCzVsKnOLY7i1Fza+hKxUiGovxojCAitoASeg6ETd7bOzAbz0pvfGgwJPCdnGTQ==} - '@zag-js/checkbox@1.42.0': - resolution: {integrity: sha512-Pn4IHtJFGLUorynWiOUN79qvYofbvIB8xM6ZwRR7gi8TjRdYPL8rVc5ASlNxus9slsvHGevRtKl5dLbT2DONNg==} + '@zag-js/checkbox@1.43.3': + resolution: {integrity: sha512-1vikdGcAokmLSMAnHQo5lYG/DMMNej2FlaAxiuyna2n0XJy/VFhv/3+/FZNB9CAJBkeqwLm9kqo94c7iEL7cxA==} - '@zag-js/collection@1.42.0': - resolution: {integrity: sha512-+Bhj/2zqVVzmS788lsg/xK1ezksMc2pfVg2oO6MnipLCo8KrgzuuvhUahUzK+uIOc9ljBW4zl9Wcm3G248gh0A==} + '@zag-js/collection@1.43.3': + resolution: {integrity: sha512-POufSu17mxTic09DP3CD2sGiRCaNODv/gAYyotrM/ynWSeb2+TjSxlKxuiyQS0amiY+BwJ3+m5qJ4odmU9TLMQ==} - '@zag-js/combobox@1.42.0': - resolution: {integrity: sha512-fe+Sco278h8PPFM0g3eR5366/oVIfSI3B7qDXl/xmYKMQXhIp+bKxew3U0q1COSVof0x3wrIoIpY51BXj1McGw==} + '@zag-js/combobox@1.43.3': + resolution: {integrity: sha512-qdm81w9PnR7/f/YNxie5iT50+7tXkKgiRJZEN59pYqwRA2AbwtE87jk7CQAuBVD1ko/wfcSBfIocoeltVKy2Kw==} - '@zag-js/core@1.42.0': - resolution: {integrity: sha512-jE/sbpZvbD+/etbawzhL/L2lP1pPefed5d919je+K5h9b6GAK3lUThleaDUCww3bT53B1L7O5gtqBapW37N1ug==} + '@zag-js/core@1.43.3': + resolution: {integrity: sha512-afREBcZWvPLHz/2AxJeuAP2o6+o3V7NZO/MutLhdclKaVSBlPzCr6qqP9k8hq1zOxk1jogKT5VVGielzWDY2Dw==} - '@zag-js/dialog@1.42.0': - resolution: {integrity: sha512-2KpLQASTjr2JUR0XvqsMueVo+AAPeanD9ShFCY/YKv2qp6WRHo1Ig/tRj6pInsSBiHFo7QXyAD4SDax6C0zDcw==} + '@zag-js/dialog@1.43.3': + resolution: {integrity: sha512-DGIUQ686slMHiphl5di/0hmsg+sWL5vlTxZtVZ+5zvNYTWgEVpz8bUXXnGrIsVBXkWgK8CVL34pdMCs3jAmQXA==} - '@zag-js/dismissable@1.42.0': - resolution: {integrity: sha512-Cx/FAQ2MuQy3kecdmswrFk94Zie1ieBa1Pnt/2ECI5FR914EJd3DwY9Krsmlrq4Z15Wm+pVtfLXgakDewVgsiQ==} + '@zag-js/dismissable@1.43.3': + resolution: {integrity: sha512-3znmDAC6qv7I9h/ZInVE+sFxE2wcBeQhT0bEiUjXAlgFGgaVmgA6A0ElGI7CDrot8qhgC88BZQisMWfYRRWgeg==} - '@zag-js/dom-query@1.42.0': - resolution: {integrity: sha512-JgCNfp7F+YNFNb7Xmt6rXQr3N3V9Mp+pFRvZ8j5BTC3/G8ZYj/enrgOcuVwPlXPBGtA7ysvjig2x1c0Uou/6kg==} + '@zag-js/dom-query@1.43.3': + resolution: {integrity: sha512-kpTTYMemMmHxhp4gyOllK7/Lf86AySTO+AlGds9iQM21eUwi1jvc9q3VTxP+qD/KwHePGftfPwkwxifC3iSGbw==} - '@zag-js/focus-trap@1.42.0': - resolution: {integrity: sha512-47O/OpLUm9o8CZd3ez0ntAOUKZEQ1zFj7e86fpYuQ+fNtBlpYMFHQxMRftPkiU5Ysjrz5x8RYV8A8uJ9cboNig==} + '@zag-js/focus-trap@1.43.3': + resolution: {integrity: sha512-/U4xWaHvHL83yDylTjLE1txCd0FR3As/1qgFWpzb1qOGWiCp/9NrLHVfegjDPgaoKLHRC4kEHzN+0NYNGHBXgQ==} - '@zag-js/focus-visible@1.42.0': - resolution: {integrity: sha512-zT1Fk+8m6x6hzoD5fOIEV4JQIX33pwkBzANqwruQwaSqkOYtI2FmsG71YHnzKvrBppG2okPrRHEMyZEgfshIcQ==} + '@zag-js/focus-visible@1.43.3': + resolution: {integrity: sha512-32l21nQUI328nfNTe2adXyXj+8t1ltDaEm7IQGmhtAw/DuIKvjpEkhJZHL1zUSlp47QGNfuYGPeD+ZXaZWFWMA==} - '@zag-js/i18n-utils@1.42.0': - resolution: {integrity: sha512-uVw+Ua3apxaBCRcsfHOlXcRPDNygSGoJ6ivmK0UJaw5QikZLEBYr7grzXDwRvZrdk0j0sA9Hk/gg3GdSXkhI3Q==} + '@zag-js/i18n-utils@1.43.3': + resolution: {integrity: sha512-bVCZxrd7xWTMhWks8zk/xRQNMPZz3CVoXjG9207b0GVTTQOwoiVg+MEIHDqirEaDJtzUKR6VbV0dRpC8LsceFg==} - '@zag-js/interact-outside@1.42.0': - resolution: {integrity: sha512-CNNT1OtASacXEst8NMWDGmDwkOQgVQ0Ahc2SpPjSBZoVpxQIfhZ9FoeqcggDX7Nvze7kl6/OwA7pPi5TGQTVZQ==} + '@zag-js/interact-outside@1.43.3': + resolution: {integrity: sha512-qgyAyWELSzFrHUtB6D7IzemI48NjX7E5oIDCXsz/DBPh+ybsK+pKMGsZkduTCK+uEqJcA/w+DAWaDUOxhjqvWA==} - '@zag-js/live-region@1.42.0': - resolution: {integrity: sha512-YFkytNxBJDQIndh7W+bWMN9P74qsnQkzqqLv6LtX/TiOYr1PnL0p54DMvWhE7hqYiCyyLscBM9nbA1foSoPIPg==} + '@zag-js/live-region@1.43.3': + resolution: {integrity: sha512-lB2ryGpRjTgbfJ5D86MQnvZRCJupKbpwS53jfCkEP0EBC9Tk7Crq9p0pobzXgH5U9fnjn4BPsqBSyuSOxPOJnw==} - '@zag-js/menu@1.42.0': - resolution: {integrity: sha512-Jg2q+FrHeZ2ZTxrz/TAnNelapN6IGtyCnBmoNbcHGcnf3gqnEjJpug3Fn6BmPQrmTNw0+q7hBmrESyncjrfbUg==} + '@zag-js/menu@1.43.3': + resolution: {integrity: sha512-RGCqRv/GeEehBYOB3JV0NUfyfaWW5KBN79RHUUYpxF9DMsPM8igHOzuoOoz+FaYoepjC34oMtmWicF1Dan6apA==} - '@zag-js/number-input@1.42.0': - resolution: {integrity: sha512-dOdABVSftfHdrUr24WyICVGRQLJrD9fIuIO9FM/Zgzc75IgYTegAyNOZHVTUeJeRw+etPb+VEq6halVDP7qrgQ==} + '@zag-js/number-input@1.43.3': + resolution: {integrity: sha512-XBuky+uuFXnshBHbIFfpUCly5nKZi+hW0R6pvgmqirqVyc4K/lwg4nz88/Z6U/q4uRQarKRr22fOMLthc7q7Yw==} - '@zag-js/pagination@1.42.0': - resolution: {integrity: sha512-QEcnc8z0U4RREOa1MVGFNbKJONLzbo5egdUppURkbAwhv+paR9ufG4uli7QlMvA3KgdfCHPM92+MhnYwutp5nw==} + '@zag-js/pagination@1.43.3': + resolution: {integrity: sha512-8shW+/WwkLNKh9nhVXQCi2mQ50un52obzgheIm/BwlUlHu+qY/dM2al3Kx9L4yK7YYTDaXFxKyvP1iaYUQd7ZA==} - '@zag-js/popover@1.42.0': - resolution: {integrity: sha512-4knrgFDeYlKqjyRfYPEUfpIRjsTvg4zY/C0i3sbdxNEWiOW30DzW+/8Igabb00bOabrtRuIHBejm0Jorgn5JIQ==} + '@zag-js/popover@1.43.3': + resolution: {integrity: sha512-kLQIj+9XaaEvSbviG+r8hI1OJxC4P4JTETNcnHV4w4aE5nR2r/LDP3JoJSEQsAvTR/MMYpZQDL5pdVQj0M/Oqg==} - '@zag-js/popper@1.42.0': - resolution: {integrity: sha512-hQvXzLen/hXCItww5tJT6+VLxnee8CpsX0FabIiHub/fKIB6OeHmS/lk+RnIII57bljdjLSus/KnT8Xlmj6tRw==} + '@zag-js/popper@1.43.3': + resolution: {integrity: sha512-99PPQerLylh+ouG16d0Go0UgpLR3rCQvWMmJoO5Ti8+2Ww3aqBNLTqpF0rJuLPe1Ps4BfEt5Lr4dYhL1ixgbRQ==} - '@zag-js/radio-group@1.42.0': - resolution: {integrity: sha512-VtoN9/d3HCe/oumjdwKzv/KilF2aLzbt1IYpejXF7Cwat8xmqnCoxiA2PLjE9NfD97ZM7grng1YGcjqrWOWvuQ==} + '@zag-js/radio-group@1.43.3': + resolution: {integrity: sha512-1QM1rNnK6mQ36aYEshFVlboBDMQTth2wxHuh5V+ALIXf4Dl1Eexy0dyLVFkYV7R3E90e6b8d+xZZ3tziTvnRDg==} - '@zag-js/rating-group@1.42.0': - resolution: {integrity: sha512-0+OQ+iy0djWJBzWdH8pMn+ze88B0T4TdO3kJuHyG2fgJnVgmuGWkoKp8RtXSAv2CVqXfx+diivqF9ZmhNmzLQA==} + '@zag-js/rating-group@1.43.3': + resolution: {integrity: sha512-BiKcyo3z7q4UBMsGuOs8w6u827pQ50eOuRCh1oqVpKqBoyRL0z9aFQYg9YeYnxXn8eyoEBMiVv01MTogJ2JBQA==} - '@zag-js/react@1.42.0': - resolution: {integrity: sha512-5rVcXTpOK0i4enXwFtz+Tlwvo+uVxemJBe4loYu14D/e9QC8yJd6XY5V/AEudI4em5505/0K7h+3hGF26c6Mog==} + '@zag-js/react@1.43.3': + resolution: {integrity: sha512-PVfq738OhicFsGOZNqrsBmdOHuh54XnIhkyJ9HSWDpzQcGHq4fuFWIvfmqW/5o/Xkj+JwFQLoIDVX/KHaUjcXA==} peerDependencies: react: '>=18.0.0' react-dom: '>=18.0.0' - '@zag-js/rect-utils@1.42.0': - resolution: {integrity: sha512-4hIvDZEPxYAqhiSNVkZtQLzd7dV7IAn7c5GdWnVVAYtJDD1Tn+v0TYkoPuGQMqLsdpNDwncw3baT3xq1+PQi+w==} + '@zag-js/rect-utils@1.43.3': + resolution: {integrity: sha512-xZA5IKUtQeRKdzM+9jGj8R18MXmORDO0smXAAoyFj1Swj/uB17eVwvsWoQLXbb/AaYXEzSIJXNUt21n3GAX24Q==} - '@zag-js/remove-scroll@1.42.0': - resolution: {integrity: sha512-RP1SzQuVDk+np5knkEz/mRasLHZUbO6f3xgq4M2u4SQhpOe34uJybd+14f4ElBntAJQWVeMhohpW4n0pNUW6WQ==} + '@zag-js/remove-scroll@1.43.3': + resolution: {integrity: sha512-1bX6IQM7q26uL0476wFqtaebeIeDheyJd8fuje2zXTCg1XDRHJmRsM/tzcmcdi4W5s+hG0cUCfuVzUTXvODM5Q==} - '@zag-js/scroll-snap@1.42.0': - resolution: {integrity: sha512-gFc5LZvHa4lyCmlt5n6nODc77dGeOc+3sWQMK+20cm/VjYNCsUDjoHP2KO5tgqLJNjPrON+i0BN/QfJUfoNA7Q==} + '@zag-js/scroll-snap@1.43.3': + resolution: {integrity: sha512-izT8eXvwqZMEKNM3lLcAWz9MpUwvZ0UCjBnfQX2JLHLcjYxLOytMd6rfGM2hE1erD0CBYlBUi5bIqstwQFLmCA==} - '@zag-js/select@1.42.0': - resolution: {integrity: sha512-SkfKM7BvAEXLpJda4bS6O2n7YOW9SNeLYvV8vxZjoPFGZDVV6uUcxZE6UPKWQqmTYbu3udCcTlQg749+4NB2mQ==} + '@zag-js/select@1.43.3': + resolution: {integrity: sha512-vd5uKPOoRXXrHlL8VPNk5Mih6XRhIzXAon7emEupCe6UAAShqYnxumrLBbr5LifiePP93CGJx3rGn8RZky/OVg==} - '@zag-js/slider@1.42.0': - resolution: {integrity: sha512-bHTNGxM2H2oag5IbsfsV/OJegz4ic1Zs89Mvm+ryVzqOc0upJl0xMC/D/K8wnRJ8OPubNnldA+R4W8o+/4IU8w==} + '@zag-js/slider@1.43.3': + resolution: {integrity: sha512-SwnwdabVPouHraF4fcICz9szGMARxUd8CPA0+x+w48D8hEn0GzFXDRI0ntXByEstwbpyzvy4ptRvh0WRvwCJbw==} - '@zag-js/steps@1.42.0': - resolution: {integrity: sha512-IDJKk4zPhQjJv5fxAvOy+LRcdnim5rJjpUdEsWq0/aDiVZiddg3sXLgJ8dTGfv6e58AIjTlaz85xv21dldWKYw==} + '@zag-js/steps@1.43.3': + resolution: {integrity: sha512-Au4LFM0jqn/v66HSU1ZIpb8Dl9LK3yDQ1pPhE2k2tuJz0ksq9s61m47Vf1FjQhlfBFgXkh20xASAgsVVbDXZdg==} - '@zag-js/store@1.42.0': - resolution: {integrity: sha512-qQ5LB+l2dR1rZFCsErn9PeSrOADjnsvDl1NreEN/AaKXw9RK0YiQDqaCh7ndqjjA72UAcrnzgXsS0AAqxCC+MQ==} + '@zag-js/store@1.43.3': + resolution: {integrity: sha512-NL3Uxk5Rszjd+X+cPXw0N7Kn5TNMyrS6cTMazlBOcN2+J68mzXhNFvM/0H38m0X+3ybK3sE5Ta6NPlhOQADRqw==} - '@zag-js/switch@1.42.0': - resolution: {integrity: sha512-227GNBAz8mYSKVsHV6r4YA/1/0rtbrP2foygPligDOkWMsNP9cjnW1HwM/z3x54UPWLqKuZjsjD7LSuDhbxtBA==} + '@zag-js/switch@1.43.3': + resolution: {integrity: sha512-MZxRjKE9RAb0qq6bBGj9PKX4m+wWKrNJ7MxdZuopcyWxRe7c6B389bprbgAUmDmm1WB8EMQwluEqyvXqC2j9tA==} - '@zag-js/tabs@1.42.0': - resolution: {integrity: sha512-O0qFmeneJHr85AUL/44mHQy8Cr/jCZzh55rTKz5sBoZxmk7PIGopsasNLnpyaZ34tOpcKnNEg96FdlZ4O1bjlQ==} + '@zag-js/tabs@1.43.3': + resolution: {integrity: sha512-uLT5YiQAcDkSi9T1plYQyXAXaqGi1H5iw/PA0yXsWDOoNFU9jlPM5s8SuXbBJzXiufSFekdhzpNhMUhcapnBdA==} - '@zag-js/toast@1.42.0': - resolution: {integrity: sha512-8WLogVjJiCrvQ4hv9vQrcjaFJLFGtOCb/pnir7SjqR1RzbYHoPLj5ASbM9bbjpcgQQYr0GaLovP6beQW8kjBjg==} + '@zag-js/toast@1.43.3': + resolution: {integrity: sha512-XBDewuymsIzZy23xrcx1v6F/HeDlk3NAfZ2C02P+DHiNKdj+a53PpqUGZg2e2FX7ZGibag+aywWG4pnvKaSQWg==} - '@zag-js/tooltip@1.42.0': - resolution: {integrity: sha512-viRGjX2yxF317M2G2tYvPS7+ZS3osmE+iGm40ZPg0sciezSZW1l4ZFSQ56qyCJ8rTUKv3T1m0NPWYhqVoT3eoA==} + '@zag-js/tooltip@1.43.3': + resolution: {integrity: sha512-sBVhcejAQN5oDFE3PoGrvvjqOt5+9rHQjtjnnuhPZqeghsiX/IaL7AY8KelwDoNkMf2npN5i7loQvAZMZLVFaw==} - '@zag-js/tree-view@1.42.0': - resolution: {integrity: sha512-QXIfFgE7BbtB7cHXwi8Gu/9ezkOaeZ4XFAixoJttiOgnw3EIRZPqATRijh446SDPuqsFh9RSjC2zEe3vcFssZA==} + '@zag-js/tree-view@1.43.3': + resolution: {integrity: sha512-5fj6RWe4FSRbE3QewuqbghS+ojd2vaWfTrn0jUmH3KHzay2h/w1464oW0IYOONzwOmUUHj4wLDZZKwVyzPvmJA==} - '@zag-js/types@1.42.0': - resolution: {integrity: sha512-4ghao1wuLouepdYMheEYtyOlKpVsvL0Eg9tf//5VyAf7S9zC7cgGxD6ttGTlIMxqKqnAuxIMsYHG76qkb8tv1w==} + '@zag-js/types@1.43.3': + resolution: {integrity: sha512-QokzUgkJ7a/TRE8SAXqlm1XfiNDbyM8y+mx0PpmzHa8bvmWXLEe/Zx2TRz8aYoGim75NluYzDy3x+WWileF7uA==} - '@zag-js/utils@1.42.0': - resolution: {integrity: sha512-Km0r9hY+f6/oCJXrO4nqCIuo+4gTqbloD0V0q7B8Jq8qeWte7HN+YJSagVlk8tfADqFMRgEW4Rug0bYHzrGbVA==} + '@zag-js/utils@1.43.3': + resolution: {integrity: sha512-9P9fvFFxuiDcLqX0BYgGNkf0/a/id32nHvQpwgv/Xv3b9n5yeyw2U8nKefpWr+1VfFsrpf2XMXiA2CDSWZgt2g==} '@zxing/text-encoding@0.9.0': resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} @@ -4542,38 +4300,24 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: acorn: ^8 - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.5.10: - resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==} - engines: {node: '>=6.0'} + adm-zip@0.6.0: + resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + engines: {node: '>=14.0'} agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} @@ -4610,8 +4354,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -4681,8 +4425,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.5.2: - resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} + autoprefixer@10.5.5: + resolution: {integrity: sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -4701,8 +4445,8 @@ packages: peerDependencies: axios: 0.x || 1.x - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -4718,12 +4462,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - base64id@2.0.0: - resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} - engines: {node: ^4.5.0 || >= 5.9} - - baseline-browser-mapping@2.11.19: - resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -4792,7 +4532,7 @@ packages: better-call@1.4.0: resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} peerDependencies: - zod: ^4.0.0 + zod: 4.5.4 peerDependenciesMeta: zod: optional: true @@ -4822,29 +4562,26 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist-load-config@1.0.3: - resolution: {integrity: sha512-boNaPS4KlW6AITZQ60G+1oDJLuxauljDd7QNQFOYpRtldzcTDknMZ8awbwI0BT/8h1/Y/CG4k/tDOLip9lAGcg==} - browserslist-to-es-version@1.4.2: resolution: {integrity: sha512-3NV13pCv0wmPxxZZcekHAG6vt8rQ94w2c4/UBe3ZU3NDUm5TP+QFK3rjS6XeKWSHWpnPYNfQzlhnljka0BrEOA==} hasBin: true - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -4962,8 +4699,8 @@ packages: cloneable-readable@3.0.0: resolution: {integrity: sha512-Lkfd9IRx1nfiBr7UHNxJSl/x7DOeUfYmxzCkxYJC2tyc/9vKgV75msgLGurGQsak/NvJDHMWcshzEXRlxfvhqg==} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} color-convert@2.0.1: @@ -5011,8 +4748,8 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} connect-history-api-fallback@2.0.0: resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} @@ -5022,8 +4759,8 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} convert-source-map@2.0.0: @@ -5032,10 +4769,6 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -5048,12 +4781,8 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} - core-js@3.49.0: - resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} @@ -5064,6 +4793,11 @@ packages: typescript: optional: true + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5102,6 +4836,9 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -5118,6 +4855,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -5129,11 +4870,11 @@ packages: peerDependencies: postcss: ^8.5.13 - cssnano-preset-default@8.0.2: - resolution: {integrity: sha512-+jQAqIKCqMmBjZs7741XkilU93ITZ/EW8gjAkMmujdCzfDkfjrDBv2VqkSu29Fzeig/0rZ3S9IAwfPLlmXEUfQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + cssnano-preset-default@9.0.4: + resolution: {integrity: sha512-eHy3rfG0/TRGGG6gbgwLLlSs3Xr0xx2ShVwN94le+pydxAC3dXgmJTu8uIqdvBbrmEirUGuK9H5rUnxJRDXdoA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 cssnano-utils@5.0.3: resolution: {integrity: sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==} @@ -5141,11 +4882,11 @@ packages: peerDependencies: postcss: ^8.5.13 - cssnano-utils@6.0.1: - resolution: {integrity: sha512-zk65GIxA8tCjqVk7nTm1mE+ZKxtnxAvU5JSUaBLXbAr3ZF7IOvz3fbPOnEDvZKhnS7GOIitXTS5BgehLzNoc8Q==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + cssnano-utils@7.0.2: + resolution: {integrity: sha512-X5B8Butd9TISH65XOoSYsq/wokHHjDK7eNujX3EcTf67T6uy2BySmT1SoaSXzlFdOsRf431FTENMsPrsc9JYHg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 cssnano@7.1.9: resolution: {integrity: sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==} @@ -5153,11 +4894,11 @@ packages: peerDependencies: postcss: ^8.5.13 - cssnano@8.0.2: - resolution: {integrity: sha512-K+a76gA1v0/CsYgcsE95HGGyIuPKxpQSetwSwz4nHEM8fFXqSkzq2JzEXFL8v5+CCjxzVVVhPcTK3Oo8SaF/xA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + cssnano@9.0.3: + resolution: {integrity: sha512-v4oRMYCXcJtI1MlGcwVgSP/TG/RrznC3iKQeNcFTQZZYWomp2j8CmgT9iiSO2v64K4LJSi6pWdyznmUUutLCKw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} @@ -5223,13 +4964,6 @@ packages: supports-color: optional: true - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -5238,8 +4972,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} defaults@1.0.4: @@ -5264,10 +4998,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -5358,7 +5088,7 @@ packages: arktype: '>=2.0.0' better-sqlite3: '>=9.3.0' bun-types: '*' - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 expo-sqlite: '>=14.0.0' minipg: '>=0.2.0' mssql: ^11.0.1 @@ -5369,7 +5099,7 @@ packages: sqlite3: '>=5' typebox: '>=1.2.0' valibot: '>=1.0.0-beta.7' - zod: ^3.25.0 || ^4.0.0 + zod: 4.5.4 peerDependenciesMeta: '@aws-sdk/client-rds-data': optional: true @@ -5480,13 +5210,13 @@ packages: version: 0.1.0 peerDependencies: '@rstest/core': ^0.11.11 - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 - effect@4.0.0-beta.107: - resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} - electron-to-chromium@1.5.414: - resolution: {integrity: sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==} + electron-to-chromium@1.5.423: + resolution: {integrity: sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5505,39 +5235,22 @@ packages: encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - engine.io-parser@5.2.3: - resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} - engines: {node: '>=10.0.0'} - - engine.io@6.6.9: - resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} - engines: {node: '>=10.2.0'} - - enhanced-resolve@5.24.3: - resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} - envinfo@7.21.0: - resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} - engines: {node: '>=4'} - hasBin: true - environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -5568,8 +5281,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -5587,9 +5300,6 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} - es-toolkit@1.49.0: - resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -5600,6 +5310,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5608,10 +5323,6 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -5718,14 +5429,6 @@ packages: resolution: {integrity: sha512-pWReu3fkohwyvztx/oQWWgld2iad25TfUdi6wvhhaDPIQjHU/pyvlKgXFw1kX31SQK2Nq9MH+vRDWB0ZLy8fYw==} engines: {node: '>=4.0.0'} - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -5742,33 +5445,14 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.5: - resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -5788,12 +5472,12 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} - eventsource@4.1.0: - resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + eventsource@4.1.1: + resolution: {integrity: sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==} engines: {node: '>=20.0.0'} execa@10.0.1: @@ -5804,8 +5488,8 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} fallow-type-aware@3.22.0: resolution: {integrity: sha512-xw18u/0XJGz1DYK2atVjw8f8+TmMM0QgKEsa4Not/y2QioBcPYBScHZs4nr4wZ8V2kMCxWpeREDus6JbS4Ax2A==} @@ -5834,23 +5518,20 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -5875,17 +5556,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filesize@11.0.22: - resolution: {integrity: sha512-RlCVs9CY+oSsRnNZn95J9vDXjNjOwddKyTFjOYtA4yxYVIxBnwiVVGJX+TFhsmu3uUf81JDGyijtYL9xgawlTw==} - engines: {node: '>= 10.8.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -5897,17 +5570,9 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - find-workspaces@0.3.1: resolution: {integrity: sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w==} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} @@ -5948,8 +5613,8 @@ packages: resolution: {integrity: sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag==} engines: {node: '>=12'} - fs-extra@11.3.6: - resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} fs.realpath@1.0.0: @@ -5998,10 +5663,6 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-port@5.1.1: - resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} - engines: {node: '>=8'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -6031,10 +5692,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - glob-to-regex.js@1.2.0: resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} engines: {node: '>=10.0'} @@ -6046,10 +5703,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - glob@7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -6110,8 +5763,8 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.31: - resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} html-minifier-terser@7.2.0: @@ -6119,9 +5772,6 @@ packages: engines: {node: ^14.13.1 || >=16.0.0} hasBin: true - htmlparser2@10.0.0: - resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} - htmlparser2@12.0.0: resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} engines: {node: '>=20.19.0'} @@ -6153,21 +5803,21 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-chained-backend@5.0.5: - resolution: {integrity: sha512-ThdtKUcNdk/zrSuzQyz2t4uH1McomKXP/t7GOf9Cm1oabDlJ6ZbQQJkLB9VlUErL9+45baFHv/ipzgi9MOotUg==} + i18next-chained-backend@5.0.6: + resolution: {integrity: sha512-oYo86EnjkKs1+osKHCFyIi3Yw7UebEHxMgVOJ1OpgmpL4y9s46Zr9pJHpqWwlxK8Vt//cER9Sx2Tfmx3awrJQA==} i18next-fs-backend@2.6.7: resolution: {integrity: sha512-nN2TIycyR/d+OVuLm1ugPyOlMprqPRnQ7QktBgn44F3H8l7TeTH4ffHJ7nUsd4QVJfetWW7/VZ3s+4g7FoAZUA==} - i18next-http-backend@4.0.0: - resolution: {integrity: sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==} + i18next-http-backend@4.0.2: + resolution: {integrity: sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==} engines: {node: '>=18'} i18next-http-middleware@3.9.8: resolution: {integrity: sha512-HMKPc/P/v3qI+JX1k8RLsc4IKT3nKwKFt4cPDZB+iSLlEkUblpjiJ2z3YZYgHgY2Xs6cnnqFkJIg4AU3JSTNaA==} - i18next@26.3.6: - resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: @@ -6203,10 +5853,6 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -6221,10 +5867,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} - is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -6404,12 +6046,8 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbot@5.2.0: - resolution: {integrity: sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw==} - engines: {node: '>=18'} - - isbot@5.2.1: - resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} isexe@2.0.0: @@ -6423,20 +6061,20 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-regex-util@30.4.0: - resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + jest-regex-util@30.5.0: + resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@30.4.1: - resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + jest-util@30.5.1: + resolution: {integrity: sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - jest-worker@30.4.1: - resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + jest-worker@30.5.1: + resolution: {integrity: sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jiti@2.4.2: @@ -6520,9 +6158,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -6532,12 +6167,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json-stream-stringify@3.0.1: - resolution: {integrity: sha512-vuxs3G1ocFDiAQ/SX0okcZbtqXwgj1g71qE9+vrjJ2EkjKQlEFDAcUNRxRU8O+GekV4v5cM2qXP0Wyt/EMDBiQ==} - json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -6561,9 +6190,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -6576,11 +6202,8 @@ packages: koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} - kubernetes-types@1.30.0: - resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} - - kysely@0.29.4: - resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + kysely@0.29.5: + resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} engines: {node: '>=22.0.0'} language-subtag-registry@0.3.23: @@ -6590,61 +6213,58 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} - launch-editor@2.14.1: - resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} - - lefthook-darwin-arm64@2.1.10: - resolution: {integrity: sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg==} + lefthook-darwin-arm64@2.1.12: + resolution: {integrity: sha512-GSUjqaCuxAYlPOovbjXEWE3HAIa/xOQkTTmZ937zieQldjPLTaC7+s5FHoxKywn+D9riBlofkDqG7Z4f5zzmPA==} cpu: [arm64] os: [darwin] - lefthook-darwin-x64@2.1.10: - resolution: {integrity: sha512-KQ/bHmvpkFdHMn4pZnUdTf+GuSC+aBBgBTxZT4GW+6cSf+qbErKZBhK7cH6BmILsvx43+VzEArvHYY7YOfRFOQ==} + lefthook-darwin-x64@2.1.12: + resolution: {integrity: sha512-qAUHSSw/7Afi5wxkoLkxORxSicCeTBXyVLnRgD6YZra6udviozpK3Aok9Z6FIWeKc5FBijRMSNDxGPTREhsjcQ==} cpu: [x64] os: [darwin] - lefthook-freebsd-arm64@2.1.10: - resolution: {integrity: sha512-8su6DwydP7+pv7kG0zCtjphqsw4ouOnfexRUErapy5GTxYBoUOhYz3RSHTSWNRsK6W4jva7FPUh2Lp5/PSn30w==} + lefthook-freebsd-arm64@2.1.12: + resolution: {integrity: sha512-FHZRfKliFNbyz54zsoGdVe9muq67cNjBTZ6jrPPUjI7xUuyCwSpzA+1OpiwJT00L9pP5ZGONBqnGZKnrpC+7Uw==} cpu: [arm64] os: [freebsd] - lefthook-freebsd-x64@2.1.10: - resolution: {integrity: sha512-GeAJEFxko3Lk+AsnS3NleAFrpyMLFUKOlgJvPKuU0xHwVEI/z+ZoCcmuO0BX+4CS0NLbZhC/YQAvBASqDvvVdQ==} + lefthook-freebsd-x64@2.1.12: + resolution: {integrity: sha512-HYQZPGy2DDEx7dbuotusJpujY5q9igOLgx36qeeQcNQuvvdGHPj2ZGSIsGKhoJ0dw5Qa4knKJoPAHEZQWdV/og==} cpu: [x64] os: [freebsd] - lefthook-linux-arm64@2.1.10: - resolution: {integrity: sha512-1sHTCmpTWjVMs+yKPBLRNT1kuuIr1yjietlk7rCB6wFPVOS6Ph3o2zPFH2AvW1UymHlqwyHXzBr9EtDpQ7j1mQ==} + lefthook-linux-arm64@2.1.12: + resolution: {integrity: sha512-ipjOri2PB/sk4noPrHQuM5fcpNBjmlKo4qMtXyLnxeOxGYHWZzf0Xi0OtrXHQ//tOprcv40ADvhUuSdcGqigLw==} cpu: [arm64] os: [linux] - lefthook-linux-x64@2.1.10: - resolution: {integrity: sha512-z/VlRB3bh6mBvW3r1rwnJ5vP8z+Krx5gJzkZ4veDXh+6FlRTx8wtd3g3fllOv/yZMxkgmL3fQoFXv05Esa7vBQ==} + lefthook-linux-x64@2.1.12: + resolution: {integrity: sha512-DmU6xfvqVoFdW+STyc70YI4Ry8vkHGKHx+IJ1Js/Gacq5dv+fiwNzwle3bi28EkL6P8xY67B/CfQfRj4SRU4tg==} cpu: [x64] os: [linux] - lefthook-openbsd-arm64@2.1.10: - resolution: {integrity: sha512-430zL8sSIKw5P0YXGG6PB+eAhHa06n0PXuaERaAQE4Ss3odfqwnl5Mq9hQmkEnOS1EGiQEKkd0UHv/i4PtMNIQ==} + lefthook-openbsd-arm64@2.1.12: + resolution: {integrity: sha512-vb0J7bElLvoaCWQmna3HntsvoNqMsGAhfjjC99ss1OdCteufZKM3RxCVoMBEbD50Itv2c1Ua2AFVToltVFTy1Q==} cpu: [arm64] os: [openbsd] - lefthook-openbsd-x64@2.1.10: - resolution: {integrity: sha512-bgkO8PphGZVDhQgCJ524aYYPI5491pVmCiLPGjBIo1AvOSlIyw4N1Y+1C3QfqwEmechzw+Aq16SNc8pqv6UuXg==} + lefthook-openbsd-x64@2.1.12: + resolution: {integrity: sha512-QOmhDWqPPK4+99scanfEmbJjUR+JYC93qhr/0bp5Dj3LaR3kVFNWc6zOQUsOTPy/LC6PG759Lej/mr/BtjUL2Q==} cpu: [x64] os: [openbsd] - lefthook-windows-arm64@2.1.10: - resolution: {integrity: sha512-5Q6etF0Fla2DDA4ilDySrdNgiR5+W7cJZwnZ69Je3kvWCaWm4wnkuc8FEdjp3kiL2x3ZXipdI00f5vpO8aWmog==} + lefthook-windows-arm64@2.1.12: + resolution: {integrity: sha512-eErEyr9AHkRFj6TxpCQeKGd0s6IrtL/VEIZ/ssg8dNfG+ycR4jAW/IAlrJSw6/ZQXb3WtWLaQ6QA5c+TLh7vmg==} cpu: [arm64] os: [win32] - lefthook-windows-x64@2.1.10: - resolution: {integrity: sha512-c/XH8YZtylG4XaxzqFfXluvq2LXq2W/p54Bnzn3+Z7E5X2Fk3JlFJAibulMbIt2+w8T7UI/r97ok5GqE4kGaeA==} + lefthook-windows-x64@2.1.12: + resolution: {integrity: sha512-0h+WmDVdDriTjloD/UbjPq/ZtqaaJlPAdGcVwMCEdAxfbF0FTgDbKX3igui9g2U/qG2y6QpVhtz1jeiRe7ctaw==} cpu: [x64] os: [win32] - lefthook@2.1.10: - resolution: {integrity: sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w==} + lefthook@2.1.12: + resolution: {integrity: sha512-2uOexfsrrRhCiTUWdGyDySxVHHhOFJ8MQejs27dM3hugrQB48/z1ZVlaNoxpZ1SnzQ1GaDIbO5rAvicwyiknYQ==} hasBin: true less-loader@12.3.3: @@ -6660,17 +6280,13 @@ packages: webpack: optional: true - less@4.7.0: - resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==} + less@4.9.1: + resolution: {integrity: sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg==} engines: {node: '>=18'} hasBin: true - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - libphonenumber-js@1.13.9: - resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==} + libphonenumber-js@1.13.12: + resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} @@ -6827,14 +6443,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lines-and-columns@2.0.4: - resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - loader-runner@4.3.2: - resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} - engines: {node: '>=6.11.5'} - loader-utils@2.0.4: resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} engines: {node: '>=8.9.0'} @@ -6847,10 +6455,6 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -6949,14 +6553,12 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} - memfs@4.64.0: - resolution: {integrity: sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==} - peerDependencies: - tslib: '2' + memfs@4.71.0: + resolution: {integrity: sha512-Zwrk7TpTXBkic7taZL+2QeppbauG0QOufRsT1zuXDYLW8jCajH0W1VSZ17+I7ODqiNbH9J1Vf1QJCqFlCfurDg==} merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7003,13 +6605,13 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - miniflare@4.20260708.1: - resolution: {integrity: sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==} + miniflare@4.20260730.0: + resolution: {integrity: sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==} engines: {node: '>=22.0.0'} hasBin: true - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -7022,11 +6624,12 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimizer-webpack-plugin@5.6.1: - resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==} + minimizer-webpack-plugin@5.10.0: + resolution: {integrity: sha512-2//60T4S0X1Ug/dqLDOgDaGlZsN1Lv8hf8oB0NOV/KIHSaXlPwXBBIbim79dE2Z7NEs52ES8YHoSv6Lmsk+XNg==} engines: {node: '>= 10.13.0'} peerDependencies: '@minify-html/node': '*' + '@napi-rs/image': '*' '@swc/core': '*' '@swc/css': '*' '@swc/html': '*' @@ -7035,13 +6638,18 @@ packages: csso: '*' esbuild: '*' html-minifier-terser: '*' + imagemin: '*' lightningcss: '*' postcss: '*' + sharp: '*' + svgo: '*' uglify-js: '*' webpack: ^5.1.0 peerDependenciesMeta: '@minify-html/node': optional: true + '@napi-rs/image': + optional: true '@swc/core': optional: true '@swc/css': @@ -7058,10 +6666,16 @@ packages: optional: true html-minifier-terser: optional: true + imagemin: + optional: true lightningcss: optional: true postcss: optional: true + sharp: + optional: true + svgo: + optional: true uglify-js: optional: true @@ -7079,8 +6693,8 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - modern-tar@0.7.7: - resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + modern-tar@0.8.5: + resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==} engines: {node: '>=18.0.0'} mrmime@1.0.1: @@ -7097,16 +6711,16 @@ packages: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanostores@1.4.2: - resolution: {integrity: sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==} + nanostores@1.5.3: + resolution: {integrity: sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==} engines: {node: ^20.0.0 || >=22.0.0} natural-compare@1.4.0: @@ -7129,10 +6743,6 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -7167,8 +6777,8 @@ packages: resolution: {integrity: sha512-8sPAz/7tw1mCCc8xBG4f0wi+flHkSSgQeX998iQ75Pu27evA6UUWCjSE7xnrYTg2q33oU5leJ061EKPDv6BocQ==} engines: {node: '>=10.12.0'} - node-releases@2.0.53: - resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} nopt@8.1.0: @@ -7191,11 +6801,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nypm@0.6.8: - resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} - engines: {node: '>=18'} - hasBin: true - nypm@0.6.9: resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} engines: {node: '>=18'} @@ -7255,10 +6860,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -7278,21 +6879,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxfmt@0.63.0: - resolution: {integrity: sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - svelte: ^5.0.0 - vite-plus: '*' - peerDependenciesMeta: - svelte: - optional: true - vite-plus: - optional: true - - oxfmt@0.64.0: - resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} + oxfmt@0.66.0: + resolution: {integrity: sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7312,21 +6900,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.78.0: - resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=7.0.2001' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - - oxlint@1.79.0: - resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} + oxlint@1.81.0: + resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7350,10 +6925,6 @@ packages: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -7393,17 +6964,10 @@ packages: pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -7423,10 +6987,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -7467,8 +7027,8 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.15.0: - resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} @@ -7487,6 +7047,15 @@ packages: pg-native: optional: true + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} @@ -7504,8 +7073,8 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} @@ -7531,17 +7100,23 @@ packages: peerDependencies: postcss: ^8.4.38 + postcss-calc@11.1.1: + resolution: {integrity: sha512-EazB2CtQwKoBOCi8l334i/gnaObqcv418pLTvw4CmcqPLhPgP8IipVSixQO7lTRDrOBIwL72krecBfi5tpl4EA==} + engines: {node: ^22.22.3 || ^24.15 || >=26.0} + peerDependencies: + postcss: ^8.5.28 + postcss-colormin@7.0.10: resolution: {integrity: sha512-yFr6JezOolHLta/buLE71VKPh2mXursp4saVe98/ol8ZnEWhL+racShqPKlvd/DKWLre/39B6HhcMXf7RZ3hxg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.5.13 - postcss-colormin@8.0.1: - resolution: {integrity: sha512-qBY4ABQ6d8/mk5RRZHwMllrZMxeMey3azVY2dZUEk+RgiUC4ARdPR3/AITzNqqKTbvW/3y/MJKinDrzwqn8RDQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-colormin@9.0.2: + resolution: {integrity: sha512-h6uf6/HVT98tSLcQNQ8V0wuEQ8doBbxFwvgRqptdIeyCe8+aa29zNMPSVOkSLXXbCv25qDZaaGo/h/O4csTK3g==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-convert-values@7.0.12: resolution: {integrity: sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==} @@ -7549,11 +7124,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-convert-values@8.0.1: - resolution: {integrity: sha512-IdOSIX3BzfMvCc1TAHIha2gfy17xnb5vfML8e2BIKARnFOghksESfaSAB/3CXgyLfMozZAbTRPVQF5dbuKOidw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-convert-values@9.0.2: + resolution: {integrity: sha512-nsFL7tpxgaoF0G/w+fe8kkWvikxvwIb4ySW7PYzcmD1N60f0SA51sVnEuCrx7mHmwOvS1U1JIFvWHWHoK/bh8g==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-custom-properties@15.0.1: resolution: {integrity: sha512-cuyq8sd8dLY0GLbelz1KB8IMIoDECo6RVXMeHeXY2Uw3Q05k/d1GVITdaKLsheqrHbnxlwxzSRZQQ5u+rNtbMg==} @@ -7567,11 +7142,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-discard-comments@8.0.1: - resolution: {integrity: sha512-FDvzm3tXlEsQBO2XQgnta5ugsAqwBrgWH+j5QgXpegEIDYA0VPnZg2aP7LtmWtC49POskeIhXesFiU/k3NyFHA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-discard-comments@9.0.2: + resolution: {integrity: sha512-QxOYI1haY3f9rPgY7i0W0+lQihsbvC/edPAAJAaSZmteI+UtGraWNnkWOZ/nCNZ6D+Ab8a9hksOCjJYBg/gF0A==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-discard-duplicates@7.0.4: resolution: {integrity: sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==} @@ -7579,11 +7154,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-discard-duplicates@8.0.1: - resolution: {integrity: sha512-stTDXkI8YkCUfADurQhp03oq5ynsgSx6Qrw5B1swds6oTHtAeOZ9I0SHGK8cY/VpWUsIYFDWMs3IWf9jIEfFvA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-discard-duplicates@9.0.2: + resolution: {integrity: sha512-FCTKRK9bH2ayM2AkhNicRw7z1ROmgk8p+QFsiLeG8MStgHkDH/NOjhJfQp1scvzk6u1szn1xvY52T1XVAaL68w==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-discard-empty@7.0.3: resolution: {integrity: sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==} @@ -7591,11 +7166,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-discard-empty@8.0.1: - resolution: {integrity: sha512-Zv4fM1Yfhk71tbt6gfiptbL6jDHi+7apSnaMeaO9n1uET+1embrXQw5m93Zp5x28UyQSuv+AVkFY193jdwZ33w==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-discard-empty@9.0.2: + resolution: {integrity: sha512-skKth+zcP//uuDos1GD/M0DKwEMDCbUnOZxz/M+BvjflCAS94czq7wwXNt2eug87rxPCik/okyXKJ2kBitHGEQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-discard-overridden@7.0.3: resolution: {integrity: sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==} @@ -7603,11 +7178,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-discard-overridden@8.0.1: - resolution: {integrity: sha512-ykt4fvrC7yYGzbxKyqBVjDCbsjF/11JgWK8enrdkobRyqqEtb/uDUCbKOGdvrK8X7BrShW8Lv5cCRNbdkNHGkQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-discard-overridden@9.0.2: + resolution: {integrity: sha512-cUGXcnnhOBZwE+dLFRYLoXYrLxCQtD0mp8nUEHlQI8KdDXhaIHL7DCWUkXXEcNFon11xROYOP9E+plzJ5MSjXw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-flexbugs-fixes@5.0.2: resolution: {integrity: sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==} @@ -7636,11 +7211,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-merge-longhand@8.0.1: - resolution: {integrity: sha512-huTfSYgQ13O81SFvAuOi7GWnO48vvybjj3xF+X3qUoPjzvvaLpJH5DcUqqXcwOEulZUcvaV4s0V9WtWs+IAQPA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-merge-longhand@9.0.3: + resolution: {integrity: sha512-JJLx47+h7TIIThb+VE3pFHZUY/HKXgXa+yAZlN0FBX2IsIURJuOavS8gQwUvj5oluzxxRA9IjR95rLllbeybBw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-merge-rules@7.0.11: resolution: {integrity: sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==} @@ -7648,11 +7223,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-merge-rules@8.0.1: - resolution: {integrity: sha512-o3rk4UpnPNg469tklYwbR/NtvKc/f/wJiVDTnNQ/EFPw/LeiPOHUCvV1GIBQIZHGrBAYdPjToK6a+ojYprsrxQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-merge-rules@9.0.3: + resolution: {integrity: sha512-Wd/r16vrGdC49ZeQesxgNrNdQkAGoKL51UGHd3MwyH3CHhvqHkRtXZKKEnblecuuWmvjWqptC4VA3BeAHAVrzQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-minify-font-values@7.0.3: resolution: {integrity: sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==} @@ -7660,11 +7235,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-minify-font-values@8.0.1: - resolution: {integrity: sha512-L8Nzs/PRlBSPrLdY/7rAiU5ZN5800+2J/4LRbfyG8SJnPljmgMaXVmQiCklvRS+yObfVRNtvmk/Ean/eoYcSeg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-minify-font-values@9.0.2: + resolution: {integrity: sha512-+QEc9ILK7sz/6LDnFITuvipMnX71A+v7b7VX3AyiN3WCIoJtc4xQovogeiOsX7CxXtwPKmk2z1BuI8h3TSnQEA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-minify-gradients@7.0.5: resolution: {integrity: sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==} @@ -7672,11 +7247,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-minify-gradients@8.0.1: - resolution: {integrity: sha512-qf+4s/hZMqTwpWN2teqf6+1yvR/SZK5HgHqXYuACeJXV7ABe7AXtBEomgxagUzcN4bSnmqBh5vnIml0dYqykYg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-minify-gradients@9.0.2: + resolution: {integrity: sha512-8EefxPsmS/RqMgJUBhx5N6hoWelHK8tW7DFUN8NLNrH1WSA4jvWRZc1OgzY0IheODiFsr/xiiOAHrPUQJFeMIg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-minify-params@7.0.9: resolution: {integrity: sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==} @@ -7684,11 +7259,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-minify-params@8.0.1: - resolution: {integrity: sha512-L0h3H59deFfFg0wQN1NVaS/8E/LfGvaMuZKGO7siwlG995zo3OshtQyRkqKdVqcBwAORBvZ1nDZrKPLRapYkQw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-minify-params@9.0.2: + resolution: {integrity: sha512-T8h6+8/JZQzTUlsw8oF3xdWIgrjw6dCpqdpUrIatrL852HNqE7w47C4mFrZADuu7IgT+4a3zRAX1nd112sAvwQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-minify-selectors@7.1.2: resolution: {integrity: sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==} @@ -7696,14 +7271,14 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-minify-selectors@8.0.2: - resolution: {integrity: sha512-3icdxc/zght5UAizdwqZBDE2KOWHf1jMQCxET6iLACeNlRxfTPyXS0/COpGk8CQ2cECyaEKTRUd/i/k8Gxmz4g==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-minify-selectors@9.0.3: + resolution: {integrity: sha512-mElz5y5+pMisgaN1VgschjOQBh8I9fga/OhJUX+VKyQ14ebukeB3xZRlbu+ebIKI/+qT8tj7dfx0AEQ6gWCPOg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 - postcss-nesting@14.0.0: - resolution: {integrity: sha512-YGFOfVrjxYfeGTS5XctP1WCI5hu8Lr9SmntjfRC+iX5hCihEO+QZl9Ra+pkjqkgoVdDKvb2JccpElcowhZtzpw==} + postcss-nesting@14.0.1: + resolution: {integrity: sha512-80MH7KcmMtb7ffSBX82uZwnogltubaXF0sagu+OGD8M9jViR3ngRgCdoTzKCvKEtllB0MW2U7Rgfcub5fR1Bug==} engines: {node: '>=20.19.0'} peerDependencies: postcss: ^8.4 @@ -7714,11 +7289,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-charset@8.0.1: - resolution: {integrity: sha512-xzqr36F8UeIZOvOHsf3aul+RVJCADvSwuwpMLgizqKjisHZpBfztgW0XFLBfJvz9pJgaStaOXAtGb0zLqT6B0w==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-charset@9.0.2: + resolution: {integrity: sha512-2mFe06u9nwCdG+swpN3YmcRBNAZTR1IocNvN6Lzi7kKXjB0LhEOSGeu4B6rbV84NtgdkJwdulzeh1AdABM9N5Q==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-display-values@7.0.3: resolution: {integrity: sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==} @@ -7726,11 +7301,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-display-values@8.0.1: - resolution: {integrity: sha512-ZDWOijOK1FFMlpgiQCUO9fCNKd7HJ9L7z9HWEq4iyubnUFWzdTSwm/LcrMbNW6iZ1oAtqeLYA0WA3xHszOI08g==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-display-values@9.0.2: + resolution: {integrity: sha512-SHON0J7MuPrwpDAz4fHQgVNBfivLNKW8hgtO0WM3d5P0vFargQ4tCUNYdVBHfXr1xRFmmifRvJKmahksaHHn+w==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-positions@7.0.4: resolution: {integrity: sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==} @@ -7738,11 +7313,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-positions@8.0.1: - resolution: {integrity: sha512-uuivan2poSqbE48ST4do20dGaFUeXey9/H8rhHzoyVHB2I6BmkoVLZ/C9+BRjUlpaAFYVOoDY7epkiidzaYbvA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-positions@9.0.2: + resolution: {integrity: sha512-2KQaPVbXUm1oUViZXvD6nsuRiAr81J3X2dRSeK1mCdPyMSUBQ76TpAjrVvbceicdbs3Bo2j9qkfO9iZSMJBsGQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-repeat-style@7.0.4: resolution: {integrity: sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==} @@ -7750,11 +7325,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-repeat-style@8.0.1: - resolution: {integrity: sha512-q2hq5fmKxk29K6DjKA3nZ17Q2dtjhLYFNmFweKALmooUqx6UWAHF1bBoWTu/EqlJ88josb82A/J0Atj9LJUmpQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-repeat-style@9.0.2: + resolution: {integrity: sha512-+EropmN1W6gVnmwbhP8Tb7BmmqXgRCFJqtWFTcA0nZBz7aGj4zXL9EWyQ20DkA0Vd+ladQOmNuaUsc0jCeXT5Q==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-string@7.0.3: resolution: {integrity: sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==} @@ -7762,11 +7337,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-string@8.0.1: - resolution: {integrity: sha512-+Wf+kQJhm1WgSGEAuUaswE9rdpR9QbrKRVemcVHs6rhOoOTVIdAbgaicftfYA6vLM346P8onRzkEVbFN29ktKQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-string@9.0.2: + resolution: {integrity: sha512-vzJqaYpeG/rYWVDMpfxA3I2XNRlkK7XKYFINAfYLKiXn0SDndmvQYZhYEQdpvapDy7QAiK1/4iPVcfxsjWfU1w==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-timing-functions@7.0.3: resolution: {integrity: sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==} @@ -7774,11 +7349,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-timing-functions@8.0.1: - resolution: {integrity: sha512-W8/tvwRlm3T+yjGkg0IRTF4bvHj0vILYr/LOogCrJKHz2ey2HFRwfsAA8Bk9N4BGR7z7WmmDu/KzzwhJ6FoGPQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-timing-functions@9.0.2: + resolution: {integrity: sha512-CZ5T2XvUra6kJDBHZ+KQYuY0QJhpGv3atiLbkCLVk3BnBwh49zhNid/+IQrxQFwYkJ54oVJQuqrj6J4eaOFHTw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-unicode@7.0.9: resolution: {integrity: sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==} @@ -7786,11 +7361,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-unicode@8.0.1: - resolution: {integrity: sha512-Ad0YHNRBp4WHEOYUM/4wL/8MoL2fimEF8se/0q+Rt/owMzYpbxsypC1P8fN/oluwoRmRKdNVX7X2oycEobPWcQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-unicode@9.0.2: + resolution: {integrity: sha512-AOT0whCCcKASm1Ee8pchg03xgFxJopQhP1/G7CEt5X9LBD6EV4zL3a8duDJEGXYaCwuxjtwywRm8K5L6o0OJLA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-url@7.0.3: resolution: {integrity: sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==} @@ -7798,11 +7373,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-url@8.0.1: - resolution: {integrity: sha512-tkYcip6pCDY806xuxpJYqMW2M3/623jzGFJmz3m5Us47q8P28+gbRZxaea3Rr/CmwwLUiVlh+BTGYwQ6gvaP8A==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-url@9.0.2: + resolution: {integrity: sha512-oGfMuPEcmjon+08+gKpuqFUPn6/B4uTAJSHRlJe6h0xeB0baq5Zm/Swk0Jyz8mzVTNOIh8PuMUvWP+tVeSpS4A==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-normalize-whitespace@7.0.3: resolution: {integrity: sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==} @@ -7810,11 +7385,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-normalize-whitespace@8.0.1: - resolution: {integrity: sha512-XzORadNfSrKWDZZpgAEHPKINKx8r9r9RIfE9c70g/HThdpbmPHhDYCodHSVESDxmKeySAYw1p4liuBCf7j6LyA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-normalize-whitespace@9.0.2: + resolution: {integrity: sha512-gLkQNyMsT5xx1kbeT7vUmxwoIt6o5MA+1pX4LBc89ipX1GPYUpf5sv5IA0iikKDn05Zo78IsSFWc/C5FcYxRCQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-ordered-values@7.0.4: resolution: {integrity: sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==} @@ -7822,11 +7397,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-ordered-values@8.0.1: - resolution: {integrity: sha512-OLXq5lR1yk3KWQ1FPK6aWjFFdktHE9f9kb8cnt4LmIw7w30DnzgD9+sOVYJc5HenkWCX8i1MJhhFwmqc/GYqLg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-ordered-values@9.0.2: + resolution: {integrity: sha512-9DDzlg3E8ZOU/5bgCF4RCVXaEeoOuh3v23BT+ka8Mh3RpEFfpCg1kfpt1v38voIqV4gRZ1S1QHlnHWrzSzWZoA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-page-break@3.0.4: resolution: {integrity: sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==} @@ -7839,11 +7414,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-reduce-initial@8.0.1: - resolution: {integrity: sha512-+aQsR6+61KRoIfcFNLP3v9RM7+0iYOTtPnjl1wr6JqMW1zx6S+t2ktHRefXwacFdHIDj5+ETG0KY7K3+SGQ4Nw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-reduce-initial@9.0.2: + resolution: {integrity: sha512-rzSZ5ns9W/OGfwKPSEmlGMr0DsZy2exNT8uEKNU49GK+lgNnF+pKbPq0ZcHvFYn9oO6U+C6RfVuWWNgzGwxCJw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-reduce-transforms@7.0.3: resolution: {integrity: sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==} @@ -7851,14 +7426,14 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-reduce-transforms@8.0.1: - resolution: {integrity: sha512-x71slHVykiFi5RuKEXM0wgYpY2PngC78x6R8TnZhHF3lhqt+u/w3MGwYLX+2t5O87ssRiMfEAhQH+3J4QwVzCw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-reduce-transforms@9.0.2: + resolution: {integrity: sha512-UXR9hvucM/VKwLDdcPFxszYAMyxLmpYACdxlPnNJ+6MKi+sEJHTMiUlVY3KRRuO00kuOjZ+t8M/I7N/gFMvwfg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss-svgo@7.1.3: @@ -7867,11 +7442,11 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-svgo@8.0.1: - resolution: {integrity: sha512-HpnvWii7W0/FPrsejJa6ZTi0kNtTJP/Iba7CUMPX0xPV6QpnndOp+SDP74tFtgjA2cYKYNWJPOlmLXMsvi/9yA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-svgo@9.0.2: + resolution: {integrity: sha512-FY1/AvWkMyXIlc3dFtwGdZDLdK12VAQqdqLOBKIQPsh574UZ5GfzMigeccvnuzEWPOySh/4LkTfGELDtnuBPXg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-unique-selectors@7.0.7: resolution: {integrity: sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==} @@ -7879,17 +7454,17 @@ packages: peerDependencies: postcss: ^8.5.13 - postcss-unique-selectors@8.0.1: - resolution: {integrity: sha512-+xvKI5+/Cl8yYQwxDV39Uhuc4WV951xngFvPPjiPj2NIbIfm6vbbRTXblyw0FioLkIoGlw+7qUcY1h2YhaZYgw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + postcss-unique-selectors@9.0.2: + resolution: {integrity: sha512-Jyl/5yYy8VuWRCkSiI/5n75bEWty8wYho3eB1w4/ZK2XSW6AnNVJ6tGuWaCmJt0M5+ouHb/m+qXzFOsG3tOWww==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -7927,10 +7502,6 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} @@ -7944,12 +7515,12 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} - probe-image-size@7.3.0: - resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==} + probe-image-size@7.4.0: + resolution: {integrity: sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==} process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} @@ -7961,8 +7532,8 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - protobufjs@7.6.5: - resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} protocols@2.0.2: @@ -7985,8 +7556,8 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -8023,16 +7594,6 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} - react-router@7.18.1: - resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - react-router@7.18.2: resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} engines: {node: '>=20.0.0'} @@ -8043,14 +7604,6 @@ packages: react-dom: optional: true - react-server-dom-rspack@0.1.0: - resolution: {integrity: sha512-KqDzmxBUZEcAphwg/PnEHOBkqTJjesTmLoBygLHie1gkiLINiZuVKPRccS6qDzfdj9ccYCaJ743IDYVh0wPf/w==} - engines: {node: '>=0.10.0'} - peerDependencies: - '@rspack/core': ^2.2.0-0 - react: ^19.1.0 - react-dom: ^19.1.0 - react-side-effect@2.1.2: resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} peerDependencies: @@ -8072,17 +7625,13 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} + redis@6.2.1: + resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} + engines: {node: '>= 20.0.0'} reduce-configs@1.1.2: resolution: {integrity: sha512-AgBP55V8FC7NaqoOP2RCbTpu6LE+YuX3LUZkNAoitcfyS3/PIC8Obg/TJrBzTkJ+lDvZv0TTAeDpLkzjTtYlbw==} @@ -8160,12 +7709,6 @@ packages: rou3@0.9.2: resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} - rsbuild-plugin-rsc@0.1.1: - resolution: {integrity: sha512-aDITXfKR7D6LudDZeErVIzftgVRK6qoA77Mt/Lnp90qXgRRT3X63yOq4bx8bRPeFXIp1Rp+KS3VJq1K9oWmkMQ==} - peerDependencies: - '@rsbuild/core': ^2.0.0 - react-server-dom-rspack: 0.1.0 - rslog@2.3.0: resolution: {integrity: sha512-g2wW/ermwzGTLlzGdJBDRc4YKz2/yPBjf57w9g5CvhtpZ91Xq95OaWku0oNHYi+XsuV6v8QrCo2oJJR/pCUR8g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8328,8 +7871,8 @@ packages: engines: {node: '>=20.19.0'} hasBin: true - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} scheduler@0.27.0: @@ -8356,18 +7899,18 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-javascript@7.0.7: - resolution: {integrity: sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==} + serialize-javascript@7.1.1: + resolution: {integrity: sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==} engines: {node: '>=20.0.0'} - seroval-plugins@1.6.4: - resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + seroval-plugins@1.6.6: + resolution: {integrity: sha512-hOWuWKonCEbIu7oGJDYc8QSGZOM8H05c+kYO4AJEGoEIbdJKzpSjVyAHRZU1LDN+4ZcmY3ASkdOV8/JN/LM0ag==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.6.4: - resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + seroval@1.6.6: + resolution: {integrity: sha512-brjHfhA6vDMEAqfpJ8aCudGCQDFYdaQocpNOs59XSt1l2ulz2Cz+OB+jVPHn1mgx/tYo/6x3YZ7+yOtBzTsKYQ==} engines: {node: '>=10'} set-cookie-parser@2.7.2: @@ -8391,9 +7934,9 @@ packages: shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -8403,10 +7946,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.10.0: - resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -8444,17 +7983,6 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - socket.io-adapter@2.5.8: - resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} - - socket.io-parser@4.2.7: - resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} - engines: {node: '>=10.0.0'} - - socket.io@4.8.1: - resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} - engines: {node: '>=10.2.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8474,9 +8002,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -8552,11 +8077,11 @@ packages: peerDependencies: postcss: ^8.5.13 - stylehacks@8.0.1: - resolution: {integrity: sha512-Gv095oTD0N+BdJALNFDsxZpETHZLTxbOl5RyIO7y6VAE6sR3z0MnV3Nix7N0IATNldNTrkvSASp2KR1Yt526HA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + stylehacks@9.0.3: + resolution: {integrity: sha512-ffR7soMPCLSZoye/H4gEcOt7fpRmm+1/Mq7j4L9Hex2284Ci0oUkPSiRSpDW0X3tptLtZqGUsOxZ64fy6izthA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0} peerDependencies: - postcss: ^8.5.15 + postcss: ^8.5.28 supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} @@ -8577,16 +8102,17 @@ packages: svg-element-attributes@1.3.1: resolution: {integrity: sha512-Bh05dSOnJBf3miNMqpsormfNtfidA/GxQVakhtn0T4DECWKeXQRQUceYjJ+OxYiiLdGe4Jo9iFV8wICFapFeIA==} - svg-parser@2.0.4: - resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + svg-parser@2.1.0: + resolution: {integrity: sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==} + engines: {node: '>=8'} - svgo@3.3.4: - resolution: {integrity: sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==} + svgo@3.3.5: + resolution: {integrity: sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==} engines: {node: '>=14.0.0'} hasBin: true - svgo@4.0.2: - resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} engines: {node: '>=16'} hasBin: true @@ -8609,15 +8135,17 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwind-variants@3.2.2: - resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} - engines: {node: '>=16.x', pnpm: '>=7.x'} + tailwind-variants@3.3.1: + resolution: {integrity: sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==} + engines: {node: '>=16.9.x', pnpm: '>=7.x'} peerDependencies: tailwind-merge: '>=3.0.0' tailwindcss: '*' peerDependenciesMeta: tailwind-merge: optional: true + tailwindcss: + optional: true tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} @@ -8635,23 +8163,23 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.21: - resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - terser@5.49.0: - resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} engines: {node: '>=10'} hasBin: true - thingies@2.6.0: - resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + thingies@2.6.1: + resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} engines: {node: '>=10.18'} peerDependencies: tslib: ^2 - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -8704,16 +8232,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - - type-fest@5.8.0: - resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + type-fest@5.9.0: + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} engines: {node: '>=20'} type-is@2.1.0: @@ -8736,8 +8256,8 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.69.0: - resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -8756,29 +8276,29 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - ultracite@7.10.2: - resolution: {integrity: sha512-WP1Hu/BKy/BDgntaiU33uq2Y8hKL2gEO5li2wfg7XFvmlyIkGDEy8qbWg7GHsxTEjEpWOAKpyFSF8aqc3JNEGQ==} + ultracite@7.11.0: + resolution: {integrity: sha512-HJByeIoO4Lhcing1k2sabI7BCHRqeMztXGYZm4Zcir7NXvidXLwDgLNZgSR4w3oPKPgx7CRTqzJitt/oPR34NA==} hasBin: true peerDependencies: - oxfmt: '>=0.1.0' - oxlint: ^1.0.0 + '@biomejs/biome': ^2.5.0 + eslint: ^10.0.0 + oxfmt: '>=0.40.0' + oxlint: ^1.79.0 + prettier: ^3.0.0 + stylelint: ^17.0.0 peerDependenciesMeta: - oxfmt: + '@biomejs/biome': optional: true - oxlint: + eslint: optional: true - - ultracite@7.10.7: - resolution: {integrity: sha512-rc+TG/zLCV+Uk1PL4XPmsq7CW8GtlaV4g7Q53Bt5o7UNGSKKNRoPY4syz1BDWwSoNBcvWKpSnfSVflsecok4EA==} - hasBin: true - peerDependencies: - oxfmt: '>=0.1.0' - oxlint: ^1.79.0 - peerDependenciesMeta: oxfmt: optional: true oxlint: optional: true + prettier: + optional: true + stylelint: + optional: true unbash@4.0.11: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} @@ -8788,18 +8308,19 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - undici@8.10.1: - resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} unenv@2.0.0-rc.24: @@ -8813,8 +8334,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -8833,17 +8354,9 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - uuid@14.0.1: - resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} - hasBin: true - varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -8866,8 +8379,8 @@ packages: resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} engines: {node: '>=10.13.0'} - webpack@5.108.4: - resolution: {integrity: sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==} + webpack@5.110.3: + resolution: {integrity: sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -8906,21 +8419,17 @@ packages: engines: {node: '>= 8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - workerd@1.20260708.1: - resolution: {integrity: sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==} + workerd@1.20260730.1: + resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} engines: {node: '>=16'} hasBin: true - wrangler@4.110.0: - resolution: {integrity: sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==} + wrangler@4.116.0: + resolution: {integrity: sha512-wP1PXxH5KJajfGEjty0NNqyxAirTFvMZpGyB5CRqEIiY0fL/SiCKtIYAJB9HXq0MP0sMXB/i/YSls+WObahAhw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260708.1 + '@cloudflare/workers-types': ^5.20260730.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -9024,17 +8533,17 @@ packages: zephyr-xpack-internal@1.2.4: resolution: {integrity: sha512-Czju+fCo1+GL4fIZHrCwa41Qt2Z0MWZ4XHC9jKjXeje+5uS4WiVNTJR0p0+xlrPjhfVFyC7WIhuKyl9rHh9kvg==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: - '@alloc/quick-lru@5.2.0': {} + '@alloc/quick-lru@5.3.0': {} - '@antfu/install-pkg@1.1.0': + '@antfu/install-pkg@2.0.1': dependencies: package-manager-detector: 1.8.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 '@authzed/authzed-node@1.6.1': dependencies: @@ -9043,12 +8552,6 @@ snapshots: '@protobuf-ts/runtime-rpc': 2.11.1 google-protobuf: 4.0.2 - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -9065,14 +8568,14 @@ snapshots: '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -9082,10 +8585,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -9103,7 +8606,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -9113,8 +8616,8 @@ snapshots: '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -9123,7 +8626,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9140,11 +8643,11 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@8.0.4': dependencies: @@ -9155,8 +8658,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/template@8.0.0': dependencies: @@ -9164,14 +8667,14 @@ snapshots: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 - '@babel/traverse@7.29.7(supports-color@10.2.2)': + '@babel/traverse@7.29.8(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9186,7 +8689,7 @@ snapshots: '@babel/types': 8.0.4 obug: 2.1.4 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -9196,61 +8699,61 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 - '@better-auth/api-key@1.7.2(831f340a6e103a07b479cd8fecebd372)': + '@better-auth/api-key@1.7.2(79cba0b50135889b6f65abdf41476109)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - better-auth: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - better-call: 1.4.0(zod@4.4.3) - zod: 4.4.3 + better-auth: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + better-call: 1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2)': + '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747) '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) jose: 6.2.5 - kysely: 0.29.4 - nanostores: 1.4.2 - zod: 4.4.3 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) optionalDependencies: '@cloudflare/workers-types': 5.20260810.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))': + '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: - kysely: 0.29.4 + kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))': + '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747) @@ -9264,13 +8767,11 @@ snapshots: '@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747)': {} - '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12(2ed8dc7978631e6ad66c75a70c01a221)': + '@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893)' - '@rstest/adapter-rsbuild': 0.11.9(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3)) + '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@rstest/adapter-rsbuild': 0.11.12(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3)) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - '@parcel/css' - '@rsbuild/core' @@ -9282,7 +8783,6 @@ snapshots: - clean-css - core-js - csso - - debug - devcert - lightningcss - react @@ -9291,102 +8791,104 @@ snapshots: - rollup - rsbuild-plugin-rsc - supports-color - - ts-node - tsconfig-paths - - tslib - typescript - utf-8-validate - webpack - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(1de7912af751fa74e815801433c09f46)': + '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: '@babel/parser': 8.0.4 '@babel/traverse': 8.0.4 - '@babel/types': 8.0.4 '@loadable/component': 5.16.7(react@19.2.8) - '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/prod-server': '@bleedingdev/modern-js-prod-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server': '@bleedingdev/modern-js-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(debug@4.3.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@modern-js/builder': '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) '@swc/helpers': 0.5.23 - compression-webpack-plugin: 12.0.0(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - es-module-lexer: 2.3.1 - esbuild: 0.28.1 - flatted: 3.4.4 - import-meta-resolve: 4.2.0 - mlly: 1.8.2 - ndepe: 0.1.13(supports-color@10.2.2) - pkg-types: 2.3.1 std-env: 4.2.0 - optionalDependencies: - tsconfig-paths: 4.2.0 transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - '@parcel/css' - '@rspack/core' - '@swc/css' - '@typescript/native-preview' - - bufferutil - clean-css - core-js - csso - - debug - - devcert + - esbuild + - lightningcss + - react + - react-dom + - react-server-dom-rspack + - rsbuild-plugin-rsc + - supports-color + - typescript + - webpack + + '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': + dependencies: + '@babel/parser': 8.0.4 + '@babel/traverse': 8.0.4 + '@loadable/component': 5.16.7(react@19.2.8) + '@modern-js/builder': '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + '@swc/helpers': 0.5.23 + std-env: 4.2.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - '@parcel/css' + - '@rspack/core' + - '@swc/css' + - '@typescript/native-preview' + - clean-css + - core-js + - csso + - esbuild - lightningcss - react - react-dom - react-server-dom-rspack - - rollup - rsbuild-plugin-rsc - supports-color - - tslib - typescript - - utf-8-validate - webpack - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893)': + '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: '@babel/parser': 8.0.4 '@babel/traverse': 8.0.4 '@babel/types': 8.0.4 '@loadable/component': 5.16.7(react@19.2.8) - '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/prod-server': '@bleedingdev/modern-js-prod-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server': '@bleedingdev/modern-js-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(debug@4.3.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@modern-js/app-tools-extensions': '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/builder': '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/prod-server': '@bleedingdev/modern-js-prod-server@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server': '@bleedingdev/modern-js-server@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@typescript/native-preview@7.0.0-dev.20260707.2)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-runtime-extensions': '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4(@typescript/native-preview@7.0.0-dev.20260707.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + '@swc/core': 1.16.2(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 - compression-webpack-plugin: 12.0.0(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - es-module-lexer: 2.3.1 - esbuild: 0.28.1 + compression-webpack-plugin: 12.0.0(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + es-module-lexer: 2.3.2 + esbuild: 0.28.2 flatted: 3.4.4 import-meta-resolve: 4.2.0 mlly: 1.8.2 ndepe: 0.1.13(supports-color@10.2.2) - pkg-types: 2.3.1 + pkg-types: 2.3.3 std-env: 4.2.0 - optionalDependencies: - tsconfig-paths: 4.2.0 transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - '@parcel/css' - '@rspack/core' @@ -9396,7 +8898,6 @@ snapshots: - clean-css - core-js - csso - - debug - devcert - lightningcss - react @@ -9405,71 +8906,90 @@ snapshots: - rollup - rsbuild-plugin-rsc - supports-color - - tslib + - tsconfig-paths - typescript - utf-8-validate - webpack - '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.4.3)': + '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))': dependencies: - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 koa-compose: 4.1.0 reflect-metadata: 0.2.2 tsconfig-paths: 4.2.0 - type-fest: 5.8.0 + type-fest: 5.9.0 optionalDependencies: - zod: 4.4.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) transitivePeerDependencies: - react - react-dom - ? '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - : dependencies: - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - '@rsbuild/plugin-assets-retry': 2.0.2(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-check-syntax': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-css-minimizer': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsbuild/plugin-less': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)) - '@rsbuild/plugin-rem': 1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-sass': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-source-build': 1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-svgr': 2.0.5(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2) - '@rsbuild/plugin-type-check': 1.6.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(tslib@2.8.1)(typescript@7.0.2) - '@rsbuild/plugin-typed-css-modules': 1.2.4(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsdoctor/rspack-plugin': 1.6.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@bleedingdev/modern-js-bff-effect@3.9.0-ultramodern.4(@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))': + dependencies: + '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-web': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optionalDependencies: + '@effect/opentelemetry': 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) + transitivePeerDependencies: + - react + - react-dom + - tsconfig-paths + - zod + + '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + '@rsbuild/plugin-assets-retry': 2.0.2(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-check-syntax': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-css-minimizer': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@rsbuild/plugin-less': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)) + '@rsbuild/plugin-rem': 1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-sass': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-source-build': 1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-svgr': 2.0.5(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2) + '@rsbuild/plugin-type-check': 1.6.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(typescript@7.0.2) + '@rsbuild/plugin-typed-css-modules': 1.2.4(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@swc/core': 1.16.2(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 - '@typescript/native-preview': 7.0.0-dev.20260707.2 - autoprefixer: 10.5.2(postcss@8.5.26) - browserslist: 4.28.8 - core-js: 3.49.0 - cssnano: 8.0.2(postcss@8.5.26) + autoprefixer: 10.5.5(postcss@8.5.28) + browserslist: 4.28.9 + core-js: 3.50.0 + cssnano: 9.0.3(postcss@8.5.28) html-minifier-terser: 7.2.0 lodash: 4.18.1 - postcss: 8.5.26 - postcss-custom-properties: 15.0.1(postcss@8.5.26) - postcss-flexbugs-fixes: 5.0.2(postcss@8.5.26) - postcss-font-variant: 5.0.0(postcss@8.5.26) - postcss-initial: 4.0.1(postcss@8.5.26) - postcss-media-minmax: 5.0.0(postcss@8.5.26) - postcss-nesting: 14.0.0(postcss@8.5.26) - postcss-page-break: 3.0.4(postcss@8.5.26) - rspack-manifest-plugin: 5.2.2(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)) + postcss: 8.5.28 + postcss-custom-properties: 15.0.1(postcss@8.5.28) + postcss-flexbugs-fixes: 5.0.2(postcss@8.5.28) + postcss-font-variant: 5.0.0(postcss@8.5.28) + postcss-initial: 4.0.1(postcss@8.5.28) + postcss-media-minmax: 5.0.0(postcss@8.5.28) + postcss-nesting: 14.0.1(postcss@8.5.28) + postcss-page-break: 3.0.4(postcss@8.5.28) + rspack-manifest-plugin: 5.2.2(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)) + sass-embedded: 1.100.0 ts-deepmerge: 8.0.0 - optionalDependencies: - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - rsbuild-plugin-rsc: 0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - '@parcel/css' - '@rspack/core' - '@swc/css' - - bufferutil + - '@typescript/native-preview' - clean-css - csso - esbuild @@ -9477,57 +8997,51 @@ snapshots: - react - react-dom - supports-color - - tslib - typescript - - utf-8-validate - webpack - ? '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - : dependencies: - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - '@rsbuild/plugin-assets-retry': 2.0.2(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-check-syntax': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-css-minimizer': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsbuild/plugin-less': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)) - '@rsbuild/plugin-rem': 1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-sass': 2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-source-build': 1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsbuild/plugin-svgr': 2.0.5(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2) - '@rsbuild/plugin-type-check': 1.6.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(tslib@2.8.1)(typescript@7.0.2) - '@rsbuild/plugin-typed-css-modules': 1.2.4(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsdoctor/rspack-plugin': 1.6.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + '@rsbuild/plugin-assets-retry': 2.0.2(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-check-syntax': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-css-minimizer': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@rsbuild/plugin-less': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)) + '@rsbuild/plugin-rem': 1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-sass': 2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-source-build': 1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@rsbuild/plugin-svgr': 2.0.5(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2) + '@rsbuild/plugin-type-check': 1.6.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(typescript@7.0.2) + '@rsbuild/plugin-typed-css-modules': 1.2.4(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)) + '@swc/core': 1.16.2(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 - '@typescript/native-preview': 7.0.0-dev.20260707.2 - autoprefixer: 10.5.2(postcss@8.5.26) - browserslist: 4.28.8 - core-js: 3.49.0 - cssnano: 8.0.2(postcss@8.5.26) + autoprefixer: 10.5.5(postcss@8.5.28) + browserslist: 4.28.9 + core-js: 3.50.0 + cssnano: 9.0.3(postcss@8.5.28) html-minifier-terser: 7.2.0 lodash: 4.18.1 - postcss: 8.5.26 - postcss-custom-properties: 15.0.1(postcss@8.5.26) - postcss-flexbugs-fixes: 5.0.2(postcss@8.5.26) - postcss-font-variant: 5.0.0(postcss@8.5.26) - postcss-initial: 4.0.1(postcss@8.5.26) - postcss-media-minmax: 5.0.0(postcss@8.5.26) - postcss-nesting: 14.0.0(postcss@8.5.26) - postcss-page-break: 3.0.4(postcss@8.5.26) - rspack-manifest-plugin: 5.2.2(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)) + postcss: 8.5.28 + postcss-custom-properties: 15.0.1(postcss@8.5.28) + postcss-flexbugs-fixes: 5.0.2(postcss@8.5.28) + postcss-font-variant: 5.0.0(postcss@8.5.28) + postcss-initial: 4.0.1(postcss@8.5.28) + postcss-media-minmax: 5.0.0(postcss@8.5.28) + postcss-nesting: 14.0.1(postcss@8.5.28) + postcss-page-break: 3.0.4(postcss@8.5.28) + rspack-manifest-plugin: 5.2.2(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)) + sass-embedded: 1.100.0 ts-deepmerge: 8.0.0 - optionalDependencies: - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - rsbuild-plugin-rsc: 0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - '@parcel/css' - '@rspack/core' - '@swc/css' - - bufferutil + - '@typescript/native-preview' - clean-css - csso - esbuild @@ -9535,214 +9049,152 @@ snapshots: - react - react-dom - supports-color - - tslib - typescript - - utf-8-validate - webpack - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110)(oxlint-tsgolint@7.0.2001)': + '@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4(oxlint-tsgolint@7.0.2001)': dependencies: - oxlint: 1.78.0(oxlint-tsgolint@7.0.2001) + '@babel/parser': 8.0.4 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + oxlint: 1.81.0(oxlint-tsgolint@7.0.2001) transitivePeerDependencies: - oxlint-tsgolint - vite-plus - '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 encoding: 0.1.13 path-to-regexp: 8.4.2 - qs: 6.15.3 + qs: 6.16.0 transitivePeerDependencies: - react - react-dom - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=fe09c7875888067ae4ab161d302024d95f45d52844f6fb9268db1214a35f84f5)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': + '@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/codesmith': 2.6.9(supports-color@10.2.2) - '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - esbuild: 0.28.1 - oxfmt: 0.63.0 - ultracite: 7.10.2(oxfmt@0.63.0)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)) - transitivePeerDependencies: - - oxlint - - react - - react-dom - - supports-color - - svelte - - vite-plus + '@modern-js/runtime': '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + i18next-browser-languagedetector: 8.2.1 + react: 19.2.8 - '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-i18n-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 transitivePeerDependencies: - react - react-dom - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(3a233a8c5baa0ff66c13c170d030459d)': - dependencies: - '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.4.3)' - '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@module-federation/runtime': 2.8.2 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-web': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - '@swc/core': 1.15.43(@swc/helpers@0.5.23) - '@swc/helpers': 0.5.23 - esbuild: 0.28.1 - qs: 6.15.3 - type-is: 2.1.0 + '@bleedingdev/modern-js-plugin-bff-extensions@3.9.0-ultramodern.4(@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))': + dependencies: + '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/bff-effect': '@bleedingdev/modern-js-bff-effect@3.9.0-ultramodern.4(@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-runtime-extensions': '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@module-federation/runtime': 2.9.0 + esbuild: 0.28.2 optionalDependencies: - '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + '@effect/opentelemetry': 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - - '@parcel/css' - - '@rspack/core' - - '@swc/css' - - '@typescript/native-preview' - - bufferutil - - clean-css - core-js - - csso - - lightningcss - react - react-dom - - react-server-dom-rspack - - rsbuild-plugin-rsc - - supports-color - tsconfig-paths - - tslib - - typescript - - utf-8-validate - - webpack - zod - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)': - dependencies: - '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.4.3)' - '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' - '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@module-federation/runtime': 2.8.2 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-web': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - '@swc/core': 1.15.43(@swc/helpers@0.5.23) - '@swc/helpers': 0.5.23 - esbuild: 0.28.1 - qs: 6.15.3 + '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4(4ee1d8b8442de2cfc24c5713107ff8c2)': + dependencies: + '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/bff-effect': '@bleedingdev/modern-js-bff-effect@3.9.0-ultramodern.4(@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin-bff-extensions': '@bleedingdev/modern-js-plugin-bff-extensions@3.9.0-ultramodern.4(@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4(@typescript/native-preview@7.0.0-dev.20260707.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@swc/core': 1.16.2(@swc/helpers@0.5.23) + qs: 6.16.0 type-is: 2.1.0 optionalDependencies: - '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + '@effect/opentelemetry': 4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) + '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@module-federation/runtime-tools' - - '@parcel/css' - - '@rspack/core' - - '@swc/css' + - '@swc/helpers' - '@typescript/native-preview' - - bufferutil - - clean-css - core-js - - csso - - lightningcss - react - react-dom - - react-server-dom-rspack - - rsbuild-plugin-rsc - - supports-color - tsconfig-paths - - tslib - - typescript - - utf-8-validate - - webpack - zod - '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-plugin-data-loader@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 path-to-regexp: 8.4.2 react: 19.2.8 transitivePeerDependencies: - react-dom - '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(i18next@26.4.2(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime': '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-runtime': '@bleedingdev/modern-js-server-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/i18n-runtime-extensions': '@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime': '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-runtime': '@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 i18next-browser-languagedetector: 8.2.1 - i18next-chained-backend: 5.0.5 + i18next-chained-backend: 5.0.6 i18next-fs-backend: 2.6.7 - i18next-http-backend: 4.0.0 + i18next-http-backend: 4.0.2 i18next-http-middleware: 3.9.8 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - i18next: 26.3.6(typescript@7.0.2) + i18next: 26.4.2(typescript@7.0.2) transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js - '@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12(@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893))(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4(@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)))(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12(patch_hash=8f49154e0dd132ce88a4583fc0a0b726c1bf3ba4f3862ad65f8b77971fc0242b)(c95492ca4d79fdd0565fb79cc4972893)' - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime': '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(clean-css@5.3.3)(core-js@3.50.0)(csso@5.0.5)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))' + '@modern-js/i18n-runtime-extensions': '@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.4(@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime': '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 - '@tanstack/react-router': 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tanstack/router-core': 1.171.21(patch_hash=413c2453d06aa521ed65ab7fcfb16bac8700e58e97693c2ba4d40727d7c9790d) + '@tanstack/react-router': 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.28 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - optionalDependencies: - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js - '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) '@swc/helpers': 0.5.23 jiti: 2.7.0 transitivePeerDependencies: @@ -9751,12 +9203,12 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-prod-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-prod-server@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-runtime-extensions': '@bleedingdev/modern-js-server-runtime-extensions@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-runtime-extensions': '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 transitivePeerDependencies: - '@module-federation/runtime-tools' @@ -9764,49 +9216,50 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-render@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-render@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - optionalDependencies: - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4': {} + + '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 lru-cache: 11.5.2 react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - serialize-javascript: 7.0.7 optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@loadable/component': 5.16.7(react@19.2.8) '@loadable/server': 5.16.7(@loadable/component@5.16.7(react@19.2.8))(react@19.2.8) - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/render': '@bleedingdev/modern-js-render@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/render': '@bleedingdev/modern-js-render@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@swc/core': 1.16.2(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 - '@swc/plugin-loadable-components': 12.0.0 + '@swc/plugin-loadable-components': 13.0.0 '@types/loadable__component': 5.13.10 '@types/react-helmet': 6.1.11 cookie: 2.0.1 - entities: 8.0.0 - es-module-lexer: 2.3.1 - esbuild: 0.28.1 + entities: 8.1.0 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 invariant: 2.2.4 - isbot: 5.2.0 + isbot: 5.2.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-helmet: 6.1.0(react@19.2.8) @@ -9817,18 +9270,19 @@ snapshots: - core-js - react-server-dom-rspack - '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/plugin': '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 '@web-std/fetch': 4.2.1 '@web-std/file': 3.0.3 '@web-std/stream': 1.0.3 cloneable-readable: 3.0.0 flatted: 3.4.4 - hono: 4.12.31 + hono: 4.13.7 ts-deepmerge: 8.0.0 transitivePeerDependencies: - '@module-federation/runtime-tools' @@ -9836,12 +9290,12 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-server-runtime-extensions@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/create-request': '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 transitivePeerDependencies: - '@module-federation/runtime-tools' @@ -9849,11 +9303,11 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-server-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' '@swc/helpers': 0.5.23 transitivePeerDependencies: - '@module-federation/runtime-tools' @@ -9861,28 +9315,29 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)': + '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4(@typescript/native-preview@7.0.0-dev.20260707.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@swc/core': 1.16.2(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 optionalDependencies: - '@typescript/native-preview': typescript@7.0.2 + '@typescript/native-preview': 7.0.0-dev.20260707.2 transitivePeerDependencies: - react - react-dom - '@bleedingdev/modern-js-server@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(debug@4.3.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(tsconfig-paths@4.2.0)(typescript@7.0.2)': + '@bleedingdev/modern-js-server@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(@typescript/native-preview@7.0.0-dev.20260707.2)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)': dependencies: - '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12(patch_hash=254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)' - '@modern-js/types': '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - '@modern-js/utils': '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-core': '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-runtime-extensions': '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4(@typescript/native-preview@7.0.0-dev.20260707.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/types': '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' '@swc/helpers': 0.5.23 - axios: 1.18.1(debug@4.3.7(supports-color@10.2.2))(supports-color@10.2.2) connect-history-api-fallback: 2.0.0 http-compression: 1.1.3 - minimatch: 10.2.5 + minimatch: 10.2.6 path-to-regexp: 8.4.2 ws: 8.21.3 optionalDependencies: @@ -9892,17 +9347,38 @@ snapshots: - '@typescript/native-preview' - bufferutil - core-js - - debug - react - react-dom - - supports-color - utf-8-validate - '@bleedingdev/modern-js-types@3.8.2-ultramodern.12': + '@bleedingdev/modern-js-types@3.9.0-ultramodern.4': dependencies: - '@jest/types': 30.4.1 + '@jest/types': 30.5.1 + '@types/react': 19.2.18 + type-fest: 5.9.0 - '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))(prettier@3.9.6)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': + dependencies: + '@babel/parser': 8.0.4 + '@modern-js/codesmith': 2.6.9(supports-color@10.2.2) + '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + '@modern-js/utils': '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' + esbuild: 0.28.2 + oxfmt: 0.66.0 + ultracite: 7.11.0(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))(prettier@3.9.6) + transitivePeerDependencies: + - '@biomejs/biome' + - eslint + - oxlint + - prettier + - react + - react-dom + - stylelint + - supports-color + - svelte + - vite-plus + + '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@swc/helpers': 0.5.23 caniuse-lite: 1.0.30001810 @@ -9914,46 +9390,48 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@bufbuild/protobuf@2.13.0': {} + '@bufbuild/protobuf@2.14.1': {} - '@clack/core@1.4.3': + '@clack/core@1.5.0': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.7.0': + '@clack/prompts@1.8.0': dependencies: - '@clack/core': 1.4.3 + '@clack/core': 1.5.0 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260708.1 + workerd: 1.20260730.1 - '@cloudflare/workerd-darwin-64@1.20260708.1': + '@cloudflare/workerd-darwin-64@1.20260730.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260708.1': + '@cloudflare/workerd-darwin-arm64@1.20260730.1': optional: true - '@cloudflare/workerd-linux-64@1.20260708.1': + '@cloudflare/workerd-linux-64@1.20260730.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260708.1': + '@cloudflare/workerd-linux-arm64@1.20260730.1': optional: true - '@cloudflare/workerd-windows-64@1.20260708.1': + '@cloudflare/workerd-windows-64@1.20260730.1': optional: true '@cloudflare/workers-types@5.20260810.1': {} - '@colordx/core@5.5.0': {} + '@colordx/core@5.8.0': {} + + '@colordx/core@6.4.0': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -9970,99 +9448,99 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.6)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.6 - '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.6)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.6 - '@csstools/utilities@3.0.0(postcss@8.5.26)': + '@csstools/utilities@3.0.0(postcss@8.5.28)': dependencies: - postcss: 8.5.26 + postcss: 8.5.28 '@cyberalien/svg-utils@1.2.19': dependencies: '@iconify/types': 2.0.0 - '@drizzle-team/brocli@0.12.0': {} + '@drizzle-team/brocli@0.12.1': {} - '@effect/opentelemetry@4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': + '@effect/opentelemetry@4.0.0-rc.112(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))': dependencies: '@opentelemetry/semantic-conventions': 1.43.0 - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) optionalDependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-web': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-web': 2.11.0(@opentelemetry/api@1.9.1) - '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': + '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2))': + '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(redis@6.2.1(@opentelemetry/api@1.9.1))': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) - ioredis: 5.11.1(supports-color@10.2.2) + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) mime: 4.1.0 - undici: 8.10.1 + redis: 6.2.1(@opentelemetry/api@1.9.1) + undici: 8.10.2 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': + '@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))': dependencies: - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) - pg: 8.22.0 + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) + pg: 8.23.0 pg-connection-string: 2.14.0 - pg-cursor: 2.22.0(pg@8.22.0) - pg-pool: 3.14.0(pg@8.22.0) + pg-cursor: 2.22.0(pg@8.23.0) + pg-pool: 3.14.0(pg@8.23.0) pg-types: 4.1.0 transitivePeerDependencies: - pg-native - '@effect/tsgo-darwin-arm64@0.19.0': + '@effect/tsgo-darwin-arm64@0.41.0': optional: true - '@effect/tsgo-darwin-x64@0.19.0': + '@effect/tsgo-darwin-x64@0.41.0': optional: true - '@effect/tsgo-linux-arm64@0.19.0': + '@effect/tsgo-linux-arm64@0.41.0': optional: true - '@effect/tsgo-linux-arm@0.19.0': + '@effect/tsgo-linux-arm@0.41.0': optional: true - '@effect/tsgo-linux-x64@0.19.0': + '@effect/tsgo-linux-x64@0.41.0': optional: true - '@effect/tsgo-win32-arm64@0.19.0': + '@effect/tsgo-win32-arm64@0.41.0': optional: true - '@effect/tsgo-win32-x64@0.19.0': + '@effect/tsgo-win32-x64@0.41.0': optional: true - '@effect/tsgo@0.19.0': + '@effect/tsgo@0.41.0': optionalDependencies: - '@effect/tsgo-darwin-arm64': 0.19.0 - '@effect/tsgo-darwin-x64': 0.19.0 - '@effect/tsgo-linux-arm': 0.19.0 - '@effect/tsgo-linux-arm64': 0.19.0 - '@effect/tsgo-linux-x64': 0.19.0 - '@effect/tsgo-win32-arm64': 0.19.0 - '@effect/tsgo-win32-x64': 0.19.0 + '@effect/tsgo-darwin-arm64': 0.41.0 + '@effect/tsgo-darwin-x64': 0.41.0 + '@effect/tsgo-linux-arm': 0.41.0 + '@effect/tsgo-linux-arm64': 0.41.0 + '@effect/tsgo-linux-x64': 0.41.0 + '@effect/tsgo-win32-arm64': 0.41.0 + '@effect/tsgo-win32-x64': 0.41.0 '@emnapi/core@1.11.2': dependencies: @@ -10096,190 +9574,251 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))': + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1': dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@2.1.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint/compat@2.1.1': dependencies: '@eslint/core': 1.2.1 - optionalDependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) - - '@eslint/config-array@0.21.2(supports-color@10.2.2)': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@10.2.2) - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 '@eslint/core@1.2.1': dependencies: @@ -10301,13 +9840,6 @@ snapshots: '@eslint/js@9.39.5': {} - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - '@fallow-cli/darwin-arm64@3.22.0': optional: true @@ -10354,25 +9886,9 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.5 + protobufjs: 7.6.6 yargs: 17.7.3 - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - '@iconify-json/mdi-light@1.2.2': dependencies: '@iconify/types': 2.0.0 @@ -10387,131 +9903,139 @@ snapshots: '@iconify/tailwind4@1.2.3(tailwindcss@4.3.3)': dependencies: - '@iconify/tools': 5.0.12 + '@iconify/tools': 5.0.14 '@iconify/types': 2.0.0 - '@iconify/utils': 3.1.4 + '@iconify/utils': 3.1.7 tailwindcss: 4.3.3 - '@iconify/tools@5.0.12': + '@iconify/tools@5.0.14': dependencies: '@cyberalien/svg-utils': 1.2.19 '@iconify/types': 2.0.0 - '@iconify/utils': 3.1.4 + '@iconify/utils': 3.1.7 fflate: 0.8.3 - modern-tar: 0.7.7 + modern-tar: 0.8.5 pathe: 2.0.3 - svgo: 4.0.2 + svgo: 4.1.0 '@iconify/types@2.0.0': {} - '@iconify/utils@3.1.4': + '@iconify/utils@3.1.7': dependencies: - '@antfu/install-pkg': 1.1.0 + '@antfu/install-pkg': 2.0.1 '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.1': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.1': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.1': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.1': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.1 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.1 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.2': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-win32-arm64@0.35.2': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-ia32@0.35.2': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-x64@0.35.2': optional: true '@internationalized/number@3.6.7': dependencies: '@swc/helpers': 0.5.23 - '@ioredis/commands@1.10.0': {} - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10525,28 +10049,28 @@ snapshots: dependencies: minipass: 7.1.3 - '@jest/pattern@30.4.0': + '@jest/pattern@30.5.0': dependencies: - '@types/node': 20.19.43 - jest-regex-util: 30.4.0 + '@types/node': 26.5.0 + jest-regex-util: 30.5.0 - '@jest/schemas@30.4.1': + '@jest/schemas@30.5.0': dependencies: '@sinclair/typebox': 0.34.52 - '@jest/types@30.4.1': + '@jest/types@30.5.1': dependencies: - '@jest/pattern': 30.4.0 - '@jest/schemas': 30.4.1 + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.43 + '@types/node': 26.5.0 '@types/yargs': 17.0.35 chalk: 4.1.2 '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -10561,17 +10085,17 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@js-sdsl/ordered-map@4.4.2': {} @@ -10603,59 +10127,59 @@ snapshots: dependencies: tslib: 2.8.1 - '@jsonjoy.com/fs-core@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-core@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-fsa@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-fsa@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-node-builtins@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-node-builtins@4.71.0(tslib@2.8.1)': dependencies: tslib: 2.8.1 - '@jsonjoy.com/fs-node-to-fsa@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-node-to-fsa@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-node-utils@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-node-utils@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) glob-to-regex.js: 1.2.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-node@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-node@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.71.0(tslib@2.8.1) glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-print@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-print@4.71.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-snapshot@4.64.0(tslib@2.8.1)': + '@jsonjoy.com/fs-snapshot@4.71.0(tslib@2.8.1)': dependencies: '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) tslib: 2.8.1 @@ -10668,7 +10192,7 @@ snapshots: '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -10680,7 +10204,7 @@ snapshots: '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -10728,7 +10252,7 @@ snapshots: node-fetch: 3.3.2 nopt: 8.1.0 semver: 7.8.5 - tar: 7.5.21 + tar: 7.5.22 transitivePeerDependencies: - supports-color @@ -10752,33 +10276,33 @@ snapshots: dependencies: '@modern-js/codesmith-utils': 2.6.9 '@swc/helpers': 0.5.1 - axios: 1.18.1(debug@4.3.7(supports-color@10.2.2))(supports-color@10.2.2) + axios: 1.20.0(debug@4.3.7(supports-color@10.2.2))(supports-color@10.2.2) debug: 4.3.7(supports-color@10.2.2) - tar: 7.5.21 + tar: 7.5.22 transitivePeerDependencies: - supports-color - '@module-federation/automatic-vendor-federation@1.2.1(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@module-federation/automatic-vendor-federation@1.2.1(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: find-package-json: 1.2.0 - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) - '@module-federation/bridge-react-webpack-plugin@2.8.0': + '@module-federation/bridge-react-webpack-plugin@2.9.0': dependencies: - '@module-federation/sdk': 2.8.0 + '@module-federation/sdk': 2.9.0 - '@module-federation/bridge-react@2.8.0(patch_hash=54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@module-federation/bridge-react@2.9.0(patch_hash=8c084f41790295af8fd015b897c6298bbc13d927b796c624ac96cb2bdb4bc87c)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - '@module-federation/sdk': 2.8.0 + '@module-federation/sdk': 2.9.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@module-federation/cli@2.8.0(typescript@7.0.2)': + '@module-federation/cli@2.9.0(typescript@7.0.2)': dependencies: - '@module-federation/dts-plugin': 2.8.0(typescript@7.0.2) - '@module-federation/sdk': 2.8.0 + '@module-federation/dts-plugin': 2.9.0(patch_hash=b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b)(typescript@7.0.2) + '@module-federation/sdk': 2.9.0 commander: 11.1.0 jiti: 2.4.2 transitivePeerDependencies: @@ -10787,82 +10311,80 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/dts-plugin@2.8.0(typescript@7.0.2)': + '@module-federation/dts-plugin@2.9.0(patch_hash=b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b)(typescript@7.0.2)': dependencies: - '@module-federation/error-codes': 2.8.0 - '@module-federation/managers': 2.8.0 - '@module-federation/sdk': 2.8.0 - '@module-federation/third-party-dts-extractor': 2.8.0 - adm-zip: 0.5.10 + '@module-federation/error-codes': 2.9.0 + '@module-federation/managers': 2.9.0 + '@module-federation/sdk': 2.9.0 + '@module-federation/third-party-dts-extractor': 2.9.0 + adm-zip: 0.6.0 isomorphic-ws: 5.0.0(ws@8.21.0) typescript: 7.0.2 - undici: 7.28.0 + undici: 7.29.0 ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@module-federation/enhanced@2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@module-federation/bridge-react-webpack-plugin': 2.8.0 - '@module-federation/cli': 2.8.0(typescript@7.0.2) - '@module-federation/dts-plugin': 2.8.0(typescript@7.0.2) - '@module-federation/error-codes': 2.8.0 - '@module-federation/inject-external-runtime-core-plugin': 2.8.0(@module-federation/runtime-tools@2.8.0) - '@module-federation/managers': 2.8.0 - '@module-federation/manifest': 2.8.0(typescript@7.0.2) - '@module-federation/rspack': 2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2) - '@module-federation/runtime-tools': 2.8.0 - '@module-federation/sdk': 2.8.0 - '@module-federation/webpack-bundler-runtime': 2.8.0 + '@module-federation/enhanced@2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': + dependencies: + '@module-federation/bridge-react-webpack-plugin': 2.9.0 + '@module-federation/cli': 2.9.0(typescript@7.0.2) + '@module-federation/dts-plugin': 2.9.0(patch_hash=b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b)(typescript@7.0.2) + '@module-federation/error-codes': 2.9.0 + '@module-federation/inject-external-runtime-core-plugin': 2.9.0(@module-federation/runtime-tools@2.9.0) + '@module-federation/managers': 2.9.0 + '@module-federation/manifest': 2.9.0(typescript@7.0.2) + '@module-federation/rspack': 2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2) + '@module-federation/runtime-tools': 2.9.0 + '@module-federation/sdk': 2.9.0 + '@module-federation/webpack-bundler-runtime': 2.9.0 schema-utils: 4.3.0 tapable: 2.3.0 optionalDependencies: typescript: 7.0.2 - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) transitivePeerDependencies: - '@rspack/core' - bufferutil - utf-8-validate - '@module-federation/error-codes@2.8.0': {} + '@module-federation/error-codes@2.9.0': {} - '@module-federation/error-codes@2.8.2': {} - - '@module-federation/inject-external-runtime-core-plugin@2.8.0(@module-federation/runtime-tools@2.8.0)': + '@module-federation/inject-external-runtime-core-plugin@2.9.0(@module-federation/runtime-tools@2.9.0)': dependencies: - '@module-federation/runtime-tools': 2.8.0 + '@module-federation/runtime-tools': 2.9.0 - '@module-federation/managers@2.8.0': + '@module-federation/managers@2.9.0': dependencies: - '@module-federation/sdk': 2.8.0 + '@module-federation/sdk': 2.9.0 - '@module-federation/manifest@2.8.0(typescript@7.0.2)': + '@module-federation/manifest@2.9.0(typescript@7.0.2)': dependencies: - '@module-federation/dts-plugin': 2.8.0(typescript@7.0.2) - '@module-federation/managers': 2.8.0 - '@module-federation/sdk': 2.8.0 + '@module-federation/dts-plugin': 2.9.0(patch_hash=b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b)(typescript@7.0.2) + '@module-federation/managers': 2.9.0 + '@module-federation/sdk': 2.9.0 transitivePeerDependencies: - bufferutil - typescript - utf-8-validate - vue-tsc - '@module-federation/modern-js-v3@2.8.0(patch_hash=56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3)(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@module-federation/modern-js-v3@2.9.0(patch_hash=ba5049c43645a4337e1a74857b2dcef5f5330692cc5d2186bcef236dc122dae8)(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@module-federation/bridge-react': 2.8.0(patch_hash=54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) - '@module-federation/cli': 2.8.0(typescript@7.0.2) - '@module-federation/enhanced': 2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/node': 2.7.47(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/rsbuild-plugin': 2.8.0(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/runtime': 2.8.0 - '@module-federation/sdk': 2.8.0 + '@module-federation/bridge-react': 2.9.0(patch_hash=8c084f41790295af8fd015b897c6298bbc13d927b796c624ac96cb2bdb4bc87c)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@module-federation/cli': 2.9.0(typescript@7.0.2) + '@module-federation/enhanced': 2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/node': 2.7.50(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/rsbuild-plugin': 2.9.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/runtime': 2.9.0 + '@module-federation/sdk': 2.9.0 '@swc/helpers': 0.5.17 jiti: 2.4.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) typescript: 7.0.2 transitivePeerDependencies: - '@rsbuild/core' @@ -10871,16 +10393,16 @@ snapshots: - utf-8-validate - webpack - '@module-federation/node@2.7.47(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@module-federation/node@2.7.50(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@module-federation/enhanced': 2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/runtime': 2.8.0 - '@module-federation/sdk': 2.8.0 + '@module-federation/enhanced': 2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/runtime': 2.9.0 + '@module-federation/sdk': 2.9.0 encoding: 0.1.13 node-fetch: 3.3.2 tapable: 2.3.0 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10888,13 +10410,13 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rsbuild-plugin@2.8.0(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@module-federation/rsbuild-plugin@2.9.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@module-federation/enhanced': 2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/node': 2.7.47(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/sdk': 2.8.0 + '@module-federation/enhanced': 2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/node': 2.7.50(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + '@module-federation/sdk': 2.9.0 optionalDependencies: - '@rsbuild/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10903,60 +10425,47 @@ snapshots: - vue-tsc - webpack - '@module-federation/rspack@2.8.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(typescript@7.0.2)': + '@module-federation/rspack@2.9.0(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(typescript@7.0.2)': dependencies: - '@module-federation/bridge-react-webpack-plugin': 2.8.0 - '@module-federation/dts-plugin': 2.8.0(typescript@7.0.2) - '@module-federation/inject-external-runtime-core-plugin': 2.8.0(@module-federation/runtime-tools@2.8.0) - '@module-federation/managers': 2.8.0 - '@module-federation/manifest': 2.8.0(typescript@7.0.2) - '@module-federation/runtime-tools': 2.8.0 - '@module-federation/sdk': 2.8.0 - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) + '@module-federation/bridge-react-webpack-plugin': 2.9.0 + '@module-federation/dts-plugin': 2.9.0(patch_hash=b4c8e1b74e7eea711fb133800780765eaae2b10e6ab34daeeda18b882558fd9b)(typescript@7.0.2) + '@module-federation/inject-external-runtime-core-plugin': 2.9.0(@module-federation/runtime-tools@2.9.0) + '@module-federation/managers': 2.9.0 + '@module-federation/manifest': 2.9.0(typescript@7.0.2) + '@module-federation/runtime-tools': 2.9.0 + '@module-federation/sdk': 2.9.0 + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: - bufferutil - utf-8-validate - '@module-federation/runtime-core@2.8.0': - dependencies: - '@module-federation/error-codes': 2.8.0 - '@module-federation/sdk': 2.8.0 - - '@module-federation/runtime-core@2.8.2': + '@module-federation/runtime-core@2.9.0(patch_hash=b241be221397f0e07dbe6c515725e12eaf3b418469bb7dd21750e6e4b215dd8d)': dependencies: - '@module-federation/error-codes': 2.8.2 - '@module-federation/sdk': 2.8.2 + '@module-federation/error-codes': 2.9.0 + '@module-federation/sdk': 2.9.0 - '@module-federation/runtime-tools@2.8.0': + '@module-federation/runtime-tools@2.9.0': dependencies: - '@module-federation/runtime': 2.8.0 - '@module-federation/webpack-bundler-runtime': 2.8.0 + '@module-federation/runtime': 2.9.0 + '@module-federation/webpack-bundler-runtime': 2.9.0 - '@module-federation/runtime@2.8.0': + '@module-federation/runtime@2.9.0': dependencies: - '@module-federation/error-codes': 2.8.0 - '@module-federation/runtime-core': 2.8.0 - '@module-federation/sdk': 2.8.0 - - '@module-federation/runtime@2.8.2': - dependencies: - '@module-federation/error-codes': 2.8.2 - '@module-federation/runtime-core': 2.8.2 - '@module-federation/sdk': 2.8.2 - - '@module-federation/sdk@2.8.0': {} + '@module-federation/error-codes': 2.9.0 + '@module-federation/runtime-core': 2.9.0(patch_hash=b241be221397f0e07dbe6c515725e12eaf3b418469bb7dd21750e6e4b215dd8d) + '@module-federation/sdk': 2.9.0 - '@module-federation/sdk@2.8.2': {} + '@module-federation/sdk@2.9.0': {} - '@module-federation/third-party-dts-extractor@2.8.0': {} + '@module-federation/third-party-dts-extractor@2.9.0': {} - '@module-federation/webpack-bundler-runtime@2.8.0': + '@module-federation/webpack-bundler-runtime@2.9.0': dependencies: - '@module-federation/error-codes': 2.8.0 - '@module-federation/runtime': 2.8.0 - '@module-federation/sdk': 2.8.0 + '@module-federation/error-codes': 2.9.0 + '@module-federation/runtime': 2.9.0 + '@module-federation/sdk': 2.9.0 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -10976,13 +10485,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -10990,11 +10492,16 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nkzw/eslint-plugin@2.0.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true - '@noble/ciphers@2.2.0': {} + '@nkzw/eslint-plugin@2.0.0': {} + + '@noble/ciphers@2.4.0': {} '@noble/hashes@2.2.0': {} @@ -11008,69 +10515,69 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + fastq: 1.20.3 - '@opentelemetry/api-logs@0.220.0': + '@opentelemetry/api-logs@0.222.0': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api@1.9.1': {} - '@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/context-async-hooks@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-trace-web@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions@1.43.0': {} @@ -11245,7 +10752,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': @@ -11254,118 +10761,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@oxfmt/binding-android-arm-eabi@0.63.0': - optional: true - - '@oxfmt/binding-android-arm-eabi@0.64.0': - optional: true - - '@oxfmt/binding-android-arm64@0.63.0': - optional: true - - '@oxfmt/binding-android-arm64@0.64.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.63.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.64.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.63.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.64.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.63.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.64.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.63.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.64.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.63.0': + '@oxfmt/binding-android-arm-eabi@0.66.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.64.0': + '@oxfmt/binding-android-arm64@0.66.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.63.0': + '@oxfmt/binding-darwin-arm64@0.66.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.64.0': + '@oxfmt/binding-darwin-x64@0.66.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.63.0': + '@oxfmt/binding-freebsd-x64@0.66.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.64.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.63.0': + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.64.0': + '@oxfmt/binding-linux-arm64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.63.0': + '@oxfmt/binding-linux-arm64-musl@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.64.0': + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.63.0': + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.64.0': + '@oxfmt/binding-linux-riscv64-musl@0.66.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.63.0': + '@oxfmt/binding-linux-s390x-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.64.0': + '@oxfmt/binding-linux-x64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.63.0': + '@oxfmt/binding-linux-x64-musl@0.66.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.64.0': + '@oxfmt/binding-openharmony-arm64@0.66.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.63.0': + '@oxfmt/binding-win32-arm64-msvc@0.66.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.64.0': + '@oxfmt/binding-win32-ia32-msvc@0.66.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.63.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.64.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.63.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.64.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.63.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.64.0': + '@oxfmt/binding-win32-x64-msvc@0.66.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2001': @@ -11386,121 +10836,64 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.78.0': - optional: true - - '@oxlint/binding-android-arm-eabi@1.79.0': - optional: true - - '@oxlint/binding-android-arm64@1.78.0': + '@oxlint/binding-android-arm-eabi@1.81.0': optional: true - '@oxlint/binding-android-arm64@1.79.0': + '@oxlint/binding-android-arm64@1.81.0': optional: true - '@oxlint/binding-darwin-arm64@1.78.0': + '@oxlint/binding-darwin-arm64@1.81.0': optional: true - '@oxlint/binding-darwin-arm64@1.79.0': + '@oxlint/binding-darwin-x64@1.81.0': optional: true - '@oxlint/binding-darwin-x64@1.78.0': + '@oxlint/binding-freebsd-x64@1.81.0': optional: true - '@oxlint/binding-darwin-x64@1.79.0': + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': optional: true - '@oxlint/binding-freebsd-x64@1.78.0': + '@oxlint/binding-linux-arm-musleabihf@1.81.0': optional: true - '@oxlint/binding-freebsd-x64@1.79.0': + '@oxlint/binding-linux-arm64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + '@oxlint/binding-linux-arm64-musl@1.81.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + '@oxlint/binding-linux-ppc64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.78.0': + '@oxlint/binding-linux-riscv64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.79.0': + '@oxlint/binding-linux-riscv64-musl@1.81.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.78.0': + '@oxlint/binding-linux-s390x-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.79.0': + '@oxlint/binding-linux-x64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.78.0': + '@oxlint/binding-linux-x64-musl@1.81.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.79.0': + '@oxlint/binding-openharmony-arm64@1.81.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.78.0': + '@oxlint/binding-win32-arm64-msvc@1.81.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.79.0': + '@oxlint/binding-win32-ia32-msvc@1.81.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.78.0': + '@oxlint/binding-win32-x64-msvc@1.81.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.79.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.78.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.79.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.78.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.79.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.78.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.79.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.78.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.79.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.78.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.79.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.78.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.79.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.78.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.79.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.78.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.79.0': - optional: true - - '@oxlint/plugins@1.79.0': {} + '@oxlint/plugins@1.81.0': {} '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -11606,59 +10999,77 @@ snapshots: '@protobufjs/utf8@1.1.2': {} + '@redis/bloom@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + + '@redis/client@6.2.1(@opentelemetry/api@1.9.1)': + dependencies: + cluster-key-slot: 1.1.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + '@redis/json@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + + '@redis/search@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + + '@redis/time-series@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@rollup/pluginutils@5.4.0': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.7 - '@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)': + '@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)': dependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 optionalDependencies: - core-js: 3.49.0 + core-js: 3.50.0 transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)': - dependencies: - '@rspack/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - '@swc/helpers': 0.5.23 + '@rsbuild/plugin-assets-retry@2.0.2(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': optionalDependencies: - core-js: 3.49.0 - transitivePeerDependencies: - - '@module-federation/runtime-tools' + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsbuild/plugin-assets-retry@2.0.2(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': - optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - - '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-check-syntax@2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': dependencies: - acorn: 8.17.0 + acorn: 8.18.0 browserslist-to-es-version: 1.4.2 - htmlparser2: 10.0.0 - picocolors: 1.1.1 + htmlparser2: 12.0.0 source-map: 0.7.6 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsbuild/plugin-check-syntax@2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-css-minimizer@2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - acorn: 8.17.0 - browserslist-to-es-version: 1.4.2 - htmlparser2: 12.0.0 - source-map: 0.7.6 + css-minimizer-webpack-plugin: 8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) + reduce-configs: 1.1.2 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + transitivePeerDependencies: + - '@parcel/css' + - '@swc/css' + - clean-css + - csso + - esbuild + - lightningcss + - webpack - '@rsbuild/plugin-css-minimizer@2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@rsbuild/plugin-css-minimizer@2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - css-minimizer-webpack-plugin: 8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + css-minimizer-webpack-plugin: 8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) reduce-configs: 1.1.2 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@parcel/css' - '@swc/css' @@ -11668,405 +11079,180 @@ snapshots: - lightningcss - webpack - '@rsbuild/plugin-less@2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@rsbuild/plugin-less@2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: deepmerge: 4.3.1 - less: 4.7.0(supports-color@10.2.2) - less-loader: 12.3.3(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(less@4.7.0(supports-color@10.2.2))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + less: 4.9.1(supports-color@10.2.2) + less-loader: 12.3.3(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(less@4.9.1(supports-color@10.2.2))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) reduce-configs: 2.0.1 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@rspack/core' - supports-color - webpack - '@rsbuild/plugin-react@2.1.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))': + '@rsbuild/plugin-react@2.1.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))': dependencies: - '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-refresh@0.18.0) + '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(react-refresh@0.18.0) react-refresh: 0.18.0 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@rspack/core' - '@rsbuild/plugin-rem@1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-rem@1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': dependencies: deepmerge: 4.3.1 - terser: 5.49.0 + terser: 5.51.2 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsbuild/plugin-sass@2.0.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-sass@2.0.1(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': dependencies: deepmerge: 4.3.1 loader-utils: 2.0.4 - postcss: 8.5.26 + postcss: 8.5.28 reduce-configs: 2.0.1 sass-embedded: 1.100.0 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsbuild/plugin-source-build@1.0.6(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-source-build@1.0.6(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': dependencies: fast-glob: 3.3.3 json5: 2.2.3 yaml: 2.9.0 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsbuild/plugin-svgr@2.0.5(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2)': + '@rsbuild/plugin-svgr@2.0.5(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(typescript@7.0.2)': dependencies: - '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)) + '@rsbuild/plugin-react': 2.1.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)) '@svgr/core': 8.1.0(supports-color@10.2.2)(typescript@7.0.2) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2) '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(supports-color@10.2.2)(typescript@7.0.2))(typescript@7.0.2) deepmerge: 4.3.1 loader-utils: 3.3.1 optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@rspack/core' - supports-color - typescript - '@rsbuild/plugin-tailwindcss@2.0.3(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@rsbuild/plugin-tailwindcss@2.0.3(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@tailwindcss/webpack': 4.3.3(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + '@tailwindcss/webpack': 4.3.3(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) optionalDependencies: - '@rsbuild/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) transitivePeerDependencies: - '@rspack/core' - webpack - '@rsbuild/plugin-type-check@1.6.0(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(tslib@2.8.1)(typescript@7.0.2)': + '@rsbuild/plugin-type-check@1.6.0(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(typescript@7.0.2)': dependencies: deepmerge: 4.3.1 json5: 2.2.3 reduce-configs: 1.1.2 - ts-checker-rspack-plugin: 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(tslib@2.8.1)(typescript@7.0.2) + ts-checker-rspack-plugin: 1.6.1(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(typescript@7.0.2) optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) '@typescript/native-preview': 7.0.0-dev.20260707.2 transitivePeerDependencies: - '@rspack/core' - - tslib - typescript - '@rsbuild/plugin-typed-css-modules@1.2.4(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))': + '@rsbuild/plugin-typed-css-modules@1.2.4(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))': optionalDependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - - '@rsdoctor/client@1.6.1': {} - - '@rsdoctor/core@1.6.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)) - '@rsdoctor/graph': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/sdk': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/types': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/utils': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rspack/resolver': 0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - browserslist-load-config: 1.0.3 - es-toolkit: 1.49.0 - filesize: 11.0.22 - fs-extra: 11.3.6 - semver: 7.8.5 - source-map: 0.7.6 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - '@rsbuild/core' - - '@rspack/core' - - bufferutil - - supports-color - - utf-8-validate - - webpack - - '@rsdoctor/graph@1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@rsdoctor/types': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/utils': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - es-toolkit: 1.49.0 - path-browserify: 1.0.1 - source-map: 0.7.6 - transitivePeerDependencies: - - '@rspack/core' - - webpack + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) - '@rsdoctor/rspack-plugin@1.6.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@rsdoctor/core': 1.6.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/graph': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/sdk': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/types': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/utils': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - '@rsbuild/core' - - bufferutil - - supports-color - - utf-8-validate - - webpack - - '@rsdoctor/sdk@1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@rsdoctor/client': 1.6.1 - '@rsdoctor/graph': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/types': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@rsdoctor/utils': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - launch-editor: 2.14.1 - safer-buffer: 2.1.2 - socket.io: 4.8.1(supports-color@10.2.2) - tapable: 2.3.3 - transitivePeerDependencies: - - '@rspack/core' - - bufferutil - - supports-color - - utf-8-validate - - webpack - - '@rsdoctor/types@1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@types/connect': 3.4.38 - '@types/estree': 1.0.5 - '@types/tapable': 2.3.0 - source-map: 0.7.6 - optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) - - '@rsdoctor/utils@1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': - dependencies: - '@babel/code-frame': 7.26.2 - '@rsdoctor/types': 1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@types/estree': 1.0.5 - acorn: 8.17.0 - acorn-import-attributes: 1.9.5(acorn@8.17.0) - acorn-walk: 8.3.5 - deep-eql: 4.1.4 - envinfo: 7.21.0 - fs-extra: 11.3.6 - get-port: 5.1.1 - json-stream-stringify: 3.0.1 - lines-and-columns: 2.0.4 - picocolors: 1.1.1 - rslog: 2.3.0 - strip-ansi: 7.2.0 - transitivePeerDependencies: - - '@rspack/core' - - webpack - - '@rspack/binding-darwin-arm64@2.2.0': - optional: true - - '@rspack/binding-darwin-arm64@2.2.0-rc.0': - optional: true - - '@rspack/binding-darwin-x64@2.2.0': + '@rspack/binding-darwin-arm64@2.2.3': optional: true - '@rspack/binding-darwin-x64@2.2.0-rc.0': + '@rspack/binding-darwin-x64@2.2.3': optional: true - '@rspack/binding-linux-arm64-gnu@2.2.0': + '@rspack/binding-linux-arm64-gnu@2.2.3': optional: true - '@rspack/binding-linux-arm64-gnu@2.2.0-rc.0': + '@rspack/binding-linux-arm64-musl@2.2.3': optional: true - '@rspack/binding-linux-arm64-musl@2.2.0': + '@rspack/binding-linux-ppc64-gnu@2.2.3': optional: true - '@rspack/binding-linux-arm64-musl@2.2.0-rc.0': + '@rspack/binding-linux-riscv64-gnu@2.2.3': optional: true - '@rspack/binding-linux-ppc64-gnu@2.2.0': + '@rspack/binding-linux-riscv64-musl@2.2.3': optional: true - '@rspack/binding-linux-ppc64-gnu@2.2.0-rc.0': + '@rspack/binding-linux-s390x-gnu@2.2.3': optional: true - '@rspack/binding-linux-riscv64-gnu@2.2.0': + '@rspack/binding-linux-x64-gnu@2.2.3': optional: true - '@rspack/binding-linux-riscv64-gnu@2.2.0-rc.0': + '@rspack/binding-linux-x64-musl@2.2.3': optional: true - '@rspack/binding-linux-riscv64-musl@2.2.0': - optional: true - - '@rspack/binding-linux-riscv64-musl@2.2.0-rc.0': - optional: true - - '@rspack/binding-linux-s390x-gnu@2.2.0': - optional: true - - '@rspack/binding-linux-s390x-gnu@2.2.0-rc.0': - optional: true - - '@rspack/binding-linux-x64-gnu@2.2.0': - optional: true - - '@rspack/binding-linux-x64-gnu@2.2.0-rc.0': - optional: true - - '@rspack/binding-linux-x64-musl@2.2.0': - optional: true - - '@rspack/binding-linux-x64-musl@2.2.0-rc.0': - optional: true - - '@rspack/binding-wasm32-wasi@2.2.0': + '@rspack/binding-wasm32-wasi@2.2.3': dependencies: '@emnapi/core': 1.11.3 '@emnapi/runtime': 1.11.3 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true - '@rspack/binding-wasm32-wasi@2.2.0-rc.0': - dependencies: - '@emnapi/core': 1.11.3 - '@emnapi/runtime': 1.11.3 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - optional: true - - '@rspack/binding-win32-arm64-msvc@2.2.0': - optional: true - - '@rspack/binding-win32-arm64-msvc@2.2.0-rc.0': - optional: true - - '@rspack/binding-win32-ia32-msvc@2.2.0': + '@rspack/binding-win32-arm64-msvc@2.2.3': optional: true - '@rspack/binding-win32-ia32-msvc@2.2.0-rc.0': + '@rspack/binding-win32-ia32-msvc@2.2.3': optional: true - '@rspack/binding-win32-x64-msvc@2.2.0': + '@rspack/binding-win32-x64-msvc@2.2.3': optional: true - '@rspack/binding-win32-x64-msvc@2.2.0-rc.0': - optional: true - - '@rspack/binding@2.2.0': - optionalDependencies: - '@rspack/binding-darwin-arm64': 2.2.0 - '@rspack/binding-darwin-x64': 2.2.0 - '@rspack/binding-linux-arm64-gnu': 2.2.0 - '@rspack/binding-linux-arm64-musl': 2.2.0 - '@rspack/binding-linux-ppc64-gnu': 2.2.0 - '@rspack/binding-linux-riscv64-gnu': 2.2.0 - '@rspack/binding-linux-riscv64-musl': 2.2.0 - '@rspack/binding-linux-s390x-gnu': 2.2.0 - '@rspack/binding-linux-x64-gnu': 2.2.0 - '@rspack/binding-linux-x64-musl': 2.2.0 - '@rspack/binding-wasm32-wasi': 2.2.0 - '@rspack/binding-win32-arm64-msvc': 2.2.0 - '@rspack/binding-win32-ia32-msvc': 2.2.0 - '@rspack/binding-win32-x64-msvc': 2.2.0 - - '@rspack/binding@2.2.0-rc.0': - optionalDependencies: - '@rspack/binding-darwin-arm64': 2.2.0-rc.0 - '@rspack/binding-darwin-x64': 2.2.0-rc.0 - '@rspack/binding-linux-arm64-gnu': 2.2.0-rc.0 - '@rspack/binding-linux-arm64-musl': 2.2.0-rc.0 - '@rspack/binding-linux-ppc64-gnu': 2.2.0-rc.0 - '@rspack/binding-linux-riscv64-gnu': 2.2.0-rc.0 - '@rspack/binding-linux-riscv64-musl': 2.2.0-rc.0 - '@rspack/binding-linux-s390x-gnu': 2.2.0-rc.0 - '@rspack/binding-linux-x64-gnu': 2.2.0-rc.0 - '@rspack/binding-linux-x64-musl': 2.2.0-rc.0 - '@rspack/binding-wasm32-wasi': 2.2.0-rc.0 - '@rspack/binding-win32-arm64-msvc': 2.2.0-rc.0 - '@rspack/binding-win32-ia32-msvc': 2.2.0-rc.0 - '@rspack/binding-win32-x64-msvc': 2.2.0-rc.0 - - '@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.2.0 + '@rspack/binding@2.2.3': optionalDependencies: - '@module-federation/runtime-tools': 2.8.0 - '@swc/helpers': 0.5.23 - - '@rspack/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.2.0-rc.0 + '@rspack/binding-darwin-arm64': 2.2.3 + '@rspack/binding-darwin-x64': 2.2.3 + '@rspack/binding-linux-arm64-gnu': 2.2.3 + '@rspack/binding-linux-arm64-musl': 2.2.3 + '@rspack/binding-linux-ppc64-gnu': 2.2.3 + '@rspack/binding-linux-riscv64-gnu': 2.2.3 + '@rspack/binding-linux-riscv64-musl': 2.2.3 + '@rspack/binding-linux-s390x-gnu': 2.2.3 + '@rspack/binding-linux-x64-gnu': 2.2.3 + '@rspack/binding-linux-x64-musl': 2.2.3 + '@rspack/binding-wasm32-wasi': 2.2.3 + '@rspack/binding-win32-arm64-msvc': 2.2.3 + '@rspack/binding-win32-ia32-msvc': 2.2.3 + '@rspack/binding-win32-x64-msvc': 2.2.3 + + '@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.3 optionalDependencies: - '@module-federation/runtime-tools': 2.8.0 + '@module-federation/runtime-tools': 2.9.0 '@swc/helpers': 0.5.23 - '@rspack/lite-tapable@1.1.2': {} + '@rspack/lite-tapable@1.1.5': {} - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - - '@rspack/resolver-binding-darwin-arm64@0.2.8': - optional: true - - '@rspack/resolver-binding-darwin-x64@0.2.8': - optional: true - - '@rspack/resolver-binding-linux-arm64-gnu@0.2.8': - optional: true - - '@rspack/resolver-binding-linux-arm64-musl@0.2.8': - optional: true - - '@rspack/resolver-binding-linux-x64-gnu@0.2.8': - optional: true - - '@rspack/resolver-binding-linux-x64-musl@0.2.8': - optional: true - - '@rspack/resolver-binding-wasm32-wasi@0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rspack/resolver-binding-win32-arm64-msvc@0.2.8': - optional: true - - '@rspack/resolver-binding-win32-ia32-msvc@0.2.8': - optional: true - - '@rspack/resolver-binding-win32-x64-msvc@0.2.8': - optional: true - - '@rspack/resolver@0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': - optionalDependencies: - '@rspack/resolver-binding-darwin-arm64': 0.2.8 - '@rspack/resolver-binding-darwin-x64': 0.2.8 - '@rspack/resolver-binding-linux-arm64-gnu': 0.2.8 - '@rspack/resolver-binding-linux-arm64-musl': 0.2.8 - '@rspack/resolver-binding-linux-x64-gnu': 0.2.8 - '@rspack/resolver-binding-linux-x64-musl': 0.2.8 - '@rspack/resolver-binding-wasm32-wasi': 0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - '@rspack/resolver-binding-win32-arm64-msvc': 0.2.8 - '@rspack/resolver-binding-win32-ia32-msvc': 0.2.8 - '@rspack/resolver-binding-win32-x64-msvc': 0.2.8 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) - '@rstest/adapter-rsbuild@0.11.9(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))': + '@rstest/adapter-rsbuild@0.11.12(@rsbuild/core@2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0))(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))': dependencies: - '@rsbuild/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - '@rstest/core': 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) + '@rstest/core': 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) - '@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3)': + '@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3)': dependencies: - '@rsbuild/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) + '@rsbuild/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0) '@types/chai': 5.2.3 optionalDependencies: happy-dom: 20.8.3 @@ -12086,9 +11272,7 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@socket.io/component-emitter@3.1.2': {} - - '@speed-highlight/core@1.2.17': {} + '@speed-highlight/core@1.2.24': {} '@standard-schema/spec@1.1.0': {} @@ -12149,7 +11333,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)': @@ -12158,7 +11342,7 @@ snapshots: '@svgr/babel-preset': 8.1.0(@babel/core@7.29.7(supports-color@10.2.2)) '@svgr/core': 8.1.0(supports-color@10.2.2)(typescript@7.0.2) '@svgr/hast-util-to-babel-ast': 8.0.0 - svg-parser: 2.0.4 + svg-parser: 2.1.0 transitivePeerDependencies: - supports-color @@ -12167,63 +11351,63 @@ snapshots: '@svgr/core': 8.1.0(supports-color@10.2.2)(typescript@7.0.2) cosmiconfig: 8.3.6(typescript@7.0.2) deepmerge: 4.3.1 - svgo: 3.3.4 + svgo: 3.3.5 transitivePeerDependencies: - typescript - '@swc/core-darwin-arm64@1.15.43': + '@swc/core-darwin-arm64@1.16.2': optional: true - '@swc/core-darwin-x64@1.15.43': + '@swc/core-darwin-x64@1.16.2': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.43': + '@swc/core-linux-arm-gnueabihf@1.16.2': optional: true - '@swc/core-linux-arm64-gnu@1.15.43': + '@swc/core-linux-arm64-gnu@1.16.2': optional: true - '@swc/core-linux-arm64-musl@1.15.43': + '@swc/core-linux-arm64-musl@1.16.2': optional: true - '@swc/core-linux-ppc64-gnu@1.15.43': + '@swc/core-linux-ppc64-gnu@1.16.2': optional: true - '@swc/core-linux-s390x-gnu@1.15.43': + '@swc/core-linux-s390x-gnu@1.16.2': optional: true - '@swc/core-linux-x64-gnu@1.15.43': + '@swc/core-linux-x64-gnu@1.16.2': optional: true - '@swc/core-linux-x64-musl@1.15.43': + '@swc/core-linux-x64-musl@1.16.2': optional: true - '@swc/core-win32-arm64-msvc@1.15.43': + '@swc/core-win32-arm64-msvc@1.16.2': optional: true - '@swc/core-win32-ia32-msvc@1.15.43': + '@swc/core-win32-ia32-msvc@1.16.2': optional: true - '@swc/core-win32-x64-msvc@1.15.43': + '@swc/core-win32-x64-msvc@1.16.2': optional: true - '@swc/core@1.15.43(@swc/helpers@0.5.23)': + '@swc/core@1.16.2(@swc/helpers@0.5.23)': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.27 + '@swc/types': 0.1.28 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.43 - '@swc/core-darwin-x64': 1.15.43 - '@swc/core-linux-arm-gnueabihf': 1.15.43 - '@swc/core-linux-arm64-gnu': 1.15.43 - '@swc/core-linux-arm64-musl': 1.15.43 - '@swc/core-linux-ppc64-gnu': 1.15.43 - '@swc/core-linux-s390x-gnu': 1.15.43 - '@swc/core-linux-x64-gnu': 1.15.43 - '@swc/core-linux-x64-musl': 1.15.43 - '@swc/core-win32-arm64-msvc': 1.15.43 - '@swc/core-win32-ia32-msvc': 1.15.43 - '@swc/core-win32-x64-msvc': 1.15.43 + '@swc/core-darwin-arm64': 1.16.2 + '@swc/core-darwin-x64': 1.16.2 + '@swc/core-linux-arm-gnueabihf': 1.16.2 + '@swc/core-linux-arm64-gnu': 1.16.2 + '@swc/core-linux-arm64-musl': 1.16.2 + '@swc/core-linux-ppc64-gnu': 1.16.2 + '@swc/core-linux-s390x-gnu': 1.16.2 + '@swc/core-linux-x64-gnu': 1.16.2 + '@swc/core-linux-x64-musl': 1.16.2 + '@swc/core-win32-arm64-msvc': 1.16.2 + '@swc/core-win32-ia32-msvc': 1.16.2 + '@swc/core-win32-x64-msvc': 1.16.2 '@swc/helpers': 0.5.23 '@swc/counter@0.1.3': {} @@ -12240,18 +11424,18 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/plugin-loadable-components@12.0.0': + '@swc/plugin-loadable-components@13.0.0': dependencies: '@swc/counter': 0.1.3 - '@swc/types@0.1.27': + '@swc/types@0.1.28': dependencies: '@swc/counter': 0.1.3 '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.24.3 + enhanced-resolve: 5.24.5 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 @@ -12309,24 +11493,24 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/webpack@4.3.3(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@tailwindcss/webpack@4.3.3(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0))': dependencies: - '@alloc/quick-lru': 5.2.0 + '@alloc/quick-lru': 5.3.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) - '@tanstack/history@1.162.1': {} + '@tanstack/history@1.162.2': {} - '@tanstack/react-router@1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/history': 1.162.1 + '@tanstack/history': 1.162.2 '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tanstack/router-core': 1.171.21(patch_hash=413c2453d06aa521ed65ab7fcfb16bac8700e58e97693c2ba4d40727d7c9790d) - isbot: 5.2.1 + '@tanstack/router-core': 1.171.28 + isbot: 5.2.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -12337,50 +11521,50 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - '@tanstack/router-core@1.171.21(patch_hash=413c2453d06aa521ed65ab7fcfb16bac8700e58e97693c2ba4d40727d7c9790d)': + '@tanstack/router-core@1.171.28': dependencies: - '@tanstack/history': 1.162.1 + '@tanstack/history': 1.162.2 cookie-es: 3.1.1 - seroval: 1.6.4 - seroval-plugins: 1.6.4(seroval@1.6.4) + seroval: 1.6.6 + seroval-plugins: 1.6.6(seroval@1.6.6) '@tanstack/store@0.9.3': {} - '@techsio/ui-kit@0.25.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)': + '@techsio/ui-kit@0.25.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)': dependencies: '@iconify-json/mdi': 1.2.3 '@iconify-json/mdi-light': 1.2.2 '@iconify-json/svg-spinners': 1.2.4 '@iconify/tailwind4': 1.2.3(tailwindcss@4.3.3) - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@zag-js/accordion': 1.42.0 - '@zag-js/carousel': 1.42.0 - '@zag-js/checkbox': 1.42.0 - '@zag-js/combobox': 1.42.0 - '@zag-js/dialog': 1.42.0 - '@zag-js/i18n-utils': 1.42.0 - '@zag-js/menu': 1.42.0 - '@zag-js/number-input': 1.42.0 - '@zag-js/pagination': 1.42.0 - '@zag-js/popover': 1.42.0 - '@zag-js/radio-group': 1.42.0 - '@zag-js/rating-group': 1.42.0 - '@zag-js/react': 1.42.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@zag-js/select': 1.42.0 - '@zag-js/slider': 1.42.0 - '@zag-js/steps': 1.42.0 - '@zag-js/switch': 1.42.0 - '@zag-js/tabs': 1.42.0 - '@zag-js/toast': 1.42.0 - '@zag-js/tooltip': 1.42.0 - '@zag-js/tree-view': 1.42.0 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@zag-js/accordion': 1.43.3 + '@zag-js/carousel': 1.43.3 + '@zag-js/checkbox': 1.43.3 + '@zag-js/combobox': 1.43.3 + '@zag-js/dialog': 1.43.3 + '@zag-js/i18n-utils': 1.43.3 + '@zag-js/menu': 1.43.3 + '@zag-js/number-input': 1.43.3 + '@zag-js/pagination': 1.43.3 + '@zag-js/popover': 1.43.3 + '@zag-js/radio-group': 1.43.3 + '@zag-js/rating-group': 1.43.3 + '@zag-js/react': 1.43.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@zag-js/select': 1.43.3 + '@zag-js/slider': 1.43.3 + '@zag-js/steps': 1.43.3 + '@zag-js/switch': 1.43.3 + '@zag-js/tabs': 1.43.3 + '@zag-js/toast': 1.43.3 + '@zag-js/tooltip': 1.43.3 + '@zag-js/tree-view': 1.43.3 better-themes: 1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - libphonenumber-js: 1.13.9 + libphonenumber-js: 1.13.12 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tailwind-merge: 3.6.0 - tailwind-variants: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + tailwind-variants: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3) tailwindcss-animate: 1.0.7(tailwindcss@4.3.3) transitivePeerDependencies: - tailwindcss @@ -12396,15 +11580,15 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -12424,20 +11608,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.19.43 - - '@types/cors@2.8.19': - dependencies: - '@types/node': 20.19.43 - '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.5': {} - '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -12458,15 +11632,11 @@ snapshots: '@types/loadable__component@5.13.10': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/node@20.19.43': + '@types/node@26.5.0': dependencies: - undici-types: 6.21.0 - - '@types/node@26.4.1': - dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/parse-path@7.1.0': dependencies: @@ -12474,31 +11644,27 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 20.19.43 - pg-protocol: 1.15.0 + '@types/node': 26.5.0 + pg-protocol: 1.16.0 pg-types: 2.2.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@types/react-helmet@6.1.11': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 - '@types/tapable@2.3.0': - dependencies: - tapable: 2.3.3 - '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 20.19.43 + '@types/node': 26.5.0 '@types/yargs-parser@21.0.3': {} @@ -12506,15 +11672,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/parser': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 ignore: 7.0.8 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -12522,56 +11687,54 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@10.2.2) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/project-service@8.70.0(supports-color@10.2.2)(typescript@7.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@7.0.2) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@7.0.2) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3(supports-color@10.2.2) typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.69.0': + '@typescript-eslint/scope-manager@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.69.0(typescript@7.0.2)': + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@7.0.2)': dependencies: typescript: 7.0.2 - '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) debug: 4.4.3(supports-color@10.2.2) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -12579,14 +11742,16 @@ snapshots: '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/types@8.70.0': {} + + '@typescript-eslint/typescript-estree@8.70.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@10.2.2) - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -12594,14 +11759,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.69.0(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/typescript-estree@8.70.0(supports-color@10.2.2)(typescript@7.0.2)': dependencies: - '@typescript-eslint/project-service': 8.69.0(supports-color@10.2.2)(typescript@7.0.2) - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@7.0.2) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.70.0(supports-color@10.2.2)(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@7.0.2) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@10.2.2) - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@7.0.2) @@ -12609,31 +11774,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@eslint-community/eslint-utils': 4.10.1 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/utils@8.70.0(supports-color@10.2.2)(typescript@7.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@7.0.2) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@eslint-community/eslint-utils': 4.10.1 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@10.2.2)(typescript@7.0.2) typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.69.0': + '@typescript-eslint/visitor-keys@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': @@ -12727,14 +11890,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@ungap/structured-clone@1.3.3': {} + '@ungap/structured-clone@1.4.0': {} '@vercel/nft@0.29.2(patch_hash=c0ed4897b98e9055716031187bb8ea16739f6ae0843d35e4873f1a177472cac7)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) '@rollup/pluginutils': 5.4.0 - acorn: 8.17.0 - acorn-import-attributes: 1.9.5(acorn@8.17.0) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 @@ -12861,267 +12024,267 @@ snapshots: '@xtuc/long@4.2.2': {} - '@zag-js/accordion@1.42.0': + '@zag-js/accordion@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/anatomy@1.42.0': {} + '@zag-js/anatomy@1.43.3': {} - '@zag-js/aria-hidden@1.42.0': + '@zag-js/aria-hidden@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/carousel@1.42.0': + '@zag-js/carousel@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/scroll-snap': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/scroll-snap': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/checkbox@1.42.0': + '@zag-js/checkbox@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/collection@1.42.0': + '@zag-js/collection@1.43.3': dependencies: - '@zag-js/utils': 1.42.0 + '@zag-js/utils': 1.43.3 - '@zag-js/combobox@1.42.0': + '@zag-js/combobox@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/collection': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/live-region': 1.42.0 - '@zag-js/popper': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/collection': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/live-region': 1.43.3 + '@zag-js/popper': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/core@1.42.0': + '@zag-js/core@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/dom-query': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/dialog@1.42.0': + '@zag-js/dialog@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/aria-hidden': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-trap': 1.42.0 - '@zag-js/remove-scroll': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/aria-hidden': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-trap': 1.43.3 + '@zag-js/remove-scroll': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/dismissable@1.42.0': + '@zag-js/dismissable@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 - '@zag-js/interact-outside': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/dom-query': 1.43.3 + '@zag-js/interact-outside': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/dom-query@1.42.0': + '@zag-js/dom-query@1.43.3': dependencies: - '@zag-js/types': 1.42.0 + '@zag-js/types': 1.43.3 - '@zag-js/focus-trap@1.42.0': + '@zag-js/focus-trap@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/focus-visible@1.42.0': + '@zag-js/focus-visible@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/i18n-utils@1.42.0': + '@zag-js/i18n-utils@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/interact-outside@1.42.0': + '@zag-js/interact-outside@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/dom-query': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/live-region@1.42.0': {} + '@zag-js/live-region@1.43.3': {} - '@zag-js/menu@1.42.0': + '@zag-js/menu@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/popper': 1.42.0 - '@zag-js/rect-utils': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/popper': 1.43.3 + '@zag-js/rect-utils': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/number-input@1.42.0': + '@zag-js/number-input@1.43.3': dependencies: '@internationalized/number': 3.6.7 - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 - - '@zag-js/pagination@1.42.0': - dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 - - '@zag-js/popover@1.42.0': - dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/aria-hidden': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-trap': 1.42.0 - '@zag-js/popper': 1.42.0 - '@zag-js/remove-scroll': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 - - '@zag-js/popper@1.42.0': + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 + + '@zag-js/pagination@1.43.3': + dependencies: + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 + + '@zag-js/popover@1.43.3': + dependencies: + '@zag-js/anatomy': 1.43.3 + '@zag-js/aria-hidden': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-trap': 1.43.3 + '@zag-js/popper': 1.43.3 + '@zag-js/remove-scroll': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 + + '@zag-js/popper@1.43.3': dependencies: '@floating-ui/dom': 1.8.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/dom-query': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/radio-group@1.42.0': + '@zag-js/radio-group@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/rating-group@1.42.0': + '@zag-js/rating-group@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/react@1.42.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@zag-js/react@1.43.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@zag-js/core': 1.42.0 - '@zag-js/store': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/core': 1.43.3 + '@zag-js/store': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@zag-js/rect-utils@1.42.0': {} + '@zag-js/rect-utils@1.43.3': {} - '@zag-js/remove-scroll@1.42.0': + '@zag-js/remove-scroll@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/scroll-snap@1.42.0': + '@zag-js/scroll-snap@1.43.3': dependencies: - '@zag-js/dom-query': 1.42.0 + '@zag-js/dom-query': 1.43.3 - '@zag-js/select@1.42.0': + '@zag-js/select@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/collection': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/popper': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/collection': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/popper': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/slider@1.42.0': + '@zag-js/slider@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/steps@1.42.0': + '@zag-js/steps@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/store@1.42.0': + '@zag-js/store@1.43.3': dependencies: proxy-compare: 3.0.1 - '@zag-js/switch@1.42.0': + '@zag-js/switch@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/tabs@1.42.0': + '@zag-js/tabs@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/toast@1.42.0': + '@zag-js/toast@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dismissable': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dismissable': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/tooltip@1.42.0': + '@zag-js/tooltip@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/focus-visible': 1.42.0 - '@zag-js/popper': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/focus-visible': 1.43.3 + '@zag-js/popper': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/tree-view@1.42.0': + '@zag-js/tree-view@1.43.3': dependencies: - '@zag-js/anatomy': 1.42.0 - '@zag-js/collection': 1.42.0 - '@zag-js/core': 1.42.0 - '@zag-js/dom-query': 1.42.0 - '@zag-js/types': 1.42.0 - '@zag-js/utils': 1.42.0 + '@zag-js/anatomy': 1.43.3 + '@zag-js/collection': 1.43.3 + '@zag-js/core': 1.43.3 + '@zag-js/dom-query': 1.43.3 + '@zag-js/types': 1.43.3 + '@zag-js/utils': 1.43.3 - '@zag-js/types@1.42.0': + '@zag-js/types@1.43.3': dependencies: csstype: 3.2.3 - '@zag-js/utils@1.42.0': {} + '@zag-js/utils@1.43.3': {} '@zxing/text-encoding@0.9.0': optional: true @@ -13132,34 +12295,21 @@ snapshots: dependencies: event-target-shim: 5.0.1 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-import-attributes@1.9.5(acorn@8.17.0): - dependencies: - acorn: 8.17.0 - - acorn-import-phases@1.0.4(acorn@8.17.0): + acorn-import-attributes@1.9.5(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn-walk@8.3.5: - dependencies: - acorn: 8.17.0 - - acorn@8.17.0: {} + acorn@8.18.0: {} - adm-zip@0.5.10: {} + adm-zip@0.6.0: {} agent-base@6.0.2(supports-color@10.2.2): dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.3.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -13184,7 +12334,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -13194,7 +12344,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: @@ -13277,13 +12427,13 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.5.2(postcss@8.5.26): + autoprefixer@10.5.5(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-lite: 1.0.30001810 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -13292,12 +12442,12 @@ snapshots: axe-core@4.13.0: {} - axios-retry@4.5.0(axios@1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)): + axios-retry@4.5.0(axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)): dependencies: - axios: 1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + axios: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) is-retry-allowed: 2.2.0 - axios@1.18.1(debug@4.3.7(supports-color@10.2.2))(supports-color@10.2.2): + axios@1.20.0(debug@4.3.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: follow-redirects: 1.16.0(debug@4.3.7(supports-color@10.2.2)) form-data: 4.0.6 @@ -13307,7 +12457,7 @@ snapshots: - debug - supports-color - axios@1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): + axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 @@ -13325,32 +12475,30 @@ snapshots: base64-js@1.5.1: {} - base64id@2.0.0: {} + baseline-browser-mapping@2.11.21: {} - baseline-browser-mapping@2.11.19: {} - - better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3)) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747)) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6))) + '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)))(jose@6.2.5)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747)) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747) - '@noble/ciphers': 2.2.0 + '@noble/ciphers': 2.4.0 '@noble/hashes': 2.2.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) defu: 6.1.7 jose: 6.2.5 - kysely: 0.29.4 - nanostores: 1.4.2 - zod: 4.4.3 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) optionalDependencies: drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)) pg: 8.22.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -13358,14 +12506,14 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.4.3): + better-call@1.4.0(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747) rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.4.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) better-themes@1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: @@ -13390,16 +12538,16 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.16: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.2: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -13407,19 +12555,17 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist-load-config@1.0.3: {} - browserslist-to-es-version@1.4.2: dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 - browserslist@4.28.8: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.19 + baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.414 - node-releases: 2.0.53 - update-browserslist-db: 1.3.1(browserslist@4.28.8) + electron-to-chromium: 1.5.423 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) buffer-from@1.1.2: {} @@ -13437,7 +12583,7 @@ snapshots: bun-types@1.4.0: dependencies: - '@types/node': 20.19.43 + '@types/node': 26.5.0 bundle-name@4.1.0: dependencies: @@ -13473,14 +12619,14 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-lite: 1.0.30001810 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 caniuse-api@4.0.0: dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-lite: 1.0.30001810 caniuse-lite@1.0.30001810: {} @@ -13504,7 +12650,7 @@ snapshots: chokidar@5.0.0: dependencies: - readdirp: 5.0.0 + readdirp: 5.1.1 optional: true chownr@3.0.0: {} @@ -13546,7 +12692,7 @@ snapshots: dependencies: readable-stream: 4.7.0 - cluster-key-slot@1.1.1: {} + cluster-key-slot@1.1.2: {} color-convert@2.0.1: dependencies: @@ -13570,30 +12716,28 @@ snapshots: commander@7.2.0: {} - compression-webpack-plugin@12.0.0(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): + compression-webpack-plugin@12.0.0(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): dependencies: schema-utils: 4.3.3 - serialize-javascript: 7.0.7 - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + serialize-javascript: 7.1.1 + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) concat-map@0.0.1: {} confbox@0.1.8: {} - confbox@0.2.4: {} + confbox@0.3.1: {} connect-history-api-fallback@2.0.0: {} consola@3.4.2: {} - content-type@2.0.0: {} + content-type@2.1.0: {} convert-source-map@2.0.0: {} cookie-es@3.1.1: {} - cookie@0.7.2: {} - cookie@1.1.1: {} cookie@2.0.1: {} @@ -13602,12 +12746,7 @@ snapshots: dependencies: is-what: 4.1.16 - core-js@3.49.0: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 + core-js@3.50.0: {} cosmiconfig@8.3.6(typescript@7.0.2): dependencies: @@ -13618,31 +12757,51 @@ snapshots: optionalDependencies: typescript: 7.0.2 + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.4.0(postcss@8.5.26): + css-declaration-sorter@7.4.0(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - css-minimizer-webpack-plugin@8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): + css-minimizer-webpack-plugin@8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 7.1.9(postcss@8.5.26) - jest-worker: 30.4.1 - postcss: 8.5.26 + cssnano: 7.1.9(postcss@8.5.28) + jest-worker: 30.5.1 + postcss: 8.5.28 schema-utils: 4.3.3 - serialize-javascript: 7.0.7 - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + serialize-javascript: 7.1.1 + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) optionalDependencies: clean-css: 5.3.3 csso: 5.0.5 esbuild: 0.28.1 lightningcss: 1.33.0 + css-minimizer-webpack-plugin@8.0.0(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + cssnano: 7.1.9(postcss@8.5.28) + jest-worker: 30.5.1 + postcss: 8.5.28 + schema-utils: 4.3.3 + serialize-javascript: 7.1.1 + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) + optionalDependencies: + clean-css: 5.3.3 + csso: 5.0.5 + esbuild: 0.28.2 + lightningcss: 1.33.0 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -13651,6 +12810,14 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-tree@2.2.1: dependencies: mdn-data: 2.0.28 @@ -13668,94 +12835,95 @@ snapshots: css-what@6.2.2: {} + css-what@7.0.0: {} + cssesc@3.0.0: {} - cssnano-preset-default@7.0.17(postcss@8.5.26): - dependencies: - browserslist: 4.28.8 - css-declaration-sorter: 7.4.0(postcss@8.5.26) - cssnano-utils: 5.0.3(postcss@8.5.26) - postcss: 8.5.26 - postcss-calc: 10.1.1(postcss@8.5.26) - postcss-colormin: 7.0.10(postcss@8.5.26) - postcss-convert-values: 7.0.12(postcss@8.5.26) - postcss-discard-comments: 7.0.8(postcss@8.5.26) - postcss-discard-duplicates: 7.0.4(postcss@8.5.26) - postcss-discard-empty: 7.0.3(postcss@8.5.26) - postcss-discard-overridden: 7.0.3(postcss@8.5.26) - postcss-merge-longhand: 7.0.7(postcss@8.5.26) - postcss-merge-rules: 7.0.11(postcss@8.5.26) - postcss-minify-font-values: 7.0.3(postcss@8.5.26) - postcss-minify-gradients: 7.0.5(postcss@8.5.26) - postcss-minify-params: 7.0.9(postcss@8.5.26) - postcss-minify-selectors: 7.1.2(postcss@8.5.26) - postcss-normalize-charset: 7.0.3(postcss@8.5.26) - postcss-normalize-display-values: 7.0.3(postcss@8.5.26) - postcss-normalize-positions: 7.0.4(postcss@8.5.26) - postcss-normalize-repeat-style: 7.0.4(postcss@8.5.26) - postcss-normalize-string: 7.0.3(postcss@8.5.26) - postcss-normalize-timing-functions: 7.0.3(postcss@8.5.26) - postcss-normalize-unicode: 7.0.9(postcss@8.5.26) - postcss-normalize-url: 7.0.3(postcss@8.5.26) - postcss-normalize-whitespace: 7.0.3(postcss@8.5.26) - postcss-ordered-values: 7.0.4(postcss@8.5.26) - postcss-reduce-initial: 7.0.9(postcss@8.5.26) - postcss-reduce-transforms: 7.0.3(postcss@8.5.26) - postcss-svgo: 7.1.3(postcss@8.5.26) - postcss-unique-selectors: 7.0.7(postcss@8.5.26) - - cssnano-preset-default@8.0.2(postcss@8.5.26): - dependencies: - browserslist: 4.28.8 - cssnano-utils: 6.0.1(postcss@8.5.26) - postcss: 8.5.26 - postcss-calc: 10.1.1(postcss@8.5.26) - postcss-colormin: 8.0.1(postcss@8.5.26) - postcss-convert-values: 8.0.1(postcss@8.5.26) - postcss-discard-comments: 8.0.1(postcss@8.5.26) - postcss-discard-duplicates: 8.0.1(postcss@8.5.26) - postcss-discard-empty: 8.0.1(postcss@8.5.26) - postcss-discard-overridden: 8.0.1(postcss@8.5.26) - postcss-merge-longhand: 8.0.1(postcss@8.5.26) - postcss-merge-rules: 8.0.1(postcss@8.5.26) - postcss-minify-font-values: 8.0.1(postcss@8.5.26) - postcss-minify-gradients: 8.0.1(postcss@8.5.26) - postcss-minify-params: 8.0.1(postcss@8.5.26) - postcss-minify-selectors: 8.0.2(postcss@8.5.26) - postcss-normalize-charset: 8.0.1(postcss@8.5.26) - postcss-normalize-display-values: 8.0.1(postcss@8.5.26) - postcss-normalize-positions: 8.0.1(postcss@8.5.26) - postcss-normalize-repeat-style: 8.0.1(postcss@8.5.26) - postcss-normalize-string: 8.0.1(postcss@8.5.26) - postcss-normalize-timing-functions: 8.0.1(postcss@8.5.26) - postcss-normalize-unicode: 8.0.1(postcss@8.5.26) - postcss-normalize-url: 8.0.1(postcss@8.5.26) - postcss-normalize-whitespace: 8.0.1(postcss@8.5.26) - postcss-ordered-values: 8.0.1(postcss@8.5.26) - postcss-reduce-initial: 8.0.1(postcss@8.5.26) - postcss-reduce-transforms: 8.0.1(postcss@8.5.26) - postcss-svgo: 8.0.1(postcss@8.5.26) - postcss-unique-selectors: 8.0.1(postcss@8.5.26) - - cssnano-utils@5.0.3(postcss@8.5.26): - dependencies: - postcss: 8.5.26 - - cssnano-utils@6.0.1(postcss@8.5.26): - dependencies: - postcss: 8.5.26 - - cssnano@7.1.9(postcss@8.5.26): - dependencies: - cssnano-preset-default: 7.0.17(postcss@8.5.26) + cssnano-preset-default@7.0.17(postcss@8.5.28): + dependencies: + browserslist: 4.28.9 + css-declaration-sorter: 7.4.0(postcss@8.5.28) + cssnano-utils: 5.0.3(postcss@8.5.28) + postcss: 8.5.28 + postcss-calc: 10.1.1(postcss@8.5.28) + postcss-colormin: 7.0.10(postcss@8.5.28) + postcss-convert-values: 7.0.12(postcss@8.5.28) + postcss-discard-comments: 7.0.8(postcss@8.5.28) + postcss-discard-duplicates: 7.0.4(postcss@8.5.28) + postcss-discard-empty: 7.0.3(postcss@8.5.28) + postcss-discard-overridden: 7.0.3(postcss@8.5.28) + postcss-merge-longhand: 7.0.7(postcss@8.5.28) + postcss-merge-rules: 7.0.11(postcss@8.5.28) + postcss-minify-font-values: 7.0.3(postcss@8.5.28) + postcss-minify-gradients: 7.0.5(postcss@8.5.28) + postcss-minify-params: 7.0.9(postcss@8.5.28) + postcss-minify-selectors: 7.1.2(postcss@8.5.28) + postcss-normalize-charset: 7.0.3(postcss@8.5.28) + postcss-normalize-display-values: 7.0.3(postcss@8.5.28) + postcss-normalize-positions: 7.0.4(postcss@8.5.28) + postcss-normalize-repeat-style: 7.0.4(postcss@8.5.28) + postcss-normalize-string: 7.0.3(postcss@8.5.28) + postcss-normalize-timing-functions: 7.0.3(postcss@8.5.28) + postcss-normalize-unicode: 7.0.9(postcss@8.5.28) + postcss-normalize-url: 7.0.3(postcss@8.5.28) + postcss-normalize-whitespace: 7.0.3(postcss@8.5.28) + postcss-ordered-values: 7.0.4(postcss@8.5.28) + postcss-reduce-initial: 7.0.9(postcss@8.5.28) + postcss-reduce-transforms: 7.0.3(postcss@8.5.28) + postcss-svgo: 7.1.3(postcss@8.5.28) + postcss-unique-selectors: 7.0.7(postcss@8.5.28) + + cssnano-preset-default@9.0.4(postcss@8.5.28): + dependencies: + browserslist: 4.28.9 + cssnano-utils: 7.0.2(postcss@8.5.28) + postcss: 8.5.28 + postcss-calc: 11.1.1(postcss@8.5.28) + postcss-colormin: 9.0.2(postcss@8.5.28) + postcss-convert-values: 9.0.2(postcss@8.5.28) + postcss-discard-comments: 9.0.2(postcss@8.5.28) + postcss-discard-duplicates: 9.0.2(postcss@8.5.28) + postcss-discard-empty: 9.0.2(postcss@8.5.28) + postcss-discard-overridden: 9.0.2(postcss@8.5.28) + postcss-merge-longhand: 9.0.3(postcss@8.5.28) + postcss-merge-rules: 9.0.3(postcss@8.5.28) + postcss-minify-font-values: 9.0.2(postcss@8.5.28) + postcss-minify-gradients: 9.0.2(postcss@8.5.28) + postcss-minify-params: 9.0.2(postcss@8.5.28) + postcss-minify-selectors: 9.0.3(postcss@8.5.28) + postcss-normalize-charset: 9.0.2(postcss@8.5.28) + postcss-normalize-display-values: 9.0.2(postcss@8.5.28) + postcss-normalize-positions: 9.0.2(postcss@8.5.28) + postcss-normalize-repeat-style: 9.0.2(postcss@8.5.28) + postcss-normalize-string: 9.0.2(postcss@8.5.28) + postcss-normalize-timing-functions: 9.0.2(postcss@8.5.28) + postcss-normalize-unicode: 9.0.2(postcss@8.5.28) + postcss-normalize-url: 9.0.2(postcss@8.5.28) + postcss-normalize-whitespace: 9.0.2(postcss@8.5.28) + postcss-ordered-values: 9.0.2(postcss@8.5.28) + postcss-reduce-initial: 9.0.2(postcss@8.5.28) + postcss-reduce-transforms: 9.0.2(postcss@8.5.28) + postcss-svgo: 9.0.2(postcss@8.5.28) + postcss-unique-selectors: 9.0.2(postcss@8.5.28) + + cssnano-utils@5.0.3(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + + cssnano-utils@7.0.2(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + + cssnano@7.1.9(postcss@8.5.28): + dependencies: + cssnano-preset-default: 7.0.17(postcss@8.5.28) lilconfig: 3.1.3 - postcss: 8.5.26 + postcss: 8.5.28 - cssnano@8.0.2(postcss@8.5.26): + cssnano@9.0.3(postcss@8.5.28): dependencies: - cssnano-preset-default: 8.0.2(postcss@8.5.26) - lilconfig: 3.1.3 - postcss: 8.5.26 + cssnano-preset-default: 9.0.4(postcss@8.5.28) + postcss: 8.5.28 csso@5.0.5: dependencies: @@ -13812,17 +12980,11 @@ snapshots: optionalDependencies: supports-color: 10.2.2 - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 - - deep-is@0.1.4: {} - deepmerge@4.3.1: {} default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -13849,8 +13011,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@2.1.0: {} - dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -13871,7 +13031,7 @@ snapshots: dependencies: domelementtype: 3.0.0 domhandler: 6.0.1 - entities: 8.0.0 + entities: 8.1.0 domelementtype@2.3.0: {} @@ -13904,23 +13064,23 @@ snapshots: drizzle-kit@1.0.0-rc.5-ab785fc: dependencies: - '@drizzle-team/brocli': 0.12.0 + '@drizzle-team/brocli': 0.12.1 '@js-temporal/polyfill': 0.5.1 esbuild: 0.25.12 get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada))(pg@8.22.0)(zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6)): optionalDependencies: '@cloudflare/workers-types': 5.20260810.1 - '@effect/sql-pg': 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + '@effect/sql-pg': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)) '@opentelemetry/api': 1.9.1 '@sinclair/typebox': 0.34.52 '@types/pg': 8.20.0 bun-types: 1.4.0 - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) pg: 8.22.0 - zod: 4.4.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) dunder-proto@1.0.1: dependencies: @@ -13930,20 +13090,17 @@ snapshots: eastasianwidth@0.2.0: {} - effect-rstest@https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3))(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)): + effect-rstest@https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc(patch_hash=aa7e505a2b575757ce3951715189b3cd30d90b7a57c17669f9f89340e751f138)(@rstest/core@0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3))(effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada)): dependencies: - '@rstest/core': 0.11.11(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(happy-dom@20.8.3) - effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) + '@rstest/core': 0.11.11(@module-federation/runtime-tools@2.9.0)(core-js@3.50.0)(happy-dom@20.8.3) + effect: 4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada) - effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98): + effect@4.0.0-rc.112(patch_hash=b7489e5109ec4daf5d6c7ac30956ed95c312f4077cbc973b8c82f0f340108ada): dependencies: - '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 - kubernetes-types: 1.30.0 - msgpackr: 2.0.4 - uuid: 14.0.1 + msgpackr: 2.1.0(patch_hash=de5c91fa6cfd894d171ed06673af40046ba97c7eb604409caf5f510e1a8a5b7a) - electron-to-chromium@1.5.414: {} + electron-to-chromium@1.5.423: {} emoji-regex@8.0.0: {} @@ -13957,39 +13114,16 @@ snapshots: dependencies: iconv-lite: 0.6.3 - engine.io-parser@5.2.3: {} - - engine.io@6.6.9(supports-color@10.2.2): - dependencies: - '@types/cors': 2.8.19 - '@types/node': 20.19.43 - '@types/ws': 8.18.1 - accepts: 1.3.8 - base64id: 2.0.0 - cookie: 0.7.2 - cors: 2.8.6 - debug: 4.4.3(supports-color@10.2.2) - engine.io-parser: 5.2.3 - ws: 8.21.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - enhanced-resolve@5.24.3: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 entities@4.5.0: {} - entities@6.0.1: {} - entities@7.0.1: {} - entities@8.0.0: {} - - envinfo@7.21.0: {} + entities@8.1.0: {} environment@1.1.0: {} @@ -14072,7 +13206,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: dependencies: @@ -14098,8 +13232,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es-toolkit@1.49.0: {} - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -14158,15 +13290,40 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): - dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + eslint-config-prettier@10.1.8: {} eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: @@ -14176,72 +13333,65 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(supports-color@10.2.2): dependencies: debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/parser': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-escompat@3.12.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-escompat@3.12.0: dependencies: - browserslist: 4.28.8 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + browserslist: 4.28.9 - eslint-plugin-eslint-comments@3.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-eslint-comments@3.2.0: dependencies: escape-string-regexp: 1.0.5 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) ignore: 5.3.2 - eslint-plugin-filenames@1.3.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-filenames@1.3.2: dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) lodash.camelcase: 4.3.0 lodash.kebabcase: 4.1.1 lodash.snakecase: 4.1.1 lodash.upperfirst: 4.3.1 - eslint-plugin-github@6.1.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-github@6.1.2(supports-color@10.2.2): dependencies: - '@eslint/compat': 2.1.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint/compat': 2.1.1 '@eslint/eslintrc': 3.3.7(supports-color@10.2.2) '@eslint/js': 9.39.5 '@github/browserslist-config': 1.0.0 - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - aria-query: 5.3.0 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) - eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-escompat: 3.12.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-eslint-comments: 3.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-filenames: 1.3.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-i18n-text: 1.0.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + aria-query: 5.3.2 + eslint-config-prettier: 10.1.8 + eslint-plugin-escompat: 3.12.0 + eslint-plugin-eslint-comments: 3.2.0 + eslint-plugin-filenames: 1.3.2 + eslint-plugin-i18n-text: 1.0.1 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) + eslint-plugin-jsx-a11y: 6.10.2 eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6) + eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8)(prettier@3.9.6) eslint-rule-documentation: 1.0.23 globals: 17.12.0 jsx-ast-utils: 3.3.5 prettier: 3.9.6 svg-element-attributes: 1.3.1 typescript: 6.0.3 - typescript-eslint: 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + typescript-eslint: 8.70.0(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - '@types/eslint' - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-i18n-text@1.0.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): - dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-i18n-text@1.0.1: {} - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14250,9 +13400,8 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -14264,13 +13413,13 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-jsx-a11y@6.10.2: dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -14280,7 +13429,6 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -14291,35 +13439,33 @@ snapshots: eslint-plugin-no-only-tests@3.4.0: {} - eslint-plugin-perfectionist@5.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2): + eslint-plugin-perfectionist@5.10.1(patch_hash=9e69fb6189199155ccf29de79c5127492dcebff0b0a1fdf79bb3cd72704501a7)(supports-color@10.2.2)(typescript@7.0.2): dependencies: - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.70.0(supports-color@10.2.2)(typescript@7.0.2) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8)(prettier@3.9.6): dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) prettier: 3.9.6 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + eslint-config-prettier: 10.1.8 - eslint-plugin-sonarjs@4.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-sonarjs@4.2.0: dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) functional-red-black-tree: 1.0.1 globals: 17.12.0 jsx-ast-utils-x: 0.1.0 lodash.merge: 4.6.2 - minimatch: 10.2.5 + minimatch: 10.2.6 scslre: 0.3.0 semver: 7.8.5 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -14328,16 +13474,6 @@ snapshots: eslint-rule-documentation@1.0.23: {} - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -14351,63 +13487,16 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@10.2.2) - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@10.2.2) - '@eslint/js': 9.39.5 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.7.0 - transitivePeerDependencies: - - supports-color - espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - esrecurse@4.3.0: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-walker@2.0.2: {} @@ -14418,11 +13507,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} - eventsource@4.1.0: + eventsource@4.1.1: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 execa@10.0.1: dependencies: @@ -14433,7 +13522,7 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 which-command: 0.1.0 @@ -14451,7 +13540,7 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exsolve@1.1.0: {} + exsolve@1.1.1: {} fallow-type-aware@3.22.0: dependencies: @@ -14490,21 +13579,19 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-levenshtein@2.0.6: {} - fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.4: {} + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 - fastq@1.20.1: + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -14527,14 +13614,8 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - file-uri-to-path@1.0.0: {} - filesize@11.0.22: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -14545,22 +13626,12 @@ snapshots: dependencies: locate-path: 3.0.0 - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - find-workspaces@0.3.1: dependencies: fast-glob: 3.3.3 pkg-types: 1.3.1 yaml: 2.9.0 - flat-cache@4.0.1: - dependencies: - flatted: 3.4.4 - keyv: 4.5.4 - flatted@3.4.4: {} follow-redirects@1.16.0(debug@4.3.7(supports-color@10.2.2)): @@ -14605,7 +13676,7 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.6: + fs-extra@11.4.0: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 @@ -14658,8 +13729,6 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 - get-port@5.1.1: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -14695,10 +13764,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - glob-to-regex.js@1.2.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -14712,12 +13777,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - glob@7.2.0: dependencies: fs.realpath: 1.0.0 @@ -14744,7 +13803,7 @@ snapshots: happy-dom@20.8.3: dependencies: - '@types/node': 20.19.43 + '@types/node': 26.5.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -14780,7 +13839,7 @@ snapshots: dependencies: react-is: 16.13.1 - hono@4.12.31: {} + hono@4.13.7: {} html-minifier-terser@7.2.0: dependencies: @@ -14790,28 +13849,21 @@ snapshots: entities: 4.5.0 param-case: 3.0.4 relateurl: 0.2.7 - terser: 5.49.0 - - htmlparser2@10.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 6.0.1 + terser: 5.51.2 htmlparser2@12.0.0: dependencies: domelementtype: 3.0.0 domhandler: 6.0.1 domutils: 4.0.2 - entities: 8.0.0 + entities: 8.1.0 http-compression@1.1.3: {} https-proxy-agent@5.0.1(supports-color@10.2.2): dependencies: agent-base: 6.0.2(supports-color@10.2.2) - debug: 4.4.3(supports-color@10.2.2) + debug: 4.3.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -14832,17 +13884,17 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - i18next-chained-backend@5.0.5: + i18next-chained-backend@5.0.6: dependencies: '@babel/runtime': 7.29.7 i18next-fs-backend@2.6.7: {} - i18next-http-backend@4.0.0: {} + i18next-http-backend@4.0.2: {} i18next-http-middleware@3.9.8: {} - i18next@26.3.6(typescript@7.0.2): + i18next@26.4.2(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 @@ -14870,8 +13922,6 @@ snapshots: import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -14889,18 +13939,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.11.1(supports-color@10.2.2): - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@10.2.2) - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -15066,9 +14104,7 @@ snapshots: isarray@2.0.5: {} - isbot@5.2.0: {} - - isbot@5.2.1: {} + isbot@5.2.2: {} isexe@2.0.0: {} @@ -15082,12 +14118,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-regex-util@30.4.0: {} + jest-regex-util@30.5.0: {} - jest-util@30.4.1: + jest-util@30.5.1: dependencies: - '@jest/types': 30.4.1 - '@types/node': 20.19.43 + '@jest/types': 30.5.1 + '@types/node': 26.5.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -15095,15 +14131,15 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.19.43 + '@types/node': 26.5.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest-worker@30.4.1: + jest-worker@30.5.1: dependencies: - '@types/node': 20.19.43 - '@ungap/structured-clone': 1.3.3 - jest-util: 30.4.1 + '@types/node': 26.5.0 + '@ungap/structured-clone': 1.4.0 + jest-util: 30.5.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -15162,18 +14198,12 @@ snapshots: jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} - - json-stream-stringify@3.0.1: {} - json5@1.0.2: dependencies: minimist: 1.2.8 @@ -15197,10 +14227,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - kleur@4.1.5: {} knip@6.34.0: @@ -15217,13 +14243,11 @@ snapshots: tinyglobby: 0.2.17 unbash: 4.0.11 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) koa-compose@4.1.0: {} - kubernetes-types@1.30.0: {} - - kysely@0.29.4: {} + kysely@0.29.5: {} language-subtag-registry@0.3.23: {} @@ -15231,62 +14255,57 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 - launch-editor@2.14.1: - dependencies: - picocolors: 1.1.1 - shell-quote: 1.10.0 - - lefthook-darwin-arm64@2.1.10: + lefthook-darwin-arm64@2.1.12: optional: true - lefthook-darwin-x64@2.1.10: + lefthook-darwin-x64@2.1.12: optional: true - lefthook-freebsd-arm64@2.1.10: + lefthook-freebsd-arm64@2.1.12: optional: true - lefthook-freebsd-x64@2.1.10: + lefthook-freebsd-x64@2.1.12: optional: true - lefthook-linux-arm64@2.1.10: + lefthook-linux-arm64@2.1.12: optional: true - lefthook-linux-x64@2.1.10: + lefthook-linux-x64@2.1.12: optional: true - lefthook-openbsd-arm64@2.1.10: + lefthook-openbsd-arm64@2.1.12: optional: true - lefthook-openbsd-x64@2.1.10: + lefthook-openbsd-x64@2.1.12: optional: true - lefthook-windows-arm64@2.1.10: + lefthook-windows-arm64@2.1.12: optional: true - lefthook-windows-x64@2.1.10: + lefthook-windows-x64@2.1.12: optional: true - lefthook@2.1.10: + lefthook@2.1.12: optionalDependencies: - lefthook-darwin-arm64: 2.1.10 - lefthook-darwin-x64: 2.1.10 - lefthook-freebsd-arm64: 2.1.10 - lefthook-freebsd-x64: 2.1.10 - lefthook-linux-arm64: 2.1.10 - lefthook-linux-x64: 2.1.10 - lefthook-openbsd-arm64: 2.1.10 - lefthook-openbsd-x64: 2.1.10 - lefthook-windows-arm64: 2.1.10 - lefthook-windows-x64: 2.1.10 - - less-loader@12.3.3(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(less@4.7.0(supports-color@10.2.2))(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): - dependencies: - less: 4.7.0(supports-color@10.2.2) + lefthook-darwin-arm64: 2.1.12 + lefthook-darwin-x64: 2.1.12 + lefthook-freebsd-arm64: 2.1.12 + lefthook-freebsd-x64: 2.1.12 + lefthook-linux-arm64: 2.1.12 + lefthook-linux-x64: 2.1.12 + lefthook-openbsd-arm64: 2.1.12 + lefthook-openbsd-x64: 2.1.12 + lefthook-windows-arm64: 2.1.12 + lefthook-windows-x64: 2.1.12 + + less-loader@12.3.3(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(less@4.9.1(supports-color@10.2.2))(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): + dependencies: + less: 4.9.1(supports-color@10.2.2) optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) - less@4.7.0(supports-color@10.2.2): + less@4.9.1(supports-color@10.2.2): dependencies: copy-anything: 3.0.5 parse-node-version: 1.0.1 @@ -15296,17 +14315,12 @@ snapshots: make-dir: 5.1.0 mime: 1.6.0 needle: 3.5.0 - probe-image-size: 7.3.0(supports-color@10.2.2) + probe-image-size: 7.4.0(supports-color@10.2.2) source-map: 0.6.1 transitivePeerDependencies: - supports-color - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - libphonenumber-js@1.13.9: {} + libphonenumber-js@1.13.12: {} lightningcss-android-arm64@1.32.0: optional: true @@ -15410,10 +14424,6 @@ snapshots: lines-and-columns@1.2.4: {} - lines-and-columns@2.0.4: {} - - loader-runner@4.3.2: {} - loader-utils@2.0.4: dependencies: big.js: 5.2.2 @@ -15427,10 +14437,6 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - lodash-es@4.18.1: {} lodash.camelcase@4.3.0: {} @@ -15495,12 +14501,12 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 magicast@0.5.4: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@5.1.0: @@ -15514,22 +14520,22 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} - memfs@4.64.0(tslib@2.8.1): + memfs@4.71.0: dependencies: - '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-to-fsa': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.71.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.71.0(tslib@2.8.1) '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -15563,48 +14569,50 @@ snapshots: mimic-function@5.0.1: {} - miniflare@4.20260708.1: + miniflare@4.20260730.0: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 + sharp: 0.35.2 undici: 7.28.0 - workerd: 1.20260708.1 + workerd: 1.20260730.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil - utf-8-validate - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.18 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 2.1.4 minimist@1.2.8: {} - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): + minimizer-webpack-plugin@5.10.0(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - terser: 5.49.0 - webpack: 5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) + terser: 5.51.2 + webpack: 5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0) optionalDependencies: - '@swc/core': 1.15.43(@swc/helpers@0.5.23) + '@swc/core': 1.16.2(@swc/helpers@0.5.23) clean-css: 5.3.3 - cssnano: 8.0.2(postcss@8.5.26) + cssnano: 9.0.3(postcss@8.5.28) csso: 5.0.5 esbuild: 0.28.1 html-minifier-terser: 7.2.0 lightningcss: 1.33.0 - postcss: 8.5.26 + postcss: 8.5.28 + sharp: 0.35.2 + svgo: 4.1.0 minipass@7.1.3: {} @@ -15614,19 +14622,19 @@ snapshots: mlly@1.6.1: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 pathe: 1.1.2 pkg-types: 1.3.1 ufo: 1.6.4 mlly@1.8.2: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.4 - modern-tar@0.7.7: {} + modern-tar@0.8.5: {} mrmime@1.0.1: {} @@ -15647,13 +14655,13 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@2.0.4: + msgpackr@2.1.0(patch_hash=de5c91fa6cfd894d171ed06673af40046ba97c7eb604409caf5f510e1a8a5b7a): optionalDependencies: msgpackr-extract: 3.0.4 nanoid@3.3.18: {} - nanostores@1.4.2: {} + nanostores@1.5.3: {} natural-compare@1.4.0: {} @@ -15663,7 +14671,7 @@ snapshots: dependencies: '@vercel/nft': 0.29.2(patch_hash=c0ed4897b98e9055716031187bb8ea16739f6ae0843d35e4873f1a177472cac7)(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2) - fs-extra: 11.3.6 + fs-extra: 11.4.0 mlly: 1.6.1 pkg-types: 1.3.1 pkg-up: 3.1.0 @@ -15676,7 +14684,7 @@ snapshots: dependencies: debug: 3.2.7(supports-color@10.2.2) iconv-lite: 0.4.24 - sax: 1.6.0 + sax: 1.6.1 transitivePeerDependencies: - supports-color optional: true @@ -15684,11 +14692,9 @@ snapshots: needle@3.5.0: dependencies: iconv-lite: 0.6.3 - sax: 1.6.0 + sax: 1.6.1 optional: true - negotiator@0.6.3: {} - neo-async@2.6.2: {} no-case@3.0.4: @@ -15725,7 +14731,7 @@ snapshots: dependencies: p-limit: 3.1.0 - node-releases@2.0.53: {} + node-releases@2.0.54: {} nopt@8.1.0: dependencies: @@ -15746,17 +14752,11 @@ snapshots: dependencies: boolbase: 1.0.0 - nypm@0.6.8: - dependencies: - citty: 0.2.2 - pathe: 2.0.3 - tinyexec: 1.2.4 - nypm@0.6.9: dependencies: citty: 0.2.2 pathe: 2.0.3 - tinyexec: 1.2.4 + tinyexec: 1.3.1 object-assign@4.1.1: {} @@ -15818,20 +14818,11 @@ snapshots: open@10.2.0: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - ora@5.4.1: dependencies: bl: 4.1.0 @@ -15921,58 +14912,34 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.63.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.63.0 - '@oxfmt/binding-android-arm64': 0.63.0 - '@oxfmt/binding-darwin-arm64': 0.63.0 - '@oxfmt/binding-darwin-x64': 0.63.0 - '@oxfmt/binding-freebsd-x64': 0.63.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.63.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.63.0 - '@oxfmt/binding-linux-arm64-gnu': 0.63.0 - '@oxfmt/binding-linux-arm64-musl': 0.63.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.63.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.63.0 - '@oxfmt/binding-linux-riscv64-musl': 0.63.0 - '@oxfmt/binding-linux-s390x-gnu': 0.63.0 - '@oxfmt/binding-linux-x64-gnu': 0.63.0 - '@oxfmt/binding-linux-x64-musl': 0.63.0 - '@oxfmt/binding-openharmony-arm64': 0.63.0 - '@oxfmt/binding-win32-arm64-msvc': 0.63.0 - '@oxfmt/binding-win32-ia32-msvc': 0.63.0 - '@oxfmt/binding-win32-x64-msvc': 0.63.0 - - oxfmt@0.64.0: + oxfmt@0.66.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.64.0 - '@oxfmt/binding-android-arm64': 0.64.0 - '@oxfmt/binding-darwin-arm64': 0.64.0 - '@oxfmt/binding-darwin-x64': 0.64.0 - '@oxfmt/binding-freebsd-x64': 0.64.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 - '@oxfmt/binding-linux-arm64-gnu': 0.64.0 - '@oxfmt/binding-linux-arm64-musl': 0.64.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 - '@oxfmt/binding-linux-riscv64-musl': 0.64.0 - '@oxfmt/binding-linux-s390x-gnu': 0.64.0 - '@oxfmt/binding-linux-x64-gnu': 0.64.0 - '@oxfmt/binding-linux-x64-musl': 0.64.0 - '@oxfmt/binding-openharmony-arm64': 0.64.0 - '@oxfmt/binding-win32-arm64-msvc': 0.64.0 - '@oxfmt/binding-win32-ia32-msvc': 0.64.0 - '@oxfmt/binding-win32-x64-msvc': 0.64.0 + '@oxfmt/binding-android-arm-eabi': 0.66.0 + '@oxfmt/binding-android-arm64': 0.66.0 + '@oxfmt/binding-darwin-arm64': 0.66.0 + '@oxfmt/binding-darwin-x64': 0.66.0 + '@oxfmt/binding-freebsd-x64': 0.66.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.66.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.66.0 + '@oxfmt/binding-linux-arm64-gnu': 0.66.0 + '@oxfmt/binding-linux-arm64-musl': 0.66.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-musl': 0.66.0 + '@oxfmt/binding-linux-s390x-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-musl': 0.66.0 + '@oxfmt/binding-openharmony-arm64': 0.66.0 + '@oxfmt/binding-win32-arm64-msvc': 0.66.0 + '@oxfmt/binding-win32-ia32-msvc': 0.66.0 + '@oxfmt/binding-win32-x64-msvc': 0.66.0 oxlint-plugin-react-doctor@0.9.12: dependencies: '@shaderfrog/glsl-parser': 7.0.1 - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.70.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 lightningcss: 1.33.0 @@ -15987,50 +14954,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.78.0(oxlint-tsgolint@7.0.2001): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.78.0 - '@oxlint/binding-android-arm64': 1.78.0 - '@oxlint/binding-darwin-arm64': 1.78.0 - '@oxlint/binding-darwin-x64': 1.78.0 - '@oxlint/binding-freebsd-x64': 1.78.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 - '@oxlint/binding-linux-arm-musleabihf': 1.78.0 - '@oxlint/binding-linux-arm64-gnu': 1.78.0 - '@oxlint/binding-linux-arm64-musl': 1.78.0 - '@oxlint/binding-linux-ppc64-gnu': 1.78.0 - '@oxlint/binding-linux-riscv64-gnu': 1.78.0 - '@oxlint/binding-linux-riscv64-musl': 1.78.0 - '@oxlint/binding-linux-s390x-gnu': 1.78.0 - '@oxlint/binding-linux-x64-gnu': 1.78.0 - '@oxlint/binding-linux-x64-musl': 1.78.0 - '@oxlint/binding-openharmony-arm64': 1.78.0 - '@oxlint/binding-win32-arm64-msvc': 1.78.0 - '@oxlint/binding-win32-ia32-msvc': 1.78.0 - '@oxlint/binding-win32-x64-msvc': 1.78.0 - oxlint-tsgolint: 7.0.2001 - - oxlint@1.79.0(oxlint-tsgolint@7.0.2001): + oxlint@1.81.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.79.0 - '@oxlint/binding-android-arm64': 1.79.0 - '@oxlint/binding-darwin-arm64': 1.79.0 - '@oxlint/binding-darwin-x64': 1.79.0 - '@oxlint/binding-freebsd-x64': 1.79.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 - '@oxlint/binding-linux-arm-musleabihf': 1.79.0 - '@oxlint/binding-linux-arm64-gnu': 1.79.0 - '@oxlint/binding-linux-arm64-musl': 1.79.0 - '@oxlint/binding-linux-ppc64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-musl': 1.79.0 - '@oxlint/binding-linux-s390x-gnu': 1.79.0 - '@oxlint/binding-linux-x64-gnu': 1.79.0 - '@oxlint/binding-linux-x64-musl': 1.79.0 - '@oxlint/binding-openharmony-arm64': 1.79.0 - '@oxlint/binding-win32-arm64-msvc': 1.79.0 - '@oxlint/binding-win32-ia32-msvc': 1.79.0 - '@oxlint/binding-win32-x64-msvc': 1.79.0 + '@oxlint/binding-android-arm-eabi': 1.81.0 + '@oxlint/binding-android-arm64': 1.81.0 + '@oxlint/binding-darwin-arm64': 1.81.0 + '@oxlint/binding-darwin-x64': 1.81.0 + '@oxlint/binding-freebsd-x64': 1.81.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.81.0 + '@oxlint/binding-linux-arm-musleabihf': 1.81.0 + '@oxlint/binding-linux-arm64-gnu': 1.81.0 + '@oxlint/binding-linux-arm64-musl': 1.81.0 + '@oxlint/binding-linux-ppc64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-musl': 1.81.0 + '@oxlint/binding-linux-s390x-gnu': 1.81.0 + '@oxlint/binding-linux-x64-gnu': 1.81.0 + '@oxlint/binding-linux-x64-musl': 1.81.0 + '@oxlint/binding-openharmony-arm64': 1.81.0 + '@oxlint/binding-win32-arm64-msvc': 1.81.0 + '@oxlint/binding-win32-ia32-msvc': 1.81.0 + '@oxlint/binding-win32-x64-msvc': 1.81.0 oxlint-tsgolint: 7.0.2001 p-limit@2.3.0: @@ -16045,10 +14989,6 @@ snapshots: dependencies: p-limit: 2.3.0 - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -16089,12 +15029,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 - path-browserify@1.0.1: {} - path-exists@3.0.0: {} - path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -16108,11 +15044,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.2 - minipass: 7.1.3 - path-to-regexp@6.3.0: {} path-to-regexp@8.4.2: {} @@ -16128,9 +15059,9 @@ snapshots: pg-connection-string@2.14.0: {} - pg-cursor@2.22.0(pg@8.22.0): + pg-cursor@2.22.0(pg@8.23.0): dependencies: - pg: 8.22.0 + pg: 8.23.0 pg-int8@1.0.1: {} @@ -16140,7 +15071,11 @@ snapshots: dependencies: pg: 8.22.0 - pg-protocol@1.15.0: {} + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} pg-types@2.2.0: dependencies: @@ -16164,7 +15099,17 @@ snapshots: dependencies: pg-connection-string: 2.14.0 pg-pool: 3.14.0(pg@8.22.0) - pg-protocol: 1.15.0 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -16186,10 +15131,10 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - pkg-types@2.3.1: + pkg-types@2.3.3: dependencies: - confbox: 0.2.4 - exsolve: 1.1.0 + confbox: 0.3.1 + exsolve: 1.1.1 pathe: 2.0.3 pkg-up@3.1.0: @@ -16206,346 +15151,351 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-calc@10.1.1(postcss@8.5.26): + postcss-calc@10.1.1(postcss@8.5.28): dependencies: - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 postcss-value-parser: 4.2.0 - postcss-colormin@7.0.10(postcss@8.5.26): + postcss-calc@11.1.1(postcss@8.5.28): dependencies: - '@colordx/core': 5.5.0 - browserslist: 4.28.8 + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.28 + + postcss-colormin@7.0.10(postcss@8.5.28): + dependencies: + '@colordx/core': 5.8.0 + browserslist: 4.28.9 caniuse-api: 3.0.0 - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-colormin@8.0.1(postcss@8.5.26): + postcss-colormin@9.0.2(postcss@8.5.28): dependencies: - '@colordx/core': 5.5.0 - browserslist: 4.28.8 + '@colordx/core': 6.4.0 + browserslist: 4.28.9 caniuse-api: 4.0.0 - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-convert-values@7.0.12(postcss@8.5.26): + postcss-convert-values@7.0.12(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 + browserslist: 4.28.9 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-convert-values@8.0.1(postcss@8.5.26): + postcss-convert-values@9.0.2(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 + browserslist: 4.28.9 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-custom-properties@15.0.1(postcss@8.5.26): + postcss-custom-properties@15.0.1(postcss@8.5.28): dependencies: '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/utilities': 3.0.0(postcss@8.5.26) - postcss: 8.5.26 + '@csstools/utilities': 3.0.0(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-discard-comments@7.0.8(postcss@8.5.26): + postcss-discard-comments@7.0.8(postcss@8.5.28): dependencies: - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-discard-comments@8.0.1(postcss@8.5.26): + postcss-discard-comments@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-discard-duplicates@7.0.4(postcss@8.5.26): + postcss-discard-duplicates@7.0.4(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-discard-duplicates@8.0.1(postcss@8.5.26): + postcss-discard-duplicates@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-discard-empty@7.0.3(postcss@8.5.26): + postcss-discard-empty@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-discard-empty@8.0.1(postcss@8.5.26): + postcss-discard-empty@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-discard-overridden@7.0.3(postcss@8.5.26): + postcss-discard-overridden@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-discard-overridden@8.0.1(postcss@8.5.26): + postcss-discard-overridden@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-flexbugs-fixes@5.0.2(postcss@8.5.26): + postcss-flexbugs-fixes@5.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-font-variant@5.0.0(postcss@8.5.26): + postcss-font-variant@5.0.0(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-initial@4.0.1(postcss@8.5.26): + postcss-initial@4.0.1(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-media-minmax@5.0.0(postcss@8.5.26): + postcss-media-minmax@5.0.0(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-merge-longhand@7.0.7(postcss@8.5.26): + postcss-merge-longhand@7.0.7(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - stylehacks: 7.0.11(postcss@8.5.26) + stylehacks: 7.0.11(postcss@8.5.28) - postcss-merge-longhand@8.0.1(postcss@8.5.26): + postcss-merge-longhand@9.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - stylehacks: 8.0.1(postcss@8.5.26) + stylehacks: 9.0.3(postcss@8.5.28) - postcss-merge-rules@7.0.11(postcss@8.5.26): + postcss-merge-rules@7.0.11(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 3.0.0 - cssnano-utils: 5.0.3(postcss@8.5.26) - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + cssnano-utils: 5.0.3(postcss@8.5.28) + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-merge-rules@8.0.1(postcss@8.5.26): + postcss-merge-rules@9.0.3(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 4.0.0 - cssnano-utils: 6.0.1(postcss@8.5.26) - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + cssnano-utils: 7.0.2(postcss@8.5.28) + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-minify-font-values@7.0.3(postcss@8.5.26): + postcss-minify-font-values@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-font-values@8.0.1(postcss@8.5.26): + postcss-minify-font-values@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-gradients@7.0.5(postcss@8.5.26): + postcss-minify-gradients@7.0.5(postcss@8.5.28): dependencies: - '@colordx/core': 5.5.0 - cssnano-utils: 5.0.3(postcss@8.5.26) - postcss: 8.5.26 + '@colordx/core': 5.8.0 + cssnano-utils: 5.0.3(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-gradients@8.0.1(postcss@8.5.26): + postcss-minify-gradients@9.0.2(postcss@8.5.28): dependencies: - '@colordx/core': 5.5.0 - cssnano-utils: 6.0.1(postcss@8.5.26) - postcss: 8.5.26 + '@colordx/core': 6.4.0 + cssnano-utils: 7.0.2(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-params@7.0.9(postcss@8.5.26): + postcss-minify-params@7.0.9(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - cssnano-utils: 5.0.3(postcss@8.5.26) - postcss: 8.5.26 + browserslist: 4.28.9 + cssnano-utils: 5.0.3(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-params@8.0.1(postcss@8.5.26): + postcss-minify-params@9.0.2(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - cssnano-utils: 6.0.1(postcss@8.5.26) - postcss: 8.5.26 + browserslist: 4.28.9 + cssnano-utils: 7.0.2(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-minify-selectors@7.1.2(postcss@8.5.26): + postcss-minify-selectors@7.1.2(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 3.0.0 cssesc: 3.0.0 - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-minify-selectors@8.0.2(postcss@8.5.26): + postcss-minify-selectors@9.0.3(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 4.0.0 cssesc: 3.0.0 - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-nesting@14.0.0(postcss@8.5.26): + postcss-nesting@14.0.1(postcss@8.5.28): dependencies: - '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.4) - '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.6) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.6) + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-normalize-charset@7.0.3(postcss@8.5.26): + postcss-normalize-charset@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-normalize-charset@8.0.1(postcss@8.5.26): + postcss-normalize-charset@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-normalize-display-values@7.0.3(postcss@8.5.26): + postcss-normalize-display-values@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-display-values@8.0.1(postcss@8.5.26): + postcss-normalize-display-values@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-positions@7.0.4(postcss@8.5.26): + postcss-normalize-positions@7.0.4(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-positions@8.0.1(postcss@8.5.26): + postcss-normalize-positions@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@7.0.4(postcss@8.5.26): + postcss-normalize-repeat-style@7.0.4(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@8.0.1(postcss@8.5.26): + postcss-normalize-repeat-style@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-string@7.0.3(postcss@8.5.26): + postcss-normalize-string@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-string@8.0.1(postcss@8.5.26): + postcss-normalize-string@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@7.0.3(postcss@8.5.26): + postcss-normalize-timing-functions@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@8.0.1(postcss@8.5.26): + postcss-normalize-timing-functions@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@7.0.9(postcss@8.5.26): + postcss-normalize-unicode@7.0.9(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 + browserslist: 4.28.9 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@8.0.1(postcss@8.5.26): + postcss-normalize-unicode@9.0.2(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 + browserslist: 4.28.9 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-url@7.0.3(postcss@8.5.26): + postcss-normalize-url@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-url@8.0.1(postcss@8.5.26): + postcss-normalize-url@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@7.0.3(postcss@8.5.26): + postcss-normalize-whitespace@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@8.0.1(postcss@8.5.26): + postcss-normalize-whitespace@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-ordered-values@7.0.4(postcss@8.5.26): + postcss-ordered-values@7.0.4(postcss@8.5.28): dependencies: - cssnano-utils: 5.0.3(postcss@8.5.26) - postcss: 8.5.26 + cssnano-utils: 5.0.3(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-ordered-values@8.0.1(postcss@8.5.26): + postcss-ordered-values@9.0.2(postcss@8.5.28): dependencies: - cssnano-utils: 6.0.1(postcss@8.5.26) - postcss: 8.5.26 + cssnano-utils: 7.0.2(postcss@8.5.28) + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.26): + postcss-page-break@3.0.4(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 - postcss-reduce-initial@7.0.9(postcss@8.5.26): + postcss-reduce-initial@7.0.9(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 3.0.0 - postcss: 8.5.26 + postcss: 8.5.28 - postcss-reduce-initial@8.0.1(postcss@8.5.26): + postcss-reduce-initial@9.0.2(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-api: 4.0.0 - postcss: 8.5.26 + postcss: 8.5.28 - postcss-reduce-transforms@7.0.3(postcss@8.5.26): + postcss-reduce-transforms@7.0.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-reduce-transforms@8.0.1(postcss@8.5.26): + postcss-reduce-transforms@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@7.1.3(postcss@8.5.26): + postcss-svgo@7.1.3(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - svgo: 4.0.2 + svgo: 4.1.0 - postcss-svgo@8.0.1(postcss@8.5.26): + postcss-svgo@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 - svgo: 4.0.2 + svgo: 4.1.0 - postcss-unique-selectors@7.0.7(postcss@8.5.26): + postcss-unique-selectors@7.0.7(postcss@8.5.28): dependencies: - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - postcss-unique-selectors@8.0.1(postcss@8.5.26): + postcss-unique-selectors@9.0.2(postcss@8.5.28): dependencies: - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 postcss-value-parser@4.2.0: {} - postcss@8.5.26: + postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -16573,8 +15523,6 @@ snapshots: postgres-range@1.1.4: {} - prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: dependencies: fast-diff: 1.3.0 @@ -16587,11 +15535,11 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 - probe-image-size@7.3.0(supports-color@10.2.2): + probe-image-size@7.4.0(supports-color@10.2.2): dependencies: lodash.merge: 4.6.2 needle: 2.9.1(supports-color@10.2.2) @@ -16614,7 +15562,7 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - protobufjs@7.6.5: + protobufjs@7.6.6: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -16625,7 +15573,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 20.19.43 + '@types/node': 26.5.0 long: 5.3.2 protocols@2.0.2: {} @@ -16641,7 +15589,7 @@ snapshots: pure-rand@8.4.2: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -16678,14 +15626,6 @@ snapshots: react-refresh@0.18.0: {} - react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - cookie: 1.1.1 - react: 19.2.8 - set-cookie-parser: 2.7.2 - optionalDependencies: - react-dom: 19.2.8(react@19.2.8) - react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: cookie: 1.1.1 @@ -16694,13 +15634,6 @@ snapshots: optionalDependencies: react-dom: 19.2.8(react@19.2.8) - react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - optional: true - react-side-effect@2.1.2(react@19.2.8): dependencies: react: 19.2.8 @@ -16725,14 +15658,19 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@5.0.0: + readdirp@5.1.1: optional: true - redis-errors@1.2.0: {} - - redis-parser@3.0.0: + redis@6.2.1(@opentelemetry/api@1.9.1): dependencies: - redis-errors: 1.2.0 + '@redis/bloom': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@redis/json': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/search': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/time-series': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' reduce-configs@1.1.2: {} @@ -16808,25 +15746,13 @@ snapshots: rou3@0.9.2: {} - rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): - dependencies: - '@rsbuild/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - optional: true - - rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): - dependencies: - '@rsbuild/core': 2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0) - react-server-dom-rspack: 0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - optional: true - rslog@2.3.0: {} - rspack-manifest-plugin@5.2.2(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23)): + rspack-manifest-plugin@5.2.2(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23)): dependencies: - '@rspack/lite-tapable': 1.1.2 + '@rspack/lite-tapable': 1.1.5 optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) run-applescript@7.1.0: {} @@ -16921,7 +15847,7 @@ snapshots: sass-embedded@1.100.0: dependencies: - '@bufbuild/protobuf': 2.13.0 + '@bufbuild/protobuf': 2.14.1 colorjs.io: 0.5.2 immutable: 5.1.9 rxjs: 7.8.2 @@ -16957,7 +15883,7 @@ snapshots: '@parcel/watcher': 2.6.0 optional: true - sax@1.6.0: {} + sax@1.6.1: {} scheduler@0.27.0: {} @@ -16985,13 +15911,13 @@ snapshots: semver@7.8.5: {} - serialize-javascript@7.0.7: {} + serialize-javascript@7.1.1: {} - seroval-plugins@1.6.4(seroval@1.6.4): + seroval-plugins@1.6.6(seroval@1.6.6): dependencies: - seroval: 1.6.4 + seroval: 1.6.6 - seroval@1.6.4: {} + seroval@1.6.6: {} set-cookie-parser@2.7.2: {} @@ -17021,36 +15947,37 @@ snapshots: shallowequal@1.1.0: {} - sharp@0.34.5: + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 shebang-command@2.0.0: dependencies: @@ -17058,8 +15985,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.10.0: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -17106,36 +16031,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socket.io-adapter@2.5.8(supports-color@10.2.2): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - ws: 8.21.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - socket.io-parser@4.2.7(supports-color@10.2.2): - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - - socket.io@4.8.1(supports-color@10.2.2): - dependencies: - accepts: 1.3.8 - base64id: 2.0.0 - cors: 2.8.6 - debug: 4.3.7(supports-color@10.2.2) - engine.io: 6.6.9(supports-color@10.2.2) - socket.io-adapter: 2.5.8(supports-color@10.2.2) - socket.io-parser: 4.2.7(supports-color@10.2.2) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -17149,8 +16044,6 @@ snapshots: split2@4.2.0: {} - standard-as-callback@2.1.0: {} - std-env@4.2.0: {} stop-iteration-iterator@1.1.0: @@ -17222,7 +16115,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-bom@3.0.0: {} @@ -17234,17 +16127,17 @@ snapshots: strip-json-comments@5.0.3: {} - stylehacks@7.0.11(postcss@8.5.26): + stylehacks@7.0.11(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + browserslist: 4.28.9 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 - stylehacks@8.0.1(postcss@8.5.26): + stylehacks@9.0.3(postcss@8.5.28): dependencies: - browserslist: 4.28.8 - postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + browserslist: 4.28.9 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 supports-color@10.2.2: {} @@ -17260,9 +16153,9 @@ snapshots: svg-element-attributes@1.3.1: {} - svg-parser@2.0.4: {} + svg-parser@2.1.0: {} - svgo@3.3.4: + svgo@3.3.5: dependencies: commander: 7.2.0 css-select: 5.2.2 @@ -17270,17 +16163,17 @@ snapshots: css-what: 6.2.2 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.6.0 + sax: 1.6.1 - svgo@4.0.2: + svgo@4.1.0: dependencies: commander: 11.1.0 - css-select: 5.2.2 + css-select: 6.0.0 css-tree: 3.2.1 - css-what: 6.2.2 + css-what: 7.0.0 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.6.0 + sax: 1.6.1 sync-child-process@1.0.2: dependencies: @@ -17296,11 +16189,10 @@ snapshots: tailwind-merge@3.6.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3): - dependencies: - tailwindcss: 4.3.3 + tailwind-variants@3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3): optionalDependencies: tailwind-merge: 3.6.0 + tailwindcss: 4.3.3 tailwindcss-animate@1.0.7(tailwindcss@4.3.3): dependencies: @@ -17312,7 +16204,7 @@ snapshots: tapable@2.3.3: {} - tar@7.5.21: + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -17320,18 +16212,18 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser@5.49.0: + terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 - thingies@2.6.0(tslib@2.8.1): + thingies@2.6.1(tslib@2.8.1): dependencies: tslib: 2.8.1 - tinyexec@1.2.4: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -17356,18 +16248,16 @@ snapshots: dependencies: typescript: 7.0.2 - ts-checker-rspack-plugin@1.6.1(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(tslib@2.8.1)(typescript@7.0.2): + ts-checker-rspack-plugin@1.6.1(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(@typescript/native-preview@7.0.0-dev.20260707.2)(typescript@7.0.2): dependencies: - '@rspack/lite-tapable': 1.1.2 + '@rspack/lite-tapable': 1.1.5 chokidar: 3.6.0 - memfs: 4.64.0(tslib@2.8.1) + memfs: 4.71.0 picocolors: 1.1.1 typescript: 7.0.2 optionalDependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) '@typescript/native-preview': 7.0.0-dev.20260707.2 - transitivePeerDependencies: - - tslib ts-deepmerge@8.0.0: {} @@ -17386,20 +16276,14 @@ snapshots: tslib@2.8.1: {} - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@4.1.0: {} - - type-fest@5.8.0: + type-fest@5.9.0: dependencies: tagged-tag: 1.0.0 type-is@2.1.0: dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 + content-type: 2.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typed-array-buffer@1.0.3: @@ -17435,13 +16319,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): + typescript-eslint@8.70.0(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -17473,24 +16356,9 @@ snapshots: ufo@1.6.4: {} - ultracite@7.10.2(oxfmt@0.63.0)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)): - dependencies: - '@clack/prompts': 1.7.0 - commander: 15.0.0 - cross-spawn: 7.0.6 - deepmerge: 4.3.1 - glob: 13.0.6 - jsonc-parser: 3.3.1 - nypm: 0.6.8 - yaml: 2.9.0 - zod: 4.4.3 - optionalDependencies: - oxfmt: 0.63.0 - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001) - - ultracite@7.10.7(oxfmt@0.64.0)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)): + ultracite@7.11.0(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))(prettier@3.9.6): dependencies: - '@clack/prompts': 1.7.0 + '@clack/prompts': 1.8.0 cli-truncate: 6.1.1 commander: 15.0.0 deepmerge: 4.3.1 @@ -17503,12 +16371,14 @@ snapshots: magicast: 0.5.4 nypm: 0.6.9 resolve.exports: 2.0.3 + semver: 7.8.5 string-width: 8.2.2 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6) optionalDependencies: - oxfmt: 0.64.0 - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001) + oxfmt: 0.66.0 + oxlint: 1.81.0(oxlint-tsgolint@7.0.2001) + prettier: 3.9.6 unbash@4.0.11: {} @@ -17519,13 +16389,13 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} - - undici-types@8.3.0: {} + undici-types@8.9.0: {} undici@7.28.0: {} - undici@8.10.1: {} + undici@7.29.0: {} + + undici@8.10.2: {} unenv@2.0.0-rc.24: dependencies: @@ -17535,9 +16405,9 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.3.1(browserslist@4.28.8): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -17559,12 +16429,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.22 - uuid@14.0.1: {} - varint@6.0.0: {} - vary@1.1.2: {} - walk-up-path@4.0.0: {} watchpack@2.5.2: @@ -17585,25 +16451,22 @@ snapshots: webpack-sources@3.5.1: {} - webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26): + webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.17.0 - acorn-import-phases: 1.0.4(acorn@8.17.0) - browserslist: 4.28.8 + acorn: 8.18.0 + browserslist: 4.28.9 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.3 - es-module-lexer: 2.3.1 - eslint-scope: 5.1.1 + enhanced-resolve: 5.24.5 + es-module-lexer: 2.3.2 events: 3.3.0 graceful-fs: 4.2.11 - loader-runner: 4.3.2 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + minimizer-webpack-plugin: 5.10.0(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 @@ -17611,6 +16474,7 @@ snapshots: webpack-sources: 3.5.1 transitivePeerDependencies: - '@minify-html/node' + - '@napi-rs/image' - '@swc/core' - '@swc/css' - '@swc/html' @@ -17619,8 +16483,11 @@ snapshots: - csso - esbuild - html-minifier-terser + - imagemin - lightningcss - postcss + - sharp + - svgo - uglify-js whatwg-mimetype@3.0.0: {} @@ -17672,26 +16539,24 @@ snapshots: dependencies: isexe: 2.0.0 - word-wrap@1.2.5: {} - - workerd@1.20260708.1: + workerd@1.20260730.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260708.1 - '@cloudflare/workerd-darwin-arm64': 1.20260708.1 - '@cloudflare/workerd-linux-64': 1.20260708.1 - '@cloudflare/workerd-linux-arm64': 1.20260708.1 - '@cloudflare/workerd-windows-64': 1.20260708.1 + '@cloudflare/workerd-darwin-64': 1.20260730.1 + '@cloudflare/workerd-darwin-arm64': 1.20260730.1 + '@cloudflare/workerd-linux-64': 1.20260730.1 + '@cloudflare/workerd-linux-arm64': 1.20260730.1 + '@cloudflare/workerd-windows-64': 1.20260730.1 - wrangler@4.110.0(@cloudflare/workers-types@5.20260810.1): + wrangler@4.116.0(@cloudflare/workers-types@5.20260810.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 4.20260708.1 + miniflare: 4.20260730.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260708.1 + workerd: 1.20260730.1 optionalDependencies: '@cloudflare/workers-types': 5.20260810.1 fsevents: 2.3.3 @@ -17761,17 +16626,17 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.17 + '@speed-highlight/core': 1.2.24 cookie: 1.1.1 youch-core: 0.3.3 zephyr-agent@1.2.4(supports-color@10.2.2): dependencies: '@toon-format/toon': 0.9.0 - axios: 1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) - axios-retry: 4.5.0(axios@1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)) + axios: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + axios-retry: 4.5.0(axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)) debug: 4.4.3(supports-color@10.2.2) - eventsource: 4.1.0 + eventsource: 4.1.1 git-url-parse: 16.1.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) is-ci: 4.1.0 @@ -17786,22 +16651,22 @@ snapshots: zephyr-edge-contract@1.2.4: {} - zephyr-rspack-plugin@1.2.4(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): + zephyr-rspack-plugin@1.2.4(@rspack/core@2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): dependencies: - '@rspack/core': 2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23) + '@rspack/core': 2.2.3(@module-federation/runtime-tools@2.9.0)(@swc/helpers@0.5.23) zephyr-agent: 1.2.4(supports-color@10.2.2) - zephyr-xpack-internal: 1.2.4(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + zephyr-xpack-internal: 1.2.4(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) transitivePeerDependencies: - supports-color - webpack - zephyr-xpack-internal@1.2.4(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): + zephyr-xpack-internal@1.2.4(supports-color@10.2.2)(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)): dependencies: - '@module-federation/automatic-vendor-federation': 1.2.1(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + '@module-federation/automatic-vendor-federation': 1.2.1(webpack@5.110.3(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@9.0.3(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(sharp@0.35.2)(svgo@4.1.0)) zephyr-agent: 1.2.4(supports-color@10.2.2) zephyr-edge-contract: 1.2.4 transitivePeerDependencies: - supports-color - webpack - zod@4.4.3: {} + zod@4.5.4(patch_hash=30612645af6a21cba7258bc2be3b51d9bfc0841b78dc581a56a4283c3bb142b6): {} diff --git a/app/pnpm-workspace.yaml b/app/pnpm-workspace.yaml index 0d1ef9889..97500f7e5 100644 --- a/app/pnpm-workspace.yaml +++ b/app/pnpm-workspace.yaml @@ -7,94 +7,49 @@ minimumReleaseAge: 1440 minimumReleaseAgeStrict: true minimumReleaseAgeIgnoreMissingTime: false minimumReleaseAgeExclude: - - '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-create-request@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-plugin-data-loader@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-plugin@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-prod-server@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-render@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-runtime-utils@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-server-core@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-server-runtime-extensions@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-server-runtime@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-server@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-types@3.8.2-ultramodern.12' - - '@bleedingdev/modern-js-utils@3.8.2-ultramodern.12' - - 'oxlint@1.79.0' - - '@oxlint/plugins@1.79.0' - - '@oxlint/binding-android-arm-eabi@1.79.0' - - '@oxlint/binding-android-arm64@1.79.0' - - '@oxlint/binding-darwin-arm64@1.79.0' - - '@oxlint/binding-darwin-x64@1.79.0' - - '@oxlint/binding-freebsd-x64@1.79.0' - - '@oxlint/binding-linux-arm-gnueabihf@1.79.0' - - '@oxlint/binding-linux-arm-musleabihf@1.79.0' - - '@oxlint/binding-linux-arm64-gnu@1.79.0' - - '@oxlint/binding-linux-arm64-musl@1.79.0' - - '@oxlint/binding-linux-ppc64-gnu@1.79.0' - - '@oxlint/binding-linux-riscv64-gnu@1.79.0' - - '@oxlint/binding-linux-riscv64-musl@1.79.0' - - '@oxlint/binding-linux-s390x-gnu@1.79.0' - - '@oxlint/binding-linux-x64-gnu@1.79.0' - - '@oxlint/binding-linux-x64-musl@1.79.0' - - '@oxlint/binding-openharmony-arm64@1.79.0' - - '@oxlint/binding-win32-arm64-msvc@1.79.0' - - '@oxlint/binding-win32-ia32-msvc@1.79.0' - - '@oxlint/binding-win32-x64-msvc@1.79.0' - - 'oxfmt@0.64.0' - - '@oxfmt/binding-android-arm-eabi@0.64.0' - - '@oxfmt/binding-android-arm64@0.64.0' - - '@oxfmt/binding-darwin-arm64@0.64.0' - - '@oxfmt/binding-darwin-x64@0.64.0' - - '@oxfmt/binding-freebsd-x64@0.64.0' - - '@oxfmt/binding-linux-arm-gnueabihf@0.64.0' - - '@oxfmt/binding-linux-arm-musleabihf@0.64.0' - - '@oxfmt/binding-linux-arm64-gnu@0.64.0' - - '@oxfmt/binding-linux-arm64-musl@0.64.0' - - '@oxfmt/binding-linux-ppc64-gnu@0.64.0' - - '@oxfmt/binding-linux-riscv64-gnu@0.64.0' - - '@oxfmt/binding-linux-riscv64-musl@0.64.0' - - '@oxfmt/binding-linux-s390x-gnu@0.64.0' - - '@oxfmt/binding-linux-x64-gnu@0.64.0' - - '@oxfmt/binding-linux-x64-musl@0.64.0' - - '@oxfmt/binding-openharmony-arm64@0.64.0' - - '@oxfmt/binding-win32-arm64-msvc@0.64.0' - - '@oxfmt/binding-win32-ia32-msvc@0.64.0' - - '@oxfmt/binding-win32-x64-msvc@0.64.0' - - '@rsbuild/core@2.2.0' - - '@rspack/binding-darwin-arm64@2.2.0' - - '@rspack/binding-darwin-x64@2.2.0' - - '@rspack/binding-linux-arm64-gnu@2.2.0' - - '@rspack/binding-linux-arm64-musl@2.2.0' - - '@rspack/binding-linux-ppc64-gnu@2.2.0' - - '@rspack/binding-linux-riscv64-gnu@2.2.0' - - '@rspack/binding-linux-riscv64-musl@2.2.0' - - '@rspack/binding-linux-s390x-gnu@2.2.0' - - '@rspack/binding-linux-x64-gnu@2.2.0' - - '@rspack/binding-linux-x64-musl@2.2.0' - - '@rspack/binding-wasm32-wasi@2.2.0' - - '@rspack/binding-win32-arm64-msvc@2.2.0' - - '@rspack/binding-win32-ia32-msvc@2.2.0' - - '@rspack/binding-win32-x64-msvc@2.2.0' - - '@rspack/binding@2.2.0' - - '@rspack/core@2.2.0' - - '@rstest/core@0.11.10' + - '@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-app-tools-extensions@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-bff-core@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-bff-effect@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-bff-runtime@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-builder@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-code-tools@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-create-request@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-i18n-utils@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-image@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-main-doc@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-bff-extensions@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-data-loader@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-polyfill@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-ssg@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-styled-components@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-plugin@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-prod-server@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-render@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-sandpack-react@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-server-core@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-server-runtime-extensions@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-server-utils@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-server@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-tsconfig@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-types@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-ultramodern-create@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-ultramodern-sandpack-profile@3.9.0-ultramodern.4' + - '@bleedingdev/modern-js-utils@3.9.0-ultramodern.4' trustPolicy: no-downgrade trustPolicyIgnoreAfter: 1440 trustPolicyExclude: - - '@effect/opentelemetry@4.0.0-beta.107' - - effect@4.0.0-beta.107 + - '@effect/opentelemetry@4.0.0-rc.112' + - effect@4.0.0-rc.112 blockExoticSubdeps: true engineStrict: true pmOnFail: error @@ -103,13 +58,18 @@ strictDepBuilds: true peerDependencyRules: allowedVersions: react: '>=19.0.0' + '@effect/vitest>effect': 4.0.0-rc.112 overrides: react-server-dom-rspack: 0.1.0 - '@tanstack/react-router': 1.170.25 - '@tanstack/router-core': 1.171.21 - '@effect/opentelemetry': 4.0.0-beta.107 - effect: 4.0.0-beta.107 + '@tanstack/react-router': 1.170.33 + '@tanstack/router-core': 1.171.28 + '@effect/opentelemetry': 4.0.0-rc.112 + effect: 4.0.0-rc.112 node-fetch: ^3.3.2 + '@tanstack/history': 1.162.2 + msgpackr: 2.1.0 + zod: 4.5.4 + '@effect/vitest': 4.0.0-rc.112 allowBuilds: '@parcel/watcher': true '@swc/core': true @@ -121,17 +81,19 @@ allowBuilds: sharp: true workerd: true patchedDependencies: - effect@4.0.0-beta.107: patches/effect-schema-sentinel.patch + effect-rstest@0.1.0: patches/effect-rstest@0.1.0.patch + effect@4.0.0-rc.112: patches/effect-cli-metadata@4.0.0-rc.112.patch + eslint-plugin-perfectionist@5.10.1: patches/eslint-plugin-perfectionist@5.10.1.patch '@vercel/nft@0.29.2': patches/@vercel__nft@0.29.2.patch - '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-builder@3.8.2-ultramodern.12.patch - '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-server-utils@3.8.2-ultramodern.12.patch - '@module-federation/bridge-react@2.8.0': patches/@module-federation__bridge-react@2.8.0.patch - '@module-federation/modern-js-v3@2.8.0': patches/@module-federation__modern-js-v3@2.8.0.patch - '@tanstack/router-core@1.171.21': patches/@tanstack__router-core@1.171.21.patch - '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-app-tools@3.8.2-ultramodern.12.patch - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-plugin-bff@3.8.2-ultramodern.12.patch - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch '@better-fetch/fetch@1.3.1': patches/@better-fetch__fetch@1.3.1.patch - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch + '@module-federation/dts-plugin@2.9.0': patches/@module-federation__dts-plugin@2.9.0.patch + '@module-federation/bridge-react@2.9.0': patches/@module-federation__bridge-react@2.9.0.patch + '@module-federation/modern-js-v3@2.9.0': patches/@module-federation__modern-js-v3@2.9.0.patch + '@module-federation/runtime-core@2.9.0': patches/@module-federation__runtime-core@2.9.0.patch + msgpackr@2.1.0: patches/msgpackr@2.1.0.patch + zod@4.5.4: patches/zod@4.5.4.patch drizzle-orm@1.0.0-rc.5-ab785fc: patches/drizzle-orm-rc5-declarations.patch - effect-rstest@0.1.0: patches/effect-rstest@0.1.0.patch +packageExtensions: + eslint-plugin-perfectionist@5.10.1: + dependencies: + '@typescript-eslint/types': 8.69.0 diff --git a/app/quality-audit/import-clone-evidence.mts b/app/quality-audit/import-clone-evidence.mts new file mode 100644 index 000000000..8f66db87e --- /dev/null +++ b/app/quality-audit/import-clone-evidence.mts @@ -0,0 +1,63 @@ +import { Effect, FileSystem, Path, Schema } from 'effect'; +import { parseSync } from 'oxc-parser'; + +const CloneLocation = Schema.Struct({ + end: Schema.optional(Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1))), + name: Schema.String, + start: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), +}); +const CloneReport = Schema.fromJsonString( + Schema.Struct({ + duplicates: Schema.Array( + Schema.Struct({ + firstFile: CloneLocation, + secondFile: CloneLocation, + }), + ), + }), +); + +/** Static binding declarations do not duplicate implementation behavior. */ +export const containsOnlyImportBindings = (source: string): boolean => { + const parsed = parseSync('clone.ts', source); + return ( + parsed.errors.length === 0 && + parsed.program.body.length > 1 && + parsed.program.body.every((statement) => statement.type === 'ImportDeclaration' && statement.specifiers.length > 0) + ); +}; + +export const importCloneEvidence = Effect.fn('QualityAudit.importCloneEvidence')(function* collectImportCloneEvidence( + root: string, + source: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const report = yield* Schema.decodeUnknownEffect(CloneReport)(source); + const provesBindings = Effect.fn('QualityAudit.provesImportBindings')(function* proveImportBindings( + location: typeof CloneLocation.Type, + ) { + const relative = path.relative(root, location.name); + if ( + location.end === undefined || + relative.startsWith('..') || + path.isAbsolute(relative) || + location.end < location.start + ) { + return false; + } + const text = yield* fs.readFileString(path.join(root, relative)); + const lines = text.split('\n'); + return ( + location.end <= lines.length && + containsOnlyImportBindings(lines.slice(location.start - 1, location.end).join('\n')) + ); + }); + const evidence = []; + for (const pair of report.duplicates) { + if ((yield* provesBindings(pair.firstFile)) && (yield* provesBindings(pair.secondFile))) { + evidence.push(pair); + } + } + return evidence; +}); diff --git a/app/quality-audit/knip-model.mts b/app/quality-audit/knip-model.mts index 64dc1c751..2cc87f786 100644 --- a/app/quality-audit/knip-model.mts +++ b/app/quality-audit/knip-model.mts @@ -1,8 +1,10 @@ import { createRequire } from 'node:module'; + import { Effect, FileSystem, Path, Schema } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { parseSync, Visitor } from 'oxc-parser'; import type { Node, ObjectExpression, Program } from 'oxc-parser'; + import { buildKnipRuntimeEvidence, workspaceDirectories } from './knip-runtime-model.mts'; const Strings = Schema.Array(Schema.String); @@ -114,8 +116,7 @@ const propertyName = (node: Node): string | undefined => { const declarations = (program: Program): Map => { const result = new Map(); for (const statement of program.body) { - const declaration = - statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; if (declaration?.type !== 'VariableDeclaration') { continue; } @@ -128,31 +129,20 @@ const declarations = (program: Program): Map => { return result; }; -const unwrap = ( - node: Node | undefined, - variables: ReadonlyMap, - depth = 0, -): Node | undefined => { +const unwrap = (node: Node | undefined, variables: ReadonlyMap, depth = 0): Node | undefined => { if (node === undefined || depth > 12) { return undefined; } if (node.type === 'Identifier') { return unwrap(variables.get(node.name), variables, depth + 1); } - if ( - node.type === 'TSAsExpression' || - node.type === 'TSSatisfiesExpression' || - node.type === 'TSNonNullExpression' - ) { + if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression' || node.type === 'TSNonNullExpression') { return unwrap(node.expression, variables, depth + 1); } return node; }; -const staticString = ( - node: Node | undefined, - variables: ReadonlyMap, -): string | undefined => { +const staticString = (node: Node | undefined, variables: ReadonlyMap): string | undefined => { const value = unwrap(node, variables); if (value?.type === 'Literal' && isString(value.value)) { return value.value; @@ -179,9 +169,7 @@ const objectExpression = ( const exportedObject = ({ program, variables }: SourceFacts): ObjectExpression | undefined => { const exported = program.body.find((node) => node.type === 'ExportDefaultDeclaration'); - return exported?.type === 'ExportDefaultDeclaration' - ? objectExpression(exported.declaration, variables) - : undefined; + return exported?.type === 'ExportDefaultDeclaration' ? objectExpression(exported.declaration, variables) : undefined; }; const objectValue = (object: ObjectExpression | undefined, name: string): Node | undefined => { @@ -231,9 +219,7 @@ const exportLeaves = (value: typeof ExportsSchema.Type | undefined): string[] => if (value === undefined) { return []; } - return Object.values(value).flatMap((entry) => - isString(entry) ? [entry] : Object.values(entry), - ); + return Object.values(value).flatMap((entry) => (isString(entry) ? [entry] : Object.values(entry))); }; const importedUrlBase = (node: Node | undefined): boolean => @@ -247,7 +233,10 @@ const parseSource = Effect.fn('QualityAudit.parseKnipModelSource')(function* par source: string, ) { const result = yield* Effect.try({ - catch: () => new KnipModelError({ reason: `Unable to parse quality model source ${file}` }), + catch: () => + new KnipModelError({ + reason: `Unable to parse quality model source ${file}`, + }), try: () => parseSync(file, source), }); if (result.errors.length > 0) { @@ -255,7 +244,12 @@ const parseSource = Effect.fn('QualityAudit.parseKnipModelSource')(function* par reason: `Invalid quality model source ${file}: ${result.errors[0]?.message}`, }); } - return { file, program: result.program, source, variables: declarations(result.program) }; + return { + file, + program: result.program, + source, + variables: declarations(result.program), + }; }); const sourceFiles = Effect.fn('QualityAudit.knipModelSourceFiles')(function* readModelFiles( @@ -268,9 +262,7 @@ const sourceFiles = Effect.fn('QualityAudit.knipModelSourceFiles')(function* rea const names = yield* fs.readDirectory(path.join(root, relative)); const children = names.flatMap((name) => { const child = relative.length > 0 ? `${relative}/${name}` : name; - return name.startsWith('.') || skippedDirectories.has(name) || child === invalidFixtures - ? [] - : [child]; + return name.startsWith('.') || skippedDirectories.has(name) || child === invalidFixtures ? [] : [child]; }); for (const child of children) { const stat = yield* fs.stat(path.join(root, child)); @@ -389,12 +381,7 @@ const federationEvidence = (facts: SourceFacts, workspace: string): KnipModelEvi } const object = exportedObject(facts); return ['remotes', 'shared', 'exposes'].flatMap((field) => - federationFieldEvidence( - facts, - workspace, - field, - objectExpression(objectValue(object, field), facts.variables), - ), + federationFieldEvidence(facts, workspace, field, objectExpression(objectValue(object, field), facts.variables)), ); }; @@ -449,10 +436,7 @@ const isAppBuildTemplate = (argument: Node | undefined): boolean => argument.quasis[1]?.value.cooked === '/shared/ultramodern-build.ts'; const configuredBuildDirectories = (facts: SourceFacts, field: string): string[] => { - const contract = objectExpression( - facts.variables.get('workspaceValidationContractDefinition'), - facts.variables, - ); + const contract = objectExpression(facts.variables.get('workspaceValidationContractDefinition'), facts.variables); const topology = objectExpression(objectValue(contract, 'topology'), facts.variables); const compact = objectExpression(objectValue(topology, 'compactConfig'), facts.variables); const collection = field === 'apps' ? objectValue(compact, 'apps') : objectValue(contract, field); @@ -469,23 +453,13 @@ const configuredBuildDirectories = (facts: SourceFacts, field: string): string[] }); }; -const buildSourceScope = ( - source: Node | undefined, - variables: ReadonlyMap, -): Node | undefined => { +const buildSourceScope = (source: Node | undefined, variables: ReadonlyMap): Node | undefined => { const reader = unwrap(source, variables); - if ( - reader?.type !== 'CallExpression' || - reader.callee.type !== 'Identifier' || - reader.callee.name !== 'readText' - ) { + if (reader?.type !== 'CallExpression' || reader.callee.type !== 'Identifier' || reader.callee.name !== 'readText') { return undefined; } const [argument] = reader.arguments; - if ( - argument?.type !== 'TemplateLiteral' || - argument.quasis[1]?.value.cooked !== '/shared/ultramodern-build.ts' - ) { + if (argument?.type !== 'TemplateLiteral' || argument.quasis[1]?.value.cooked !== '/shared/ultramodern-build.ts') { return undefined; } return argument.expressions[0]; @@ -525,15 +499,11 @@ const validatedBuildExport = ( const [source] = node.arguments; return name === undefined || source === undefined ? undefined : { name, source }; } - if ( - node.callee.type !== 'MemberExpression' || - propertyName(node.callee.property) !== 'includes' - ) { + if (node.callee.type !== 'MemberExpression' || propertyName(node.callee.property) !== 'includes') { return undefined; } const expected = staticString(node.arguments[0], variables); - const { name } = - expected?.match(/^export const (?[A-Za-z_$][A-Za-z0-9_$]*)\b/u)?.groups ?? {}; + const { name } = expected?.match(/^export const (?[A-Za-z_$][A-Za-z0-9_$]*)\b/u)?.groups ?? {}; return name === undefined ? undefined : { name, source: node.callee.object }; }; @@ -637,11 +607,7 @@ const sourceEvidence = ( ); } const recordUrl = (node: Extract) => { - if ( - node.callee.type !== 'Identifier' || - node.callee.name !== 'URL' || - !importedUrlBase(node.arguments[1]) - ) { + if (node.callee.type !== 'Identifier' || node.callee.name !== 'URL' || !importedUrlBase(node.arguments[1])) { return; } const target = staticString(node.arguments[0], scopedVariables(facts, node.start)); @@ -665,16 +631,7 @@ const sourceEvidence = ( for (const argument of args.elements) { const target = staticString(argument ?? undefined, facts.variables); if (target !== undefined && sourceExtension.test(target)) { - result.push( - evidenceAt( - facts, - workspace, - 'file', - target, - node.start, - 'Node subprocess source argument', - ), - ); + result.push(evidenceAt(facts, workspace, 'file', target, node.start, 'Node subprocess source argument')); } } } @@ -704,10 +661,7 @@ const sourceEvidence = ( const recordLintConfig = (node: Extract) => { const consumers = new Map([ ['tools/oxlint/effect-native/report.mts', 'report.config.ts'], - [ - 'tools/oxlint/effect-native/tests/repository-policy.test.mts', - 'repository-policy.config.ts', - ], + ['tools/oxlint/effect-native/tests/repository-policy.test.mts', 'repository-policy.config.ts'], ]); const config = consumers.get(facts.file); const [joined] = node.arguments; @@ -738,12 +692,7 @@ const sourceEvidence = ( if ( facts.file !== 'tools/oxlint/effect-native/tests/shared-helpers.test.mts' || staticString(objectValue(node, 'name'), facts.variables) !== 'shared-helpers-probe' || - !isJoinedSourceSpecifier( - specifier, - 'testsDirectory', - 'shared-helpers-probe.ts', - facts.variables, - ) + !isJoinedSourceSpecifier(specifier, 'testsDirectory', 'shared-helpers-probe.ts', facts.variables) ) { return; } @@ -774,21 +723,8 @@ const sourceEvidence = ( if (facts.file === 'scripts/quality-audit.mts') { const recordAuditStep = (node: ObjectExpression) => { const tool = staticString(objectValue(node, 'tool'), facts.variables); - if ( - tool !== undefined && - objectValue(node, 'args') !== undefined && - objectValue(node, 'name') !== undefined - ) { - result.push( - evidenceAt( - facts, - workspace, - 'dependency', - tool, - node.start, - 'Quality audit subprocess step', - ), - ); + if (tool !== undefined && objectValue(node, 'args') !== undefined && objectValue(node, 'name') !== undefined) { + result.push(evidenceAt(facts, workspace, 'dependency', tool, node.start, 'Quality audit subprocess step')); } }; new Visitor({ ObjectExpression: recordAuditStep }).visit(facts.program); @@ -805,14 +741,9 @@ const drizzleFactories = (facts: SourceFacts): ReadonlySet => { node.type === 'ImportDeclaration' ? node.specifiers.flatMap((specifier) => specifier.type === 'ImportSpecifier' && - [ - 'pgSchema', - 'pgTable', - 'pgEnum', - 'pgSequence', - 'pgView', - 'pgMaterializedView', - ].includes(propertyName(specifier.imported) ?? '') + ['pgSchema', 'pgTable', 'pgEnum', 'pgSequence', 'pgView', 'pgMaterializedView'].includes( + propertyName(specifier.imported) ?? '', + ) ? [specifier.local.name] : [], ) @@ -834,27 +765,17 @@ const isDrizzleDeclaration = ( if (expression.callee.type === 'Identifier') { return factories.has(expression.callee.name); } - if ( - expression.callee.type === 'MemberExpression' && - propertyName(expression.callee.property) === 'table' - ) { + if (expression.callee.type === 'MemberExpression' && propertyName(expression.callee.property) === 'table') { return isDrizzleDeclaration(expression.callee.object, variables, factories, depth + 1); } return false; }; -const reflectedDrizzleExports = ( - facts: SourceFacts, - workspace: string, - configSource: string, -): KnipModelEvidence[] => { +const reflectedDrizzleExports = (facts: SourceFacts, workspace: string, configSource: string): KnipModelEvidence[] => { const factories = drizzleFactories(facts); const result: KnipModelEvidence[] = []; for (const node of facts.program.body) { - if ( - node.type !== 'ExportNamedDeclaration' || - node.declaration?.type !== 'VariableDeclaration' - ) { + if (node.type !== 'ExportNamedDeclaration' || node.declaration?.type !== 'VariableDeclaration') { continue; } for (const declaration of node.declaration.declarations) { @@ -881,9 +802,7 @@ const reflectedDrizzleExports = ( const resolver = createRequire(import.meta.url); const packageName = (specifier: string): string => - specifier.startsWith('@') - ? specifier.split('/').slice(0, 2).join('/') - : (specifier.split('/')[0] ?? specifier); + specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : (specifier.split('/')[0] ?? specifier); const isRequireResolve = (node: Node): boolean => node.type === 'CallExpression' && @@ -930,14 +849,10 @@ const resolveStaticCall = ( return undefined; } const resolvedAnchor = resolvePath(anchor); - return resolvedAnchor === undefined - ? undefined - : resolveInstalledDependency(target, resolvedAnchor); + return resolvedAnchor === undefined ? undefined : resolveInstalledDependency(target, resolvedAnchor); }; -const isImportMetaUrl = ( - node: Node | undefined, -): node is Extract => +const isImportMetaUrl = (node: Node | undefined): node is Extract => node?.type === 'NewExpression' && node.callee.type === 'Identifier' && node.callee.name === 'URL' && @@ -984,13 +899,9 @@ const staticPath = ( return staticPath(value.arguments[0], variables, file, path, depth + 1); } if (isRequireResolve(value)) { - return resolveStaticCall(value, variables, (argument) => - staticPath(argument, variables, file, path, depth + 1), - ); + return resolveStaticCall(value, variables, (argument) => staticPath(argument, variables, file, path, depth + 1)); } - return resolveJoinedPath(value, path, (argument) => - staticPath(argument, variables, file, path, depth + 1), - ); + return resolveJoinedPath(value, path, (argument) => staticPath(argument, variables, file, path, depth + 1)); }; const hasNativeRequire = (facts: SourceFacts, variables: ReadonlyMap): boolean => { @@ -1034,12 +945,7 @@ const resolverEvidence = ( if (target === undefined || target.startsWith('.') || target.startsWith('node:')) { return; } - const anchor = staticPath( - resolverAnchor(node, variables), - variables, - path.join(appRoot, facts.file), - path, - ); + const anchor = staticPath(resolverAnchor(node, variables), variables, path.join(appRoot, facts.file), path); const resolved = staticPath(node, variables, path.join(appRoot, facts.file), path); if (anchor === undefined || resolved === undefined) { return; @@ -1072,10 +978,7 @@ const drizzleEvidence = ( continue; } const schema = staticString(objectValue(exportedObject(facts), 'schema'), facts.variables); - const target = - schema === undefined - ? undefined - : factsByPath.get(`${prefix}${schema.replace(/^\.\//u, '')}`); + const target = schema === undefined ? undefined : factsByPath.get(`${prefix}${schema.replace(/^\.\//u, '')}`); if (target !== undefined) { evidence.push(...reflectedDrizzleExports(target, workspace, facts.file)); } @@ -1083,9 +986,7 @@ const drizzleEvidence = ( return evidence; }; -const nearestPackage = Effect.fn('QualityAudit.nearestPackage')(function* readNearestPackage( - anchor: string, -) { +const nearestPackage = Effect.fn('QualityAudit.nearestPackage')(function* readNearestPackage(anchor: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; let directory = anchor; @@ -1098,9 +999,9 @@ const nearestPackage = Effect.fn('QualityAudit.nearestPackage')(function* readNe while (true) { const manifest = path.join(directory, 'package.json'); if (yield* fs.exists(manifest)) { - const declared = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(DependencyDeclarationSchema), - )(yield* fs.readFileString(manifest)); + const declared = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(DependencyDeclarationSchema))( + yield* fs.readFileString(manifest), + ); if (declared.name !== undefined) { return { declared, manifest }; } @@ -1113,39 +1014,40 @@ const nearestPackage = Effect.fn('QualityAudit.nearestPackage')(function* readNe } }); -const classifyProducerTarget = Effect.fn('QualityAudit.classifyProducerTarget')( - function* classifyProducer(fact: KnipModelEvidence, manifest: string) { - const fs = yield* FileSystem.FileSystem; - const resolved = resolveInstalledDependency(fact.target, manifest); - if (resolved === undefined || fact.resolved === undefined) { - return { - ...fact, - ...unprovenResolver, - producerManifest: manifest, - reason: `${fact.reason}; declaring producer could not resolve the exact target`, - }; - } - const [producerResolved, selectedResolved] = yield* Effect.all([ - fs.realPath(resolved), - fs.realPath(fact.resolved), - ]); - if (producerResolved !== selectedResolved) { - return { - ...fact, - ...unprovenResolver, - producerManifest: manifest, - producerResolved, - reason: `${fact.reason}; declaring producer ${manifest} resolves a different canonical target ${producerResolved}; selected target ${selectedResolved}`, - resolved: selectedResolved, - }; - } - return { ...fact, owningManifest: manifest }; - }, -); +const classifyProducerTarget = Effect.fn('QualityAudit.classifyProducerTarget')(function* classifyProducer( + fact: KnipModelEvidence, + manifest: string, +) { + const fs = yield* FileSystem.FileSystem; + const resolved = resolveInstalledDependency(fact.target, manifest); + if (resolved === undefined || fact.resolved === undefined) { + return { + ...fact, + ...unprovenResolver, + producerManifest: manifest, + reason: `${fact.reason}; declaring producer could not resolve the exact target`, + }; + } + const [producerResolved, selectedResolved] = yield* Effect.all([fs.realPath(resolved), fs.realPath(fact.resolved)]); + if (producerResolved !== selectedResolved) { + return { + ...fact, + ...unprovenResolver, + producerManifest: manifest, + producerResolved, + reason: `${fact.reason}; declaring producer ${manifest} resolves a different canonical target ${producerResolved}; selected target ${selectedResolved}`, + resolved: selectedResolved, + }; + } + return { ...fact, owningManifest: manifest }; +}); const proveVendorDependency = Effect.fn('QualityAudit.proveVendorDependency')(function* proveVendor( fact: KnipModelEvidence, - owner: { readonly declared: typeof DependencyDeclarationSchema.Type; readonly manifest: string }, + owner: { + readonly declared: typeof DependencyDeclarationSchema.Type; + readonly manifest: string; + }, ) { if (!owner.manifest.includes('/node_modules/')) { return null; @@ -1171,29 +1073,32 @@ const proveVendorDependency = Effect.fn('QualityAudit.proveVendorDependency')(fu return unproven; }); -const proveResolverOwnership = Effect.fn('QualityAudit.proveResolverOwnership')( - function* proveResolver(fact: KnipModelEvidence) { - if (fact.anchor === undefined) { - return null; - } - const owner = yield* nearestPackage(fact.anchor); - if (owner === null) { - return null; - } - const dependencies = { ...owner.declared.dependencies, ...owner.declared.devDependencies }; - if (Object.hasOwn(dependencies, fact.target)) { - return { ...fact, owningManifest: owner.manifest }; +const proveResolverOwnership = Effect.fn('QualityAudit.proveResolverOwnership')(function* proveResolver( + fact: KnipModelEvidence, +) { + if (fact.anchor === undefined) { + return null; + } + const owner = yield* nearestPackage(fact.anchor); + if (owner === null) { + return null; + } + const dependencies = { + ...owner.declared.dependencies, + ...owner.declared.devDependencies, + }; + if (Object.hasOwn(dependencies, fact.target)) { + return { ...fact, owningManifest: owner.manifest }; + } + const producerProof = yield* proveVendorDependency(fact, owner); + return ( + producerProof ?? { + ...fact, + ...unprovenResolver, + reason: `${fact.reason}; anchor package ${owner.manifest} does not declare ${fact.target}, and no producer selecting the same target was proven`, } - const producerProof = yield* proveVendorDependency(fact, owner); - return ( - producerProof ?? { - ...fact, - ...unprovenResolver, - reason: `${fact.reason}; anchor package ${owner.manifest} does not declare ${fact.target}, and no producer selecting the same target was proven`, - } - ); - }, -); + ); +}); const workspaceModel = Effect.fn('QualityAudit.knipWorkspaceModel')(function* buildWorkspace( appRoot: string, @@ -1215,9 +1120,7 @@ const workspaceModel = Effect.fn('QualityAudit.knipWorkspaceModel')(function* bu const dependencies = new Set(current.ignoreDependencies); const add = (fact: KnipModelEvidence) => { if (fact.kind === 'entry' || fact.kind === 'file') { - const target = path - .relative(appRoot, path.resolve(appRoot, prefix, fact.target)) - .replaceAll('\\', '/'); + const target = path.relative(appRoot, path.resolve(appRoot, prefix, fact.target)).replaceAll('\\', '/'); if (!fileSet.has(target)) { return; } @@ -1249,16 +1152,18 @@ const workspaceModel = Effect.fn('QualityAudit.knipWorkspaceModel')(function* bu evidence.push(proof); } } - const directory = path.dirname( - path.relative(path.resolve(appRoot, prefix), path.resolve(appRoot, file)), - ); + const directory = path.dirname(path.relative(path.resolve(appRoot, prefix), path.resolve(appRoot, file))); for (const fact of sourceEvidence(facts, workspace, directory, path)) { add(fact); } } evidence.push(...drizzleEvidence(factsByPath, prefix, workspace)); return { - config: { ...current, entry: [...entries], ignoreDependencies: [...dependencies] }, + config: { + ...current, + entry: [...entries], + ignoreDependencies: [...dependencies], + }, evidence, }; }); @@ -1316,9 +1221,7 @@ export const buildKnipModel = Effect.fn('QualityAudit.buildKnipModel')(function* (fact) => fact.kind === 'export' || fact.kind === 'file' || - (fact.kind === 'dependency' && - fact.workspace === '.' && - fact.reason !== 'Module Federation remotes consumer'), + (fact.kind === 'dependency' && fact.workspace === '.' && fact.reason !== 'Module Federation remotes consumer'), ); const consumerSource = reflected .map((fact, index) => { diff --git a/app/quality-audit/knip-reporter.mts b/app/quality-audit/knip-reporter.mts index 5c45a8d5b..6503ec847 100644 --- a/app/quality-audit/knip-reporter.mts +++ b/app/quality-audit/knip-reporter.mts @@ -1,21 +1,15 @@ -import type { ReporterOptions } from 'knip'; import { Result, Schema } from 'effect'; +import type { ReporterOptions } from 'knip'; // Knip's stock JSON reporter omits coverage. This second NDJSON record preserves // the analyzer's own counters without deriving success from its exit status. -export default function reportCoverage({ - configurationHints, - counters, - includedWorkspaceDirs, -}: ReporterOptions): void { +export default function reportCoverage({ configurationHints, counters, includedWorkspaceDirs }: ReporterOptions): void { const source = Result.getOrThrow( Schema.encodeResult(Schema.fromJsonString(Schema.Unknown))({ configurationHints, coverage: counters, findingCounts: Object.fromEntries( - Object.entries(counters).filter( - ([category]) => category !== 'processed' && category !== 'total', - ), + Object.entries(counters).filter(([category]) => category !== 'processed' && category !== 'total'), ), workspaces: includedWorkspaceDirs, }), diff --git a/app/quality-audit/knip-runtime-model.mts b/app/quality-audit/knip-runtime-model.mts index 9040f1110..8fa08b415 100644 --- a/app/quality-audit/knip-runtime-model.mts +++ b/app/quality-audit/knip-runtime-model.mts @@ -1,8 +1,9 @@ -import { hasUltramodernDispatch } from '../scripts/shared/ultramodern-wrapper-source.mts'; import { Effect, FileSystem, Path, Schema } from 'effect'; import { parse as parseJsonc } from 'jsonc-parser'; import type { ParseError } from 'jsonc-parser'; import { parseSync } from 'oxc-parser'; + +import { hasUltramodernDispatch } from '../scripts/shared/ultramodern-wrapper-source.mts'; import type { KnipModelEvidence } from './knip-model.mts'; const EFFECT_TSGO = '@effect/tsgo'; @@ -14,9 +15,7 @@ const Manifest = Schema.fromJsonString( scripts: Schema.optional(Schema.Record(Schema.String, Schema.String)), }), ); -const InstalledPackage = Schema.fromJsonString( - Schema.Struct({ name: Schema.String, version: Schema.String }), -); +const InstalledPackage = Schema.fromJsonString(Schema.Struct({ name: Schema.String, version: Schema.String })); const documentsBuiltInPlugin = (readme: string): boolean => readme.includes('A wrapper around [TypeScript-Go]') && readme.includes('Adding the `@effect/tsgo` dependency to your project.') && @@ -42,7 +41,9 @@ const parseTsconfig = Effect.fn('QualityAudit.parseTsconfig')(function* parseTsc source: string, ) { const errors: ParseError[] = []; - const parsed: unknown = parseJsonc(source, errors, { allowTrailingComma: true }); + const parsed: unknown = parseJsonc(source, errors, { + allowTrailingComma: true, + }); const [error] = errors; if (error !== undefined) { return yield* new InvalidTsconfig({ file, offset: error.offset }); @@ -70,9 +71,7 @@ const packageName = (specifier: string): string | undefined => { if (/^(?:[./#]|[a-z]+:)/u.test(specifier)) { return undefined; } - return specifier.startsWith('@') - ? specifier.split('/').slice(0, 2).join('/') - : specifier.split('/')[0]; + return specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]; }; const uncomment = (file: string, source: string | undefined): string | undefined => { if (source === undefined) { @@ -93,65 +92,47 @@ const uncomment = (file: string, source: string | undefined): string | undefined }; const invokedShell = (command: string): string | undefined => { - const { shell } = - /^(?:sh|bash)\s+(?:\.\/)?(?[\w./-]+\.sh)(?:\s|$)/u.exec(command)?.groups ?? {}; + const { shell } = /^(?:sh|bash)\s+(?:\.\/)?(?[\w./-]+\.sh)(?:\s|$)/u.exec(command)?.groups ?? {}; if (shell === undefined || shell.includes('..')) { return undefined; } return shell; }; -const cssDependencies = ( - cssFile: string, - css: string, - layoutFile: string, - workspace: string, -): KnipModelEvidence[] => { +const cssDependencies = (cssFile: string, css: string, layoutFile: string, workspace: string): KnipModelEvidence[] => { const result: KnipModelEvidence[] = []; - const withoutComments = css.replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => - comment.replaceAll(/[^\n]/gu, ' '), - ); - for (const match of withoutComments.matchAll( - /@import\s+(?:url\(\s*)?["'](?[^"']+)["']/gu, - )) { + const withoutComments = css.replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, ' ')); + for (const match of withoutComments.matchAll(/@import\s+(?:url\(\s*)?["'](?[^"']+)["']/gu)) { const { specifier = '' } = match.groups ?? {}; const target = packageName(specifier); if (target === undefined || target.length === 0) { continue; } result.push( - at( - cssFile, - css, - match.index, - workspace, - 'dependency', - target, - `CSS package import reached from ${layoutFile}`, - ), + at(cssFile, css, match.index, workspace, 'dependency', target, `CSS package import reached from ${layoutFile}`), ); } return result; }; -export const workspaceDirectories = Effect.fn('QualityAudit.knipWorkspaces')( - function* readModelWorkspaces(appRoot: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspaces = ['.']; - for (const directory of ['apps', 'verticals', 'packages']) { - const location = path.join(appRoot, directory); - if (!(yield* fs.exists(location))) { - continue; - } - for (const name of yield* fs.readDirectory(location)) { - if (yield* fs.exists(path.join(location, name, 'package.json'))) { - workspaces.push(`${directory}/${name}`); - } +export const workspaceDirectories = Effect.fn('QualityAudit.knipWorkspaces')(function* readModelWorkspaces( + appRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = ['.']; + for (const directory of ['apps', 'verticals', 'packages']) { + const location = path.join(appRoot, directory); + if (!(yield* fs.exists(location))) { + continue; + } + for (const name of yield* fs.readDirectory(location)) { + if (yield* fs.exists(path.join(location, name, 'package.json'))) { + workspaces.push(`${directory}/${name}`); } } - return workspaces; - }, -); + } + return workspaces; +}); /** Model only source-backed runtime contracts; never execute a wrapper or vendor module. */ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntimeEvidence')( @@ -159,9 +140,7 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const evidence: KnipModelEvidence[] = []; - const read = Effect.fn('QualityAudit.readRuntimeModelFile')(function* readOptional( - file: string, - ) { + const read = Effect.fn('QualityAudit.readRuntimeModelFile')(function* readOptional(file: string) { return (yield* fs.exists(path.join(appRoot, file))) ? yield* fs.readFileString(path.join(appRoot, file)) : undefined; @@ -184,15 +163,9 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime if (!source.includes(`cd "\${script_directory}/.."`) || !source.includes('dirname -- "$0"')) { return; } - for (const match of source.matchAll( - /^\s*node\s+(?[\w./-]+\.[cm]?[jt]s)(?:\s|$)/gmu, - )) { + for (const match of source.matchAll(/^\s*node\s+(?[\w./-]+\.[cm]?[jt]s)(?:\s|$)/gmu)) { const { target } = match.groups ?? {}; - if ( - target !== undefined && - !target.includes('..') && - (yield* read(`${prefix}${target}`)) !== undefined - ) { + if (target !== undefined && !target.includes('..') && (yield* read(`${prefix}${target}`)) !== undefined) { evidence.push( at( file, @@ -207,76 +180,72 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime } } }); - const testConsumerEvidence = Effect.fn('QualityAudit.testConsumerEvidence')( - function* testConsumerEvidence( - manifestFile: string, - manifestText: string, - commands: readonly string[], - prefix: string, - workspace: string, + const testConsumerEvidence = Effect.fn('QualityAudit.testConsumerEvidence')(function* testConsumerEvidence( + manifestFile: string, + manifestText: string, + commands: readonly string[], + prefix: string, + workspace: string, + ) { + // Rstest's default-config loader consumes default, not every named export. + const config = `${prefix}rstest.config.ts`; + if ( + commands.some((command) => /^rstest(?:\s|$)/u.test(command) && !/--config(?:\s|=)/u.test(command)) && + (yield* read(config)) !== undefined ) { - // Rstest's default-config loader consumes default, not every named export. - const config = `${prefix}rstest.config.ts`; - if ( - commands.some( - (command) => /^rstest(?:\s|$)/u.test(command) && !/--config(?:\s|=)/u.test(command), - ) && - (yield* read(config)) !== undefined - ) { + evidence.push( + at( + manifestFile, + manifestText, + 0, + workspace, + 'file', + 'rstest.config.ts', + 'Package script invokes Rstest default configuration discovery', + ), + at( + manifestFile, + manifestText, + 0, + workspace, + 'export', + `${config}#default`, + 'Rstest default-config loader consumes the default export', + ), + ); + } + const tsconfigFile = `${prefix}tsconfig.json`; + const tsconfigText = yield* read(tsconfigFile); + if (tsconfigText === undefined) { + return; + } + const tsconfig = yield* parseTsconfig(tsconfigFile, tsconfigText); + // Only the explicit directory inclusion contract is modeled here. Do not + // turn all compiler inputs into roots or mark their exports as consumed. + if ( + tsconfig.include?.includes('src') !== true || + tsconfig.exclude !== undefined || + tsconfig.files !== undefined || + !(yield* fs.exists(path.join(appRoot, prefix, 'src'))) + ) { + return; + } + for (const name of yield* fs.readDirectory(path.join(appRoot, prefix, 'src'))) { + if (name.endsWith('.type-test.ts')) { evidence.push( at( - manifestFile, - manifestText, + tsconfigFile, + tsconfigText, 0, workspace, 'file', - 'rstest.config.ts', - 'Package script invokes Rstest default configuration discovery', - ), - at( - manifestFile, - manifestText, - 0, - workspace, - 'export', - `${config}#default`, - 'Rstest default-config loader consumes the default export', + `src/${name}`, + 'TypeScript include src compiles this type-test module; exports remain audited', ), ); } - const tsconfigFile = `${prefix}tsconfig.json`; - const tsconfigText = yield* read(tsconfigFile); - if (tsconfigText === undefined) { - return; - } - const tsconfig = yield* parseTsconfig(tsconfigFile, tsconfigText); - // Only the explicit directory inclusion contract is modeled here. Do not - // turn all compiler inputs into roots or mark their exports as consumed. - if ( - tsconfig.include?.includes('src') !== true || - tsconfig.exclude !== undefined || - tsconfig.files !== undefined || - !(yield* fs.exists(path.join(appRoot, prefix, 'src'))) - ) { - return; - } - for (const name of yield* fs.readDirectory(path.join(appRoot, prefix, 'src'))) { - if (name.endsWith('.type-test.ts')) { - evidence.push( - at( - tsconfigFile, - tsconfigText, - 0, - workspace, - 'file', - `src/${name}`, - 'TypeScript include src compiles this type-test module; exports remain audited', - ), - ); - } - } - }, - ); + } + }); const cssEvidence = Effect.fn('QualityAudit.cssEvidence')(function* cssEvidence( extension: string, prefix: string, @@ -306,15 +275,31 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime } } }); - const federationEvidence = Effect.fn('QualityAudit.federationEvidence')( - function* federationEvidence(prefix: string, workspace: string) { - const federationFile = `${prefix}module-federation.config.ts`; - const federation = uncomment(federationFile, yield* read(federationFile)); - if ( - federation?.includes("from '@modern-js/app-tools/config'") === true && - federation.includes('resolveEffectTsgoCompiler({ from: import.meta.url })') - ) { - const offset = federation.indexOf('resolveEffectTsgoCompiler({ from: import.meta.url })'); + const federationEvidence = Effect.fn('QualityAudit.federationEvidence')(function* federationEvidence( + prefix: string, + workspace: string, + ) { + const federationFile = `${prefix}module-federation.config.ts`; + const federation = uncomment(federationFile, yield* read(federationFile)); + if ( + federation?.includes("from '@modern-js/app-tools/config'") === true && + /resolveEffectTsgoCompiler\s*\(\s*\{\s*from:\s*import\.meta\.url\s*,?\s*\}\s*\)/u.test(federation) + ) { + const offset = federation.indexOf('resolveEffectTsgoCompiler'); + evidence.push( + at( + federationFile, + federation, + offset, + workspace, + 'dependency', + EFFECT_TSGO, + 'Framework DTS resolver resolves the Effect TSGo package from this configuration module', + ), + ); + const readmeFile = `${prefix}node_modules/@effect/tsgo/README.md`; + const readme = yield* read(readmeFile); + if (readme?.includes('tries `typescript`, then `@typescript/native`') === true) { evidence.push( at( federationFile, @@ -322,28 +307,13 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime offset, workspace, 'dependency', - EFFECT_TSGO, - 'Framework DTS resolver resolves the Effect TSGo package from this configuration module', + '@typescript/native', + `Effect TSGo native compiler fallback documented in ${readmeFile}`, ), ); - const readmeFile = `${prefix}node_modules/@effect/tsgo/README.md`; - const readme = yield* read(readmeFile); - if (readme?.includes('tries `typescript`, then `@typescript/native`') === true) { - evidence.push( - at( - federationFile, - federation, - offset, - workspace, - 'dependency', - '@typescript/native', - `Effect TSGo native compiler fallback documented in ${readmeFile}`, - ), - ); - } } - }, - ); + } + }); const zeropsEvidence = Effect.fn('QualityAudit.zeropsEvidence')(function* zeropsEvidence() { // Zerops buildCommands run from the repository root and explicitly cd into app. for (const file of ['zerops.yaml', 'zerops.yml']) { @@ -355,11 +325,7 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime /^\s*-\s+cd app && (?:[A-Z_]+=\S+\s+)*node\s+(?[\w./-]+\.[cm]?[jt]s)(?:\s|$)/gmu, )) { const { target } = match.groups ?? {}; - if ( - target !== undefined && - !target.includes('..') && - (yield* read(target)) !== undefined - ) { + if (target !== undefined && !target.includes('..') && (yield* read(target)) !== undefined) { evidence.push( at( file, @@ -375,36 +341,26 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime } } }); - const tsgoDocumentation = Effect.fn('QualityAudit.tsgoDocumentation')( - function* tsgoDocumentation() { - const readme = yield* read('node_modules/@effect/tsgo/README.md'); - const installedText = yield* read('node_modules/@effect/tsgo/package.json'); - const rootText = yield* read('package.json'); - if (readme === undefined || installedText === undefined || rootText === undefined) { - return false; - } - const installed = yield* Schema.decodeUnknownEffect(InstalledPackage)(installedText); - const root = yield* Schema.decodeUnknownEffect(Manifest)(rootText); - const pinned = root.devDependencies?.[EFFECT_TSGO] ?? root.dependencies?.[EFFECT_TSGO]; - return ( - installed.name === EFFECT_TSGO && - pinned === installed.version && - documentsBuiltInPlugin(readme) - ); - }, - ); + const tsgoDocumentation = Effect.fn('QualityAudit.tsgoDocumentation')(function* tsgoDocumentation() { + const readme = yield* read('node_modules/@effect/tsgo/README.md'); + const installedText = yield* read('node_modules/@effect/tsgo/package.json'); + const rootText = yield* read('package.json'); + if (readme === undefined || installedText === undefined || rootText === undefined) { + return false; + } + const installed = yield* Schema.decodeUnknownEffect(InstalledPackage)(installedText); + const root = yield* Schema.decodeUnknownEffect(Manifest)(rootText); + const pinned = root.devDependencies?.[EFFECT_TSGO] ?? root.dependencies?.[EFFECT_TSGO]; + return installed.name === EFFECT_TSGO && pinned === installed.version && documentsBuiltInPlugin(readme); + }); const tsgoEvidence = Effect.fn('QualityAudit.tsgoEvidence')(function* tsgoEvidence() { const typecheckFile = 'scripts/ultramodern-typecheck.mts'; const typecheck = yield* read(typecheckFile); const vendorTypecheck = yield* read( - 'node_modules/@modern-js/create/templates/workspace-scripts/ultramodern-typecheck.mjs', + 'node_modules/@modern-js/ultramodern-create/templates/workspace-scripts/ultramodern-typecheck.mjs', ); const usesTsgo = - hasUltramodernDispatch( - typecheck, - 'typecheck', - yield* read('scripts/shared/ultramodern-command.mts'), - ) && + hasUltramodernDispatch(typecheck, 'typecheck', yield* read('scripts/shared/ultramodern-command.mts')) && vendorTypecheck?.includes('resolveEffectTsgoCompiler({') === true && vendorTypecheck.includes("from: pathToFileURL(join(workspaceRoot, 'package.json'))"); if (usesTsgo && typecheck !== undefined) { @@ -424,10 +380,8 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime if (configText !== undefined && (yield* tsgoDocumentation())) { const config = yield* parseTsconfig(configFile, configText); if ( - config.compilerOptions?.plugins?.some((plugin) => plugin.name === EFFECT_PLUGIN) === - true && - config.compilerOptions.types?.some((name) => packageName(name) === EFFECT_PLUGIN) !== - true + config.compilerOptions?.plugins?.some((plugin) => plugin.name === EFFECT_PLUGIN) === true && + config.compilerOptions.types?.some((name) => packageName(name) === EFFECT_PLUGIN) !== true ) { const match = /"name"\s*:\s*"@effect\/language-service"/u.exec(configText); if (match !== null) { @@ -449,87 +403,77 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime } } }); - const readinessEvidence = Effect.fn('QualityAudit.readinessEvidence')( - function* readinessEvidence() { - const readinessFile = 'scripts/ultramodern-performance-readiness.mts'; - const readiness = yield* read(readinessFile); - const vendorFile = - 'node_modules/@modern-js/create/templates/workspace-scripts/ultramodern-performance-readiness.mjs'; - const vendor = yield* read(vendorFile); - if ( - hasUltramodernDispatch( - readiness, - 'performance-readiness', - yield* read('scripts/shared/ultramodern-command.mts'), - ) && - vendor?.includes('pathToFileURL(path.join(root, configPath)).href') === true && - vendor.includes('import(moduleUrl)') - ) { - const match = /const configPath = '(?[^']+)'/u.exec(vendor); - if (match === null) { - return; - } - const [, target] = match; - if ( - target !== undefined && - !target.includes('..') && - (yield* read(target)) !== undefined - ) { + const readinessEvidence = Effect.fn('QualityAudit.readinessEvidence')(function* readinessEvidence() { + const readinessFile = 'scripts/ultramodern-performance-readiness.mts'; + const readiness = yield* read(readinessFile); + const vendorFile = + 'node_modules/@modern-js/ultramodern-create/templates/workspace-scripts/ultramodern-performance-readiness.mjs'; + const vendor = yield* read(vendorFile); + if ( + hasUltramodernDispatch( + readiness, + 'performance-readiness', + yield* read('scripts/shared/ultramodern-command.mts'), + ) && + vendor?.includes('pathToFileURL(path.join(root, configPath)).href') === true && + vendor.includes('import(moduleUrl)') + ) { + const match = /const configPath = '(?[^']+)'/u.exec(vendor); + if (match === null) { + return; + } + const [, target] = match; + if (target !== undefined && !target.includes('..') && (yield* read(target)) !== undefined) { + evidence.push( + at( + vendorFile, + vendor, + match.index, + '.', + 'file', + target, + `Invoked by ${readinessFile}; installed framework imports this exact configPath`, + ), + ); + if (vendor.includes('module.default ?? {}')) { evidence.push( at( vendorFile, vendor, - match.index, + vendor.indexOf('module.default ?? {}'), '.', - 'file', - target, - `Invoked by ${readinessFile}; installed framework imports this exact configPath`, + 'export', + `${target}#default`, + 'Installed framework loader reads the imported configuration default export', ), ); - if (vendor.includes('module.default ?? {}')) { - evidence.push( - at( - vendorFile, - vendor, - vendor.indexOf('module.default ?? {}'), - '.', - 'export', - `${target}#default`, - 'Installed framework loader reads the imported configuration default export', - ), - ); - } } } - }, - ); - const lefthookEvidence = Effect.fn('QualityAudit.lefthookEvidence')( - function* lefthookEvidence() { - const file = 'lefthook.yml'; - const source = yield* read(file); - if (source === undefined) { - return; - } - for (const match of source.matchAll( - /^(?:pre-commit|pre-push):\r?\n(?(?:^[ \t].*(?:\r?\n|$))*)/gmu, - )) { - const { body = '' } = match.groups ?? {}; - if (/^\s+commands:\s*$/mu.test(body) && /^\s+run:\s+\S.+$/mu.test(body)) { - evidence.push( - at( - file, - source, - match.index, - '.', - 'dependency', - 'lefthook', - 'Configured optional Lefthook tool; this configuration does not establish hook activation or enforcement', - ), - ); - } + } + }); + const lefthookEvidence = Effect.fn('QualityAudit.lefthookEvidence')(function* lefthookEvidence() { + const file = 'lefthook.yml'; + const source = yield* read(file); + if (source === undefined) { + return; + } + for (const match of source.matchAll(/^(?:pre-commit|pre-push):\r?\n(?(?:^[ \t].*(?:\r?\n|$))*)/gmu)) { + const { body = '' } = match.groups ?? {}; + if (/^\s+commands:\s*$/mu.test(body) && /^\s+run:\s+\S.+$/mu.test(body)) { + evidence.push( + at( + file, + source, + match.index, + '.', + 'dependency', + 'lefthook', + 'Configured optional Lefthook tool; this configuration does not establish hook activation or enforcement', + ), + ); } - }, - ); + } + }); const workspaces = yield* workspaceDirectories(appRoot); for (const workspace of workspaces) { const prefix = workspace === '.' ? '' : `${workspace}/`; diff --git a/app/rstest.config.ts b/app/rstest.config.ts index 3e0e143ca..ad8f724f3 100644 --- a/app/rstest.config.ts +++ b/app/rstest.config.ts @@ -2,9 +2,15 @@ import { defineConfig } from '@rstest/core'; // SWC rejects every generic arrow function in `.mts` files (even `(...)`) under its // default mts/cts parser mode, and Rstest bundles the imported scripts through SWC. -const swc = { jsc: { parser: { disallowAmbiguousJsxLike: false, syntax: 'typescript' } } } as const; +const swc = { + jsc: { parser: { disallowAmbiguousJsxLike: false, syntax: 'typescript' } }, +} as const; -const shared = { testEnvironment: 'node', testTimeout: 120_000, tools: { swc } } as const; +const shared = { + testEnvironment: 'node', + testTimeout: 120_000, + tools: { swc }, +} as const; export default defineConfig({ projects: [ diff --git a/app/scripts/assert-mf-types.mts b/app/scripts/assert-mf-types.mts index 6880e6d0c..07ae28abe 100644 --- a/app/scripts/assert-mf-types.mts +++ b/app/scripts/assert-mf-types.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/scripts/audit-database-trust-boundaries.mts b/app/scripts/audit-database-trust-boundaries.mts index 7730fa04c..db04c7a62 100644 --- a/app/scripts/audit-database-trust-boundaries.mts +++ b/app/scripts/audit-database-trust-boundaries.mts @@ -1,8 +1,10 @@ #!/usr/bin/env node -import { NodeServices } from '@effect/platform-node'; import { pathToFileURL } from 'node:url'; -import { Client } from 'pg'; + +import { NodeServices } from '@effect/platform-node'; import { Config, Console, Effect, Exit, FileSystem, Path, Schema } from 'effect'; +import { Client } from 'pg'; + import { loadDatabaseConnectionPair } from '../packages/core-runtime/src/db/config.ts'; import { collectSnapshot } from './database-trust-audit/collect-snapshot.mts'; import { @@ -47,8 +49,12 @@ export const auditDatabaseTrustBoundaries = (): Effect.Effect< }), ), ); - const admin = new Client({ connectionString: connections.admin.connectionString }); - const runtime = new Client({ connectionString: connections.runtime.connectionString }); + const admin = new Client({ + connectionString: connections.admin.connectionString, + }); + const runtime = new Client({ + connectionString: connections.runtime.connectionString, + }); let adminConnected = false; let runtimeConnected = false; return yield* Effect.gen(function* collectDatabaseTrustBoundaryReport() { @@ -69,8 +75,7 @@ export const auditDatabaseTrustBoundaries = (): Effect.Effect< (error) => new DatabaseTrustBoundaryAuditError({ reason: - Schema.is(DatabaseTargetMismatchError)(error) || - Schema.is(DatabaseSessionIdentityError)(error) + Schema.is(DatabaseTargetMismatchError)(error) || Schema.is(DatabaseSessionIdentityError)(error) ? error.message : 'Database trust-boundary evidence could not be collected', }), @@ -91,60 +96,51 @@ export const auditDatabaseTrustBoundaries = (): Effect.Effect< const DatabaseTrustBoundaryReportJsonSchema = Schema.fromJsonString(Schema.Unknown, { space: 2 }); -const writeDatabaseTrustBoundaryReport = Effect.gen( - function* writeDatabaseTrustBoundaryReportEffect() { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const defaultWorkspaceRoot = path.resolve(import.meta.dirname, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError( - () => - new DatabaseTrustBoundaryAuditError({ - reason: genericAuditFailureMessage, - }), - ), - ); - const output = path.join(workspaceRoot, '.codex/reports/database/database-trust-boundary.json'); - const report = yield* auditDatabaseTrustBoundaries(); - const reportJson = yield* Schema.encodeEffect(DatabaseTrustBoundaryReportJsonSchema)( - report, - ).pipe( - Effect.mapError( - () => - new DatabaseTrustBoundaryAuditError({ - reason: genericAuditFailureMessage, - }), - ), - ); - yield* fileSystem.makeDirectory(path.dirname(output), { recursive: true }).pipe( - Effect.mapError( - () => - new DatabaseTrustBoundaryAuditError({ - reason: genericAuditFailureMessage, - }), - ), - ); - yield* fileSystem.writeFileString(output, `${reportJson}\n`).pipe( - Effect.mapError( - () => - new DatabaseTrustBoundaryAuditError({ - reason: genericAuditFailureMessage, - }), - ), - ); - yield* Console.log( - `Database trust-boundary evidence written with ${report.findings.length} finding(s).`, - ); - }, -).pipe(Effect.tapCause((cause) => Console.error(getDatabaseTrustBoundaryFailureMessage(cause)))); +const writeDatabaseTrustBoundaryReport = Effect.gen(function* writeDatabaseTrustBoundaryReportEffect() { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const defaultWorkspaceRoot = path.resolve(import.meta.dirname, '..'); + const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( + Config.withDefault(defaultWorkspaceRoot), + Effect.mapError( + () => + new DatabaseTrustBoundaryAuditError({ + reason: genericAuditFailureMessage, + }), + ), + ); + const output = path.join(workspaceRoot, '.codex/reports/database/database-trust-boundary.json'); + const report = yield* auditDatabaseTrustBoundaries(); + const reportJson = yield* Schema.encodeEffect(DatabaseTrustBoundaryReportJsonSchema)(report).pipe( + Effect.mapError( + () => + new DatabaseTrustBoundaryAuditError({ + reason: genericAuditFailureMessage, + }), + ), + ); + yield* fileSystem.makeDirectory(path.dirname(output), { recursive: true }).pipe( + Effect.mapError( + () => + new DatabaseTrustBoundaryAuditError({ + reason: genericAuditFailureMessage, + }), + ), + ); + yield* fileSystem.writeFileString(output, `${reportJson}\n`).pipe( + Effect.mapError( + () => + new DatabaseTrustBoundaryAuditError({ + reason: genericAuditFailureMessage, + }), + ), + ); + yield* Console.log(`Database trust-boundary evidence written with ${report.findings.length} finding(s).`); +}).pipe(Effect.tapCause((cause) => Console.error(getDatabaseTrustBoundaryFailureMessage(cause)))); -const isMain = - process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; +const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - const exit = await Effect.runPromiseExit( - writeDatabaseTrustBoundaryReport.pipe(Effect.provide(NodeServices.layer)), - ); + const exit = await Effect.runPromiseExit(writeDatabaseTrustBoundaryReport.pipe(Effect.provide(NodeServices.layer))); process.exitCode = Exit.match(exit, { onFailure: () => 1, onSuccess: () => 0, diff --git a/app/scripts/authorization/protected-entrypoint-inventory.mts b/app/scripts/authorization/protected-entrypoint-inventory.mts index 26bf09d2b..1f003c50b 100644 --- a/app/scripts/authorization/protected-entrypoint-inventory.mts +++ b/app/scripts/authorization/protected-entrypoint-inventory.mts @@ -4,9 +4,7 @@ import { Array as EffectArray, Order, Result, Schema } from 'effect'; export const PROTECTED_ENTRYPOINT_INVENTORY_SCHEMA_VERSION = 1 as const; -const PermissionSchema = Schema.String.check( - Schema.isPattern(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u), -); +const PermissionSchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u)); const InventoryAuthorizationSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal('public') }), @@ -28,18 +26,11 @@ const InventoryAuthorizationSchema = Schema.Union([ export type InventoryAuthorization = typeof InventoryAuthorizationSchema.Type; -const ProtectedEntrypointSurfaceSchema = Schema.Literals([ - 'action', - 'capability_issuance', - 'route', - 'worker', -]); +const ProtectedEntrypointSurfaceSchema = Schema.Literals(['action', 'capability_issuance', 'route', 'worker']); type ProtectedEntrypointSurface = typeof ProtectedEntrypointSurfaceSchema.Type; -const StableIdentifierSchema = Schema.String.check( - Schema.isPattern(/^[a-z][a-z0-9]*(?:[./_-][a-z0-9]+)*$/u), -); +const StableIdentifierSchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9]*(?:[./_-][a-z0-9]+)*$/u)); const EntrypointKeySchema = StableIdentifierSchema.pipe(Schema.brand('EntrypointKey')); @@ -60,16 +51,13 @@ const ProtectedEntrypointInventorySchema = Schema.Struct({ sourceRevision: SourceRevisionSchema, }); -export type ProtectedEntrypointInventoryEntry = - typeof ProtectedEntrypointInventoryEntrySchema.Encoded; +export type ProtectedEntrypointInventoryEntry = typeof ProtectedEntrypointInventoryEntrySchema.Encoded; export type ProtectedEntrypointInventory = typeof ProtectedEntrypointInventorySchema.Encoded; const encodeJsonResult = Schema.encodeResult(Schema.fromJsonString(Schema.Unknown)); -const encodePrettyJsonResult = Schema.encodeResult( - Schema.fromJsonString(Schema.Unknown, { space: 2 }), -); +const encodePrettyJsonResult = Schema.encodeResult(Schema.fromJsonString(Schema.Unknown, { space: 2 })); class ProtectedEntrypointInventoryError extends Schema.TaggedError()( 'ProtectedEntrypointInventoryError', @@ -79,8 +67,7 @@ class ProtectedEntrypointInventoryError extends Schema.TaggedError new ProtectedEntrypointInventoryError({ message }); -const toTypeError = (error: ProtectedEntrypointInventoryError): TypeError => - new TypeError(error.message); +const toTypeError = (error: ProtectedEntrypointInventoryError): TypeError => new TypeError(error.message); // Compatibility boundary for the established synchronous, TypeError-throwing public API. const getOrThrowTypeError = (result: Result.Result): A => @@ -89,10 +76,7 @@ const getOrThrowTypeError = (result: Result.Result invalidInventory('protected entrypoint inventory encoding failed'); -const stableValue = ( - value: string, - field: string, -): Result.Result => +const stableValue = (value: string, field: string): Result.Result => Schema.is(StableIdentifierSchema)(value) ? Result.succeed(value) : Result.fail(invalidInventory(`${field} must be a stable, non-sensitive identifier`)); @@ -132,8 +116,7 @@ const compareInventoryEntries = ( right: ProtectedEntrypointInventoryEntry, ): -1 | 0 | 1 => { const surfaceOrder = left.surface.localeCompare(right.surface); - const order = - surfaceOrder === 0 ? left.entrypointKey.localeCompare(right.entrypointKey) : surfaceOrder; + const order = surfaceOrder === 0 ? left.entrypointKey.localeCompare(right.entrypointKey) : surfaceOrder; if (order < 0) { return -1; } @@ -153,9 +136,7 @@ const normalizeProtectedEntrypointInventoryResult = ( const seen = new Set(); for (const entry of normalized) { if (seen.has(entry.entrypointKey)) { - return yield* Result.fail( - invalidInventory(`duplicate protected entrypoint: ${entry.entrypointKey}`), - ); + return yield* Result.fail(invalidInventory(`duplicate protected entrypoint: ${entry.entrypointKey}`)); } seen.add(entry.entrypointKey); } @@ -167,12 +148,8 @@ export const normalizeProtectedEntrypointInventory = ( ): readonly ProtectedEntrypointInventoryEntry[] => getOrThrowTypeError(normalizeProtectedEntrypointInventoryResult(entries)); -export const hashProtectedEntrypointInventory = ( - entries: readonly ProtectedEntrypointInventoryEntry[], -): string => { - const encodedEntries = getOrThrowTypeError( - encodeJsonResult(entries).pipe(Result.mapError(encodingFailure)), - ); +export const hashProtectedEntrypointInventory = (entries: readonly ProtectedEntrypointInventoryEntry[]): string => { + const encodedEntries = getOrThrowTypeError(encodeJsonResult(entries).pipe(Result.mapError(encodingFailure))); const source = `${encodedEntries}\n`; return bytesToHex(sha256(utf8ToBytes(source))); }; @@ -184,9 +161,7 @@ export const makeProtectedEntrypointInventory = ( getOrThrowTypeError( Result.gen(function* makeInventoryResult() { if (!Schema.is(SourceRevisionSchema)(sourceRevision)) { - return yield* Result.fail( - invalidInventory('sourceRevision must be a stable revision identifier'), - ); + return yield* Result.fail(invalidInventory('sourceRevision must be a stable revision identifier')); } const normalized = yield* normalizeProtectedEntrypointInventoryResult(entries); return { @@ -198,9 +173,5 @@ export const makeProtectedEntrypointInventory = ( }), ); -export const serializeProtectedEntrypointInventory = ( - inventory: ProtectedEntrypointInventory, -): string => - `${getOrThrowTypeError( - encodePrettyJsonResult(inventory).pipe(Result.mapError(encodingFailure)), - )}\n`; +export const serializeProtectedEntrypointInventory = (inventory: ProtectedEntrypointInventory): string => + `${getOrThrowTypeError(encodePrettyJsonResult(inventory).pipe(Result.mapError(encodingFailure)))}\n`; diff --git a/app/scripts/authorization/rollout-contract.mts b/app/scripts/authorization/rollout-contract.mts index 0d76d3c67..54d517b1f 100644 --- a/app/scripts/authorization/rollout-contract.mts +++ b/app/scripts/authorization/rollout-contract.mts @@ -15,20 +15,14 @@ const AuthorizationRolloutContractSchema = Schema.Struct({ schemaVersion: Schema.Literal(AUTHORIZATION_ROLLOUT_SCHEMA_VERSION), }); -const BaselineSourceRevisionSchema = Schema.String.check( - Schema.isPattern(/^[a-zA-Z0-9._-]{1,100}$/u), -); +const BaselineSourceRevisionSchema = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9._-]{1,100}$/u)); const DecisionReferenceSchema = Schema.String.check( Schema.isPattern(/^(?:https:\/\/github\.com\/TechsioCZ\/ontos\/issues\/\d+|ADR-\d{4})$/u), ); -type DecodedAuthorizationRolloutContract = Schema.Schema.Type< - typeof AuthorizationRolloutContractSchema ->; +type DecodedAuthorizationRolloutContract = Schema.Schema.Type; -export type AuthorizationRolloutContract = Schema.Codec.Encoded< - typeof AuthorizationRolloutContractSchema ->; +export type AuthorizationRolloutContract = Schema.Codec.Encoded; type AuthorizationRolloutContractDocument = | boolean | null @@ -59,9 +53,7 @@ const malformedContract = (): AuthorizationRolloutContractError => const encodeContract = ( contract: DecodedAuthorizationRolloutContract, ): Result.Result => - Schema.encodeUnknownResult(AuthorizationRolloutContractSchema)(contract).pipe( - Result.mapError(malformedContract), - ); + Schema.encodeUnknownResult(AuthorizationRolloutContractSchema)(contract).pipe(Result.mapError(malformedContract)); const validateContractActivity = ( contract: DecodedAuthorizationRolloutContract, @@ -82,9 +74,7 @@ const validateInventoryBinding = ( ): Result.Result => contract.baselineInventoryHash !== context.inventoryHash || !Schema.is(BaselineSourceRevisionSchema)(contract.baselineSourceRevision) - ? Result.fail( - invalidContract('authorization rollout contract does not match the classified inventory'), - ) + ? Result.fail(invalidContract('authorization rollout contract does not match the classified inventory')) : Result.succeed(true); const validateDecisionReference = ( @@ -92,9 +82,7 @@ const validateDecisionReference = ( ): Result.Result => Schema.is(DecisionReferenceSchema)(contract.decisionReference) ? Result.succeed(true) - : Result.fail( - invalidContract('authorization rollout contract requires an auditable decision reference'), - ); + : Result.fail(invalidContract('authorization rollout contract requires an auditable decision reference')); const validateCompatibilityEntrypoints = ( contract: DecodedAuthorizationRolloutContract, @@ -102,20 +90,11 @@ const validateCompatibilityEntrypoints = ( ): Result.Result => { const entries = sortArray(StringOrder)(dedupeArray(contract.compatibilityEligibleEntrypoints)); if (entries.length !== contract.compatibilityEligibleEntrypoints.length) { - return Result.fail( - invalidContract('authorization rollout compatibility baseline contains duplicates'), - ); + return Result.fail(invalidContract('authorization rollout compatibility baseline contains duplicates')); } const { entrypointKeys } = context; - if ( - entrypointKeys !== undefined && - entries.some((entrypoint) => !entrypointKeys.has(entrypoint)) - ) { - return Result.fail( - invalidContract( - 'authorization rollout compatibility baseline contains an unknown entrypoint', - ), - ); + if (entrypointKeys !== undefined && entries.some((entrypoint) => !entrypointKeys.has(entrypoint))) { + return Result.fail(invalidContract('authorization rollout compatibility baseline contains an unknown entrypoint')); } return Result.succeed(entries); }; @@ -128,11 +107,11 @@ const validateDecodedContract = ( yield* validateContractActivity(contract, context); yield* validateInventoryBinding(contract, context); yield* validateDecisionReference(contract); - const compatibilityEligibleEntrypoints = yield* validateCompatibilityEntrypoints( - contract, - context, - ); - return yield* encodeContract({ ...contract, compatibilityEligibleEntrypoints }); + const compatibilityEligibleEntrypoints = yield* validateCompatibilityEntrypoints(contract, context); + return yield* encodeContract({ + ...contract, + compatibilityEligibleEntrypoints, + }); }); const decodeContract = ( @@ -146,6 +125,4 @@ export const validateAuthorizationRolloutContract = ( raw: AuthorizationRolloutContractDocument, context: RolloutValidationContext, ): AuthorizationRolloutContract => - Result.getOrThrow( - Result.flatMap(decodeContract(raw), (contract) => validateDecodedContract(contract, context)), - ); + Result.getOrThrow(Result.flatMap(decodeContract(raw), (contract) => validateDecodedContract(contract, context))); diff --git a/app/scripts/bootstrap-agent-skills.mts b/app/scripts/bootstrap-agent-skills.mts index 8b2127738..fe52f45fa 100644 --- a/app/scripts/bootstrap-agent-skills.mts +++ b/app/scripts/bootstrap-agent-skills.mts @@ -1,28 +1,15 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { - Config, - ConfigProvider, - Console, - Effect, - Exit, - Option, - Path, - Predicate, - Schema, - Stdio, -} from 'effect'; +import { Config, ConfigProvider, Console, Effect, Exit, Option, Path, Predicate, Schema, Stdio } from 'effect'; import { ChildProcessSpawner } from 'effect/unstable/process'; import { ultramodernLaunch } from './shared/ultramodern-launch.mts'; -class AgentSkillsBootstrapError extends Schema.TaggedError()( - 'AgentSkillsBootstrapError', - { reason: Schema.String }, -) {} +class AgentSkillsBootstrapError extends Schema.TaggedError()('AgentSkillsBootstrapError', { + reason: Schema.String, +}) {} -const failure = (reason: string): AgentSkillsBootstrapError => - new AgentSkillsBootstrapError({ reason }); +const failure = (reason: string): AgentSkillsBootstrapError => new AgentSkillsBootstrapError({ reason }); const program = Effect.gen(function* bootstrapAgentSkills() { const path = yield* Path.Path; @@ -70,10 +57,7 @@ const program = Effect.gen(function* bootstrapAgentSkills() { const exit = await Effect.runPromiseExit( program.pipe( Effect.tapError((error) => Console.error(error.reason)), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ preserveEmptyStrings: true }), - ), + Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnv({ preserveEmptyStrings: true })), Effect.provide(NodeServices.layer), Effect.scoped, ), diff --git a/app/scripts/check-authorization-readiness.mts b/app/scripts/check-authorization-readiness.mts index 4217178b4..4abe540c1 100644 --- a/app/scripts/check-authorization-readiness.mts +++ b/app/scripts/check-authorization-readiness.mts @@ -1,31 +1,17 @@ #!/usr/bin/env node /// import { createHash } from 'node:crypto'; -import { loadCoreNodeServices } from './shared/core-node-services.mts'; -import { - Clock, - Config, - Console, - DateTime, - Duration, - Effect, - FileSystem, - Option, - Path, - Result, - Schema, -} from 'effect'; + +import { Clock, Config, Console, DateTime, Duration, Effect, FileSystem, Option, Path, Result, Schema } from 'effect'; import { Argument, Command } from 'effect/unstable/cli'; + import type { ProtectedEntrypointInventory } from './authorization/protected-entrypoint-inventory.mts'; import type { AuthorizationRolloutContract } from './authorization/rollout-contract.mts'; import { validateAuthorizationRolloutContract } from './authorization/rollout-contract.mts'; import type { AuthorizationImpactReport } from './report-fail-closed-authorization-impact.mts'; +import { loadCoreNodeServices } from './shared/core-node-services.mts'; -export const AuthorizationEnvironmentSchema = Schema.Literals([ - 'development', - 'production', - 'stage', -]); +export const AuthorizationEnvironmentSchema = Schema.Literals(['development', 'production', 'stage']); export type AuthorizationEnvironment = typeof AuthorizationEnvironmentSchema.Type; export const CredentialSchema = Schema.Literals(['api_key', 'session']); export type Credential = typeof CredentialSchema.Type; @@ -159,31 +145,26 @@ const validRevision = (value: string): boolean => /^[a-zA-Z0-9._-]{1,100}$/u.tes const fail = (message: string): never => Option.getOrThrowWith( Option.none(), - () => new AuthorizationReadinessError({ reason: `authorization readiness failed: ${message}` }), + () => + new AuthorizationReadinessError({ + reason: `authorization readiness failed: ${message}`, + }), ); const requiredEntrypoints = (inventory: ProtectedEntrypointInventory) => { const actions = inventory.entries - .filter( - ({ authorization, surface }) => - surface === 'action' && authorization.kind === 'action_execution', - ) + .filter(({ authorization, surface }) => surface === 'action' && authorization.kind === 'action_execution') .map(({ entrypointKey }) => entrypointKey); const contextPermissions = inventory.entries .filter(({ authorization }) => authorization.kind === 'context_permission') .map(({ entrypointKey }) => entrypointKey); const workers = inventory.entries - .filter( - ({ authorization, surface }) => - surface === 'worker' && authorization.kind === 'owner_local_background', - ) + .filter(({ authorization, surface }) => surface === 'worker' && authorization.kind === 'owner_local_background') .map(({ entrypointKey }) => entrypointKey); const activeModules = inventory.entries .filter( ({ authorization, owner, surface }) => - owner !== 'shell-super-app' && - surface !== 'capability_issuance' && - authorization.kind !== 'public', + owner !== 'shell-super-app' && surface !== 'capability_issuance' && authorization.kind !== 'public', ) .map(({ entrypointKey }) => entrypointKey); return { actions, activeModules, contextPermissions, workers }; @@ -194,10 +175,7 @@ const validateFixedContext = (input: AuthorizationReadinessInput): void => { if (context.schemaVersion !== 1 || context.approvalStatus !== 'approved') { fail('the fixed deployment context is absent or unapproved'); } - if ( - context.environment !== observation.environment || - context.environment !== negativeSmoke.environment - ) { + if (context.environment !== observation.environment || context.environment !== negativeSmoke.environment) { fail('evidence does not match the fixed deployment environment'); } if ( @@ -262,8 +240,7 @@ const validateObservationWindow = (input: AuthorizationReadinessInput): void => ![observationStarted, observationEnded, rolloutStarted, rolloutEnded].every(Number.isFinite) || observationStarted < rolloutStarted || observationEnded > rolloutEnded || - observationEnded - observationStarted < - Duration.toMillis(Duration.seconds(input.context.minimumObservationSeconds)) + observationEnded - observationStarted < Duration.toMillis(Duration.seconds(input.context.minimumObservationSeconds)) ) { fail('compatibility observation is outside the approved bounds'); } @@ -292,9 +269,7 @@ const validateEntrypointCoverage = (input: AuthorizationReadinessInput): void => !sameList(observation.verifiedWorkerEntrypoints, required.workers) || !sameList(observation.verifiedActiveModuleEntrypoints, required.activeModules) ) { - fail( - 'required relationships, route permissions, module state, or worker ownership are incomplete', - ); + fail('required relationships, route permissions, module state, or worker ownership are incomplete'); } }; @@ -306,10 +281,7 @@ const validateGatewayAndSmokeEvidence = (input: AuthorizationReadinessInput): vo } catch { return fail('gateway issuer configuration is malformed'); } - if ( - issuer.protocol !== 'https:' || - !sameList(observation.gatewayAudiences, context.gatewayAudiences) - ) { + if (issuer.protocol !== 'https:' || !sameList(observation.gatewayAudiences, context.gatewayAudiences)) { fail('gateway issuer or audience topology is incorrect'); } const requiredSmoke = context.negativeSmokeScenarios.flatMap((scenario) => @@ -323,9 +295,7 @@ const validateGatewayAndSmokeEvidence = (input: AuthorizationReadinessInput): vo } }; -export const checkAuthorizationReadiness = ( - input: AuthorizationReadinessInput, -): AuthorizationReadinessEvidence => { +export const checkAuthorizationReadiness = (input: AuthorizationReadinessInput): AuthorizationReadinessEvidence => { validateFixedContext(input); validateEvidenceIdentity(input); validateObservationWindow(input); @@ -353,23 +323,24 @@ export const checkAuthorizationReadiness = ( }; const EntrypointKeySchema = Schema.String.pipe(Schema.brand('EntrypointKey')); -const ProtectedEntrypointSurfaceSchema = Schema.Literals([ - 'action', - 'capability_issuance', - 'route', - 'worker', -]); +const ProtectedEntrypointSurfaceSchema = Schema.Literals(['action', 'capability_issuance', 'route', 'worker']); const InventoryAuthorizationSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal('public') }), Schema.Struct({ kind: Schema.Literal('authenticated_principal') }), - Schema.Struct({ kind: Schema.Literal('context_permission'), permission: Schema.String }), + Schema.Struct({ + kind: Schema.Literal('context_permission'), + permission: Schema.String, + }), Schema.Struct({ kind: Schema.Literal('action_execution'), provisioning: Schema.Literals(['explicit', 'tenant_membership_default']), }), Schema.Struct({ kind: Schema.Literal('owner_local_background') }), - Schema.Struct({ credential: CredentialSchema, kind: Schema.Literal('capability_issuance') }), + Schema.Struct({ + credential: CredentialSchema, + kind: Schema.Literal('capability_issuance'), + }), ]); const ProtectedEntrypointInventorySchema = Schema.Struct({ @@ -494,18 +465,16 @@ const readJson = (schema: S, file: string) => }).pipe( Effect.mapError( () => - new AuthorizationReadinessError({ reason: `authorization evidence is invalid: ${file}` }), + new AuthorizationReadinessError({ + reason: `authorization evidence is invalid: ${file}`, + }), ), ); const insideWorkspace = (pathService: Path.Path, root: string, relativeFile: string): string => { const target = pathService.resolve(root, relativeFile); const relative = pathService.relative(root, target); - if ( - relative === '' || - relative.startsWith(`..${pathService.sep}`) || - pathService.isAbsolute(relative) - ) { + if (relative === '' || relative.startsWith(`..${pathService.sep}`) || pathService.isAbsolute(relative)) { fail('fixed context references a path outside the workspace'); } return target; @@ -521,15 +490,9 @@ const authorizationReadinessCommand = Command.make( const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const defaultRoot = yield* pathService.fromFileUrl(new URL('..', import.meta.url)); - const root = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultRoot), - ); + const root = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe(Config.withDefault(defaultRoot)); const reportDirectory = pathService.join(root, '.codex/reports/authorization'); - const contextPath = pathService.join( - root, - 'topology/authorization-contexts', - `${environment}.json`, - ); + const contextPath = pathService.join(root, 'topology/authorization-contexts', `${environment}.json`); const context = yield* readJson(FixedAuthorizationContextSchema, contextPath).pipe( Effect.mapError( () => @@ -545,14 +508,8 @@ const authorizationReadinessCommand = Command.make( } const [inventory, impact, observation, negativeSmoke, rollout] = yield* Effect.all( [ - readJson( - ProtectedEntrypointInventorySchema, - pathService.join(reportDirectory, 'protected-entrypoints.json'), - ), - readJson( - AuthorizationImpactReportSchema, - pathService.join(reportDirectory, 'fail-closed-impact.json'), - ), + readJson(ProtectedEntrypointInventorySchema, pathService.join(reportDirectory, 'protected-entrypoints.json')), + readJson(AuthorizationImpactReportSchema, pathService.join(reportDirectory, 'fail-closed-impact.json')), readJson( AuthorizationReadinessObservationSchema, pathService.join(reportDirectory, `fixed-context-observation.${environment}.json`), @@ -561,10 +518,7 @@ const authorizationReadinessCommand = Command.make( AuthorizationNegativeSmokeEvidenceSchema, pathService.join(reportDirectory, `negative-smoke.${environment}.json`), ), - readJson( - AuthorizationRolloutContractSchema, - pathService.join(root, 'topology/authorization-rollout.json'), - ), + readJson(AuthorizationRolloutContractSchema, pathService.join(root, 'topology/authorization-rollout.json')), ], { concurrency: 'unbounded' }, ); @@ -572,9 +526,7 @@ const authorizationReadinessCommand = Command.make( [ fileSystem.readFileString(contextPath), fileSystem.readFileString(insideWorkspace(pathService, root, context.spiceDbSchemaPath)), - fileSystem.readFileString( - insideWorkspace(pathService, root, context.replayMigrationPath), - ), + fileSystem.readFileString(insideWorkspace(pathService, root, context.replayMigrationPath)), ], { concurrency: 'unbounded' }, ); @@ -583,7 +535,9 @@ const authorizationReadinessCommand = Command.make( catch: (error) => Schema.is(AuthorizationReadinessError)(error) ? error - : new AuthorizationReadinessError({ reason: 'authorization evidence is invalid' }), + : new AuthorizationReadinessError({ + reason: 'authorization evidence is invalid', + }), try: () => checkAuthorizationReadiness({ context, @@ -602,10 +556,7 @@ const authorizationReadinessCommand = Command.make( }); const outputPath = pathService.join(reportDirectory, 'readiness.json'); yield* fileSystem.makeDirectory(reportDirectory, { recursive: true }); - yield* fileSystem.writeFileString( - outputPath, - `${encodeFormattedAuthorizationEvidence(evidence)}\n`, - ); + yield* fileSystem.writeFileString(outputPath, `${encodeFormattedAuthorizationEvidence(evidence)}\n`); yield* Console.log(`${outputPath} ${hashAuthorizationEvidence(evidence)}`); }), ); @@ -615,8 +566,6 @@ const isMain = invokedModule !== undefined && import.meta.url.endsWith(invokedMo if (isMain) { const NodeServices = loadCoreNodeServices(); await Effect.runPromise( - Command.run(authorizationReadinessCommand, { version: '1.0.0' }).pipe( - Effect.provide(NodeServices.layer), - ), + Command.run(authorizationReadinessCommand, { version: '1.0.0' }).pipe(Effect.provide(NodeServices.layer)), ); } diff --git a/app/scripts/check-database-access-boundaries.mts b/app/scripts/check-database-access-boundaries.mts index 35dbe7a7e..177498899 100644 --- a/app/scripts/check-database-access-boundaries.mts +++ b/app/scripts/check-database-access-boundaries.mts @@ -1,15 +1,5 @@ import { NodeServices } from '@effect/platform-node'; -import { - Array as EffectArray, - Console, - Effect, - Exit, - FileSystem, - ManagedRuntime, - Order, - Path, - Schema, -} from 'effect'; +import { Array as EffectArray, Console, Effect, Exit, FileSystem, ManagedRuntime, Order, Path, Schema } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; export interface DatabaseAccessViolation { @@ -22,9 +12,7 @@ const sourceExtensions = new Set(['.ts', '.tsx', '.mts']); const ignoredDirectories = new Set(['dist', 'node_modules', 'repos', '.output', '.codex']); const coreRuntimeSourcePrefix = 'packages/core-runtime/src/'; -const collect = ( - root: string, -): Effect.Effect => +const collect = (root: string): Effect.Effect => Effect.gen(function* collectSourceFiles() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -51,8 +39,7 @@ const collect = ( const isGovernedAdapter = (relative: string, source: string): boolean => !/(?:^|\/)(?:tests?|__tests__)\//u.test(relative) && - ((!relative.startsWith(coreRuntimeSourcePrefix) && - /(?:^|\/)src\/(?:actions|reads)\//u.test(relative)) || + ((!relative.startsWith(coreRuntimeSourcePrefix) && /(?:^|\/)src\/(?:actions|reads)\//u.test(relative)) || /(?:^|\/)verticals\/[^/]+\/api\//u.test(relative) || (/(?:^|\/)apps\/[^/]+\/api\//u.test(relative) && !/(?:^|\/)api\/(?:[^/]+\/)*(?:db|repositories?)\//u.test(relative) && @@ -63,8 +50,7 @@ const isGovernedAdapter = (relative: string, source: string): boolean => const isOwnerOperationSource = (relative: string, source: string): boolean => !/(?:^|\/)(?:tests?|__tests__)\//u.test(relative) && - ((!relative.startsWith(coreRuntimeSourcePrefix) && - /(?:^|\/)src\/(?:actions|reads)\//u.test(relative)) || + ((!relative.startsWith(coreRuntimeSourcePrefix) && /(?:^|\/)src\/(?:actions|reads)\//u.test(relative)) || (!relative.startsWith('packages/core-runtime/') && !relative.startsWith('scripts/') && /@generated by OntOS Codesmith (?:Action v|Governed Contribution)/u.test(source))); @@ -75,17 +61,12 @@ const forbiddenImport = new RegExp(forbiddenImportPattern, 'u'); const multilineForbiddenImport = new RegExp(forbiddenImportPattern, 'gu'); const importedSpecifier = new RegExp(`${importPrefix}['"](?[^'"]+)['"]`, 'gu'); const isTestSource = (relative: string): boolean => - /(?:^|\/)(?:tests?|__tests__)\//u.test(relative) || - relative.startsWith('packages/core-runtime/src/testing/'); + /(?:^|\/)(?:tests?|__tests__)\//u.test(relative) || relative.startsWith('packages/core-runtime/src/testing/'); const hiddenCapability = /\b(?:CoreDatabase|CoreDatabaseExecutor|CoreTransaction|ScopedTransactionExecutor|ActionTransactionExecutor|coreDatabaseSchema|actionInvocations|CORE_TABLES)\b/u; const hiddenCoreSchema = /\b(?:coreDatabaseSchema|actionInvocations|CORE_TABLES)\b/u; -const isVerticalOwnerSource = (relative: string): boolean => - /(?:^|\/)verticals\/[^/]+\/src\//u.test(relative); -const importsCoreDatabaseSchema = new RegExp( - `${importPrefix}['"]@app\\/core-runtime\\/db\\/schema['"]`, - 'u', -); +const isVerticalOwnerSource = (relative: string): boolean => /(?:^|\/)verticals\/[^/]+\/src\//u.test(relative); +const importsCoreDatabaseSchema = new RegExp(`${importPrefix}['"]@app\\/core-runtime\\/db\\/schema['"]`, 'u'); const importSpecifier = new RegExp(`${importPrefix}['"](?[^'"]+)['"]`, 'u'); const globalDatabaseImplementationImport = /(?:import\s+(?!type\b)[^;]*?\s+from\s+|import\s*\(\s*|import\s+|export\s+(?!type\b)[^;]*?\s+from\s+)['"](?:pg|drizzle-orm\/node-postgres|@app\/core-runtime\/db\/client|[^'"]*\/db\/client(?:\.[^'"]*)?)['"]/u; @@ -120,9 +101,7 @@ const resolveLocalSource = ( sourceFiles.has(candidate), ); } - const packageGroups = /^@app\/(?[^/]+)(?:\/(?.+))?$/u.exec( - specifier, - )?.groups; + const packageGroups = /^@app\/(?[^/]+)(?:\/(?.+))?$/u.exec(specifier)?.groups; const packageName = packageGroups?.packageName; if (packageName === undefined) { return undefined; @@ -229,8 +208,7 @@ interface SourceCheckContext { readonly sources: ReadonlyMap; } -const sourceLine = (source: string, index: number): number => - source.slice(0, index).split('\n').length; +const sourceLine = (source: string, index: number): number => source.slice(0, index).split('\n').length; const recordProductionTestingImports = (context: SourceCheckContext): void => { if (isTestSource(context.relative)) { @@ -241,13 +219,7 @@ const recordProductionTestingImports = (context: SourceCheckContext): void => { if (specifier === undefined) { continue; } - const dependency = resolveLocalSource( - context.sourceFiles, - context.path, - context.root, - context.file, - specifier, - ); + const dependency = resolveLocalSource(context.sourceFiles, context.path, context.root, context.file, specifier); const dependencyRelative = dependency === undefined ? undefined @@ -274,22 +246,10 @@ const recordTransitiveDatabaseCapabilities = (context: SourceCheckContext): void if (specifier === undefined) { continue; } - const dependency = resolveLocalSource( - context.sourceFiles, - context.path, - context.root, - context.file, - specifier, - ); + const dependency = resolveLocalSource(context.sourceFiles, context.path, context.root, context.file, specifier); if ( dependency !== undefined && - importsGlobalDatabaseCapability( - dependency, - context.sources, - context.sourceFiles, - context.path, - context.root, - ) + importsGlobalDatabaseCapability(dependency, context.sources, context.sourceFiles, context.path, context.root) ) { context.record({ file: context.relative, @@ -335,8 +295,7 @@ const recordLineDatabaseViolations = (context: SourceCheckContext): void => { context.record({ file: context.relative, line: index + 1, - reason: - 'direct database/private repository import in governed handler or transport adapter', + reason: 'direct database/private repository import in governed handler or transport adapter', }); } else if (exposesHiddenCapability(context, line)) { context.record({ @@ -348,10 +307,7 @@ const recordLineDatabaseViolations = (context: SourceCheckContext): void => { } }; -const compareViolations = ( - left: DatabaseAccessViolation, - right: DatabaseAccessViolation, -): -1 | 0 | 1 => { +const compareViolations = (left: DatabaseAccessViolation, right: DatabaseAccessViolation): -1 | 0 | 1 => { const fileOrder = left.file.localeCompare(right.file); if (fileOrder < 0) { return -1; @@ -383,9 +339,7 @@ export const checkDatabaseAccessBoundaries = (root: string) => const files = yield* collect(root); const sourcePairs = yield* Effect.all( files.map((file) => - fileSystem - .readFileString(file, 'utf-8') - .pipe(Effect.map((source) => [file, source] as const)), + fileSystem.readFileString(file, 'utf-8').pipe(Effect.map((source) => [file, source] as const)), ), { concurrency: 'unbounded' }, ); @@ -431,13 +385,13 @@ const main = Effect.gen(function* databaseAccessBoundaryMain() { const violations = yield* checkDatabaseAccessBoundaries(path.resolve(process.cwd())); if (violations.length > 0) { yield* Effect.all( - violations.map((violation) => - Console.error(`${violation.file}:${violation.line}: ${violation.reason}`), - ), + violations.map((violation) => Console.error(`${violation.file}:${violation.line}: ${violation.reason}`)), { concurrency: 1, discard: true }, ); return yield* Effect.fail( - new DatabaseAccessBoundaryCheckFailed({ violationCount: violations.length }), + new DatabaseAccessBoundaryCheckFailed({ + violationCount: violations.length, + }), ); } return yield* Console.log('Database access boundaries verified'); diff --git a/app/scripts/check-module-entrypoint-boundaries.mts b/app/scripts/check-module-entrypoint-boundaries.mts index b78840594..2cc270296 100644 --- a/app/scripts/check-module-entrypoint-boundaries.mts +++ b/app/scripts/check-module-entrypoint-boundaries.mts @@ -1,6 +1,4 @@ #!/usr/bin/env node -import { maskNonCode } from './scaffolding/shared.mts'; -import { topLevelSeparators } from './boundary-source-structure.mts'; import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { LanguageVariant, SyntaxKind, createScanner } from '@typescript/native/unstable/ast'; import { @@ -17,6 +15,7 @@ import { } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; + import { gatewayContextAuthorizationEntrypoints, shellGatewayContextContract, @@ -31,6 +30,11 @@ import type { InventoryAuthorization, ProtectedEntrypointInventoryEntry, } from './authorization/protected-entrypoint-inventory.mts'; +import { topLevelSeparators } from './boundary-source-structure.mts'; +import { + hasCompleteGeneratedModuleApiSeam, + hasGeneratedGovernedServerContract, +} from './generated-governed-http-boundary.mts'; import { toPascalCase, generatedApiGroup, @@ -44,11 +48,9 @@ import { hasGeneratedProviderManifest, hasGeneratedProviderReadContract, hasGeneratedProviderRegistration, + hasGeneratedSourceHeader, } from './generated-module-api-boundary.mts'; -import { - hasCompleteGeneratedModuleApiSeam, - hasGeneratedGovernedServerContract, -} from './generated-governed-http-boundary.mts'; +import { maskNonCode } from './scaffolding/shared.mts'; const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']); const ACTION_EXTENSION = '.action.ts'; @@ -61,10 +63,7 @@ const sortStrings = (values: readonly string[]): readonly string[] => { const sorted: string[] = []; for (const value of values) { let insertionIndex = 0; - while ( - insertionIndex < sorted.length && - (sorted[insertionIndex]?.localeCompare(value) ?? 0) <= 0 - ) { + while (insertionIndex < sorted.length && (sorted[insertionIndex]?.localeCompare(value) ?? 0) <= 0) { insertionIndex += 1; } sorted.splice(insertionIndex, 0, value); @@ -78,9 +77,7 @@ const isGeneratedInfrastructureReadinessApi = (file: string, source: string): bo return false; } const endpoints = [ - ...source.matchAll( - /HttpApiEndpoint\.(?get|post)\(\s*'(?[^']+)'\s*,\s*'(?[^']+)'/gu, - ), + ...source.matchAll(/HttpApiEndpoint\.(?get|post)\(\s*'(?[^']+)'\s*,\s*'(?[^']+)'/gu), ].map((match) => { const { groups } = match; return `${groups?.method}:${groups?.name}:${groups?.path}`; @@ -94,9 +91,7 @@ const isGeneratedInfrastructureReadinessApi = (file: string, source: string): bo const SKIPPED_DIRECTORIES = new Set(['.output', 'node_modules']); -const walk = ( - directory: string, -): Effect.Effect => +const walk = (directory: string): Effect.Effect => Effect.gen(function* walkEffect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -154,22 +149,14 @@ interface ObjectProperties { readonly values: ReadonlyMap; } -const tokenKindAt = (tokens: readonly SourceToken[], index: number): SyntaxKind | undefined => - tokens[index]?.kind; +const tokenKindAt = (tokens: readonly SourceToken[], index: number): SyntaxKind | undefined => tokens[index]?.kind; -const isPropertyValue = ( - tokens: readonly SourceToken[], - index: number, - valueKind: SyntaxKind, -): boolean => +const isPropertyValue = (tokens: readonly SourceToken[], index: number, valueKind: SyntaxKind): boolean => tokenKindAt(tokens, index) === SyntaxKind.Identifier && tokenKindAt(tokens, index + 1) === SyntaxKind.ColonToken && tokenKindAt(tokens, index + 2) === valueKind; -const readObjectStringProperties = ( - tokens: readonly SourceToken[], - openBraceIndex: number, -): ObjectProperties => { +const readObjectStringProperties = (tokens: readonly SourceToken[], openBraceIndex: number): ObjectProperties => { const values = new Map(); let depth = 0; for (let cursor = openBraceIndex; cursor < tokens.length; cursor += 1) { @@ -191,28 +178,19 @@ const readObjectStringProperties = ( return { closeBraceIndex: tokens.length, values }; }; -const readContextPermission = ( - properties: ReadonlyMap, -): InventoryAuthorization | undefined => { +const readContextPermission = (properties: ReadonlyMap): InventoryAuthorization | undefined => { const permission = properties.get('permission'); - return properties.size === 2 && permission !== undefined - ? { kind: 'context_permission', permission } - : undefined; + return properties.size === 2 && permission !== undefined ? { kind: 'context_permission', permission } : undefined; }; -const readActionExecution = ( - properties: ReadonlyMap, -): InventoryAuthorization | undefined => { +const readActionExecution = (properties: ReadonlyMap): InventoryAuthorization | undefined => { const provisioning = properties.get('provisioning'); - return properties.size === 2 && - (provisioning === 'explicit' || provisioning === 'tenant_membership_default') + return properties.size === 2 && (provisioning === 'explicit' || provisioning === 'tenant_membership_default') ? { kind: 'action_execution', provisioning } : undefined; }; -const readCapabilityIssuance = ( - properties: ReadonlyMap, -): InventoryAuthorization | undefined => { +const readCapabilityIssuance = (properties: ReadonlyMap): InventoryAuthorization | undefined => { const credential = properties.get('credential'); return properties.size === 2 && (credential === 'api_key' || credential === 'session') ? { credential, kind: 'capability_issuance' } @@ -235,10 +213,7 @@ const readAuthorization = ( ); }; -const rescanTemplateClose = ( - scanner: ReturnType, - depths: number[], -): SyntaxKind => { +const rescanTemplateClose = (scanner: ReturnType, depths: number[]): SyntaxKind => { const index = depths.length - 1; const depth = depths[index] ?? 0; if (depth !== 0) { @@ -301,10 +276,7 @@ const readEntrypointAuthorization = ( ): InventoryAuthorization | undefined => { let authorization: InventoryAuthorization | undefined; for (let cursor = start; cursor < end; cursor += 1) { - if ( - tokens[cursor]?.value === 'authorization' && - isPropertyValue(tokens, cursor, SyntaxKind.OpenBraceToken) - ) { + if (tokens[cursor]?.value === 'authorization' && isPropertyValue(tokens, cursor, SyntaxKind.OpenBraceToken)) { authorization = readAuthorization(tokens, cursor + 2); } } @@ -365,17 +337,14 @@ const callsIdentifier = (source: string, identifier: string): boolean => { }; const containsIdentifier = (source: string, identifiers: ReadonlySet): boolean => - tokenize(source).some( - (token) => token.kind === SyntaxKind.Identifier && identifiers.has(token.value), - ); + tokenize(source).some((token) => token.kind === SyntaxKind.Identifier && identifiers.has(token.value)); const isModuleSpecifierPosition = (tokens: readonly SourceToken[], index: number): boolean => { const previous = tokenKindAt(tokens, index - 1); return ( previous === SyntaxKind.FromKeyword || previous === SyntaxKind.ImportKeyword || - (previous === SyntaxKind.OpenParenToken && - tokenKindAt(tokens, index - 2) === SyntaxKind.ImportKeyword) + (previous === SyntaxKind.OpenParenToken && tokenKindAt(tokens, index - 2) === SyntaxKind.ImportKeyword) ); }; @@ -393,10 +362,7 @@ const readImportedModuleSpecifiers = (source: string): readonly string[] => { return specifiers; }; -const authorizationEquals = ( - left: InventoryAuthorization | undefined, - right: InventoryAuthorization, -): boolean => { +const authorizationEquals = (left: InventoryAuthorization | undefined, right: InventoryAuthorization): boolean => { if (left?.kind !== right.kind) { return false; } @@ -412,8 +378,7 @@ const authorizationEquals = ( return true; }; -const sourceOrEmpty = (sourceMap: ReadonlyMap, file: string): string => - sourceMap.get(file) ?? ''; +const sourceOrEmpty = (sourceMap: ReadonlyMap, file: string): string => sourceMap.get(file) ?? ''; const readActionEntrypoints = ( sourceMap: ReadonlyMap, @@ -425,10 +390,7 @@ const readActionEntrypoints = ( /^verticals\/party-registry\/src\/actions\/(?(?:archive|unarchive)-(?:organization|person)-engagement)\.action\.ts$/u.exec( file, )?.groups?.action; - if ( - action === undefined && - !containsIdentifier(source, new Set(['engagementLifecycleRegistration'])) - ) { + if (action === undefined && !containsIdentifier(source, new Set(['engagementLifecycleRegistration']))) { return inline; } if ( @@ -436,10 +398,7 @@ const readActionEntrypoints = ( inline.length !== 0 || !hasEngagementLifecycleRegistrationContract( source, - sourceOrEmpty( - sourceMap, - 'verticals/party-registry/src/actions/engagement-lifecycle-registration.ts', - ), + sourceOrEmpty(sourceMap, 'verticals/party-registry/src/actions/engagement-lifecycle-registration.ts'), action, ) ) { @@ -448,7 +407,10 @@ const readActionEntrypoints = ( return [ { access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: `party.registry.${action}`, moduleKey: 'party.registry', role: 'action', @@ -480,13 +442,9 @@ const requireExactEntrypoint = ( return entrypoint; }); -const requireGeneratedActionEntrypoint = ( - sourceMap: ReadonlyMap, - file: string, - source: string, -) => +const requireGeneratedActionEntrypoint = (sourceMap: ReadonlyMap, file: string, source: string) => Effect.gen(function* requireGeneratedActionEntrypointEffect() { - if (!source.startsWith(`${ACTION_HEADER}\n`)) { + if (!hasGeneratedSourceHeader(source, `${ACTION_HEADER}\n`)) { yield* fail(file, 'Actions must be created and maintained with scaffold:action'); } const owner = /^\/\/ @ontos-action-owner (?.+)$/mu.exec(source)?.groups?.owner; @@ -521,15 +479,11 @@ const requireGeneratedActionEntrypoint = ( const requireGeneratedWorkerEntrypoint = (file: string, source: string) => Effect.gen(function* requireGeneratedWorkerEntrypointEffect() { - if (!source.startsWith(`${WORKER_HEADER}\n`)) { - yield* fail( - file, - 'Outbox Workers must be created and maintained with scaffold:outbox-worker', - ); + if (!hasGeneratedSourceHeader(source, `${WORKER_HEADER}\n`)) { + yield* fail(file, 'Outbox Workers must be created and maintained with scaffold:outbox-worker'); } const owner = /^\/\/ @ontos-outbox-worker-owner (?.+)$/mu.exec(source)?.groups?.owner; - const workerKey = /^\/\/ @ontos-outbox-worker-key (?.+)$/mu.exec(source)?.groups - ?.workerKey; + const workerKey = /^\/\/ @ontos-outbox-worker-key (?.+)$/mu.exec(source)?.groups?.workerKey; const descriptorMessage = 'regenerate this Worker with scaffold:outbox-worker so it has its governed worker/background entrypoint'; const definedOwner = yield* requireDefined(owner, file, descriptorMessage); @@ -578,22 +532,16 @@ const requireRouteEntrypoint = ( const TopologyMetadataSchema = Schema.Struct({ topology: Schema.optionalKey( Schema.Struct({ - apps: Schema.optionalKey( - Schema.Array(Schema.Struct({ id: Schema.String, path: Schema.String })), - ), + apps: Schema.optionalKey(Schema.Array(Schema.Struct({ id: Schema.String, path: Schema.String }))), }), ), }); -const decodeTopologyMetadata = Schema.decodeUnknownEffect( - Schema.fromJsonString(TopologyMetadataSchema), -); +const decodeTopologyMetadata = Schema.decodeUnknownEffect(Schema.fromJsonString(TopologyMetadataSchema)); const VerticalPackageJsonSchema = Schema.Struct({ exports: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); -const decodeVerticalPackageJson = Schema.decodeUnknownEffect( - Schema.fromJsonString(VerticalPackageJsonSchema), -); +const decodeVerticalPackageJson = Schema.decodeUnknownEffect(Schema.fromJsonString(VerticalPackageJsonSchema)); const readTopologyOwners = (root: string) => Effect.gen(function* readTopologyOwnersEffect() { @@ -602,9 +550,7 @@ const readTopologyOwners = (root: string) => const metadata = yield* fileSystem .readFileString(path.join(root, '.modernjs/ultramodern.json'), 'utf-8') .pipe(Effect.flatMap(decodeTopologyMetadata)); - return new Map( - (metadata.topology?.apps ?? []).map((app) => [app.path.replaceAll('\\', '/'), app.id]), - ); + return new Map((metadata.topology?.apps ?? []).map((app) => [app.path.replaceAll('\\', '/'), app.id])); }); const readSourceRevision = (root: string) => @@ -614,15 +560,13 @@ const readSourceRevision = (root: string) => return configured.value; } const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner - .string(ChildProcess.make('git', ['rev-parse', 'HEAD'], { cwd: root })) - .pipe( - Effect.map((revision) => { - const trimmed = revision.trim(); - return trimmed.length === 0 ? 'working-tree' : trimmed; - }), - Effect.catch(() => Effect.succeed('working-tree')), - ); + return yield* spawner.string(ChildProcess.make('git', ['rev-parse', 'HEAD'], { cwd: root })).pipe( + Effect.map((revision) => { + const trimmed = revision.trim(); + return trimmed.length === 0 ? 'working-tree' : trimmed; + }), + Effect.catch(() => Effect.succeed('working-tree')), + ); }); interface RouteEntrypointRecord { @@ -663,14 +607,8 @@ const validateRouteSource = (state: BoundaryCheckState, file: string, source: st if (!file.endsWith('/route.meta.ts')) { return; } - const ownerEntry = [...state.owners.entries()].find(([appPath]) => - file.startsWith(`${appPath}/`), - ); - const [, owner] = yield* requireDefined( - ownerEntry, - file, - 'route owner is absent from the generated topology', - ); + const ownerEntry = [...state.owners.entries()].find(([appPath]) => file.startsWith(`${appPath}/`)); + const [, owner] = yield* requireDefined(ownerEntry, file, 'route owner is absent from the generated topology'); const ownerAppIds = readStringProperties(source, 'ownerAppId'); if (ownerAppIds.size !== 1 || !ownerAppIds.has(owner)) { yield* fail(file, 'route ownerAppId must match the generated topology deployment identity'); @@ -690,12 +628,11 @@ const validateRouteSource = (state: BoundaryCheckState, file: string, source: st const validateGovernedSource = (file: string, source: string) => Effect.gen(function* validateGovernedSourceEffect() { - const category = /\/src\/(?components|search|reports)\//u.exec(`/${file}`)?.groups - ?.category; + const category = /\/src\/(?components|search|reports)\//u.exec(`/${file}`)?.groups?.category; const expectedHeader = governedSourceHeader(category); const invalidGovernedSource = /\/src\/public-components\//u.test(`/${file}`) || - (expectedHeader !== undefined && !source.startsWith(expectedHeader)); + (expectedHeader !== undefined && !hasGeneratedSourceHeader(source, expectedHeader)); if (invalidGovernedSource) { yield* fail( file, @@ -704,19 +641,21 @@ const validateGovernedSource = (file: string, source: string) => } }); +const SEARCH_PROVIDER_KIND = 'search-provider'; + interface GeneratedProviderLocation { readonly kind: 'report' | 'search'; readonly name: string; readonly vertical: string; } -const generatedProviderLocation = ( - file: string, - source: string, -): GeneratedProviderLocation | undefined => { +const generatedProviderLocation = (file: string, source: string): GeneratedProviderLocation | undefined => { if ( - !/^\/\/ @generated by OntOS Codesmith Governed Contribution v1\n\/\/ @ontos-contribution-kind (?:report|search-provider)\n/u.test( - source, + !['report', SEARCH_PROVIDER_KIND].some((kind) => + hasGeneratedSourceHeader( + source, + `// @generated by OntOS Codesmith Governed Contribution v1\n// @ontos-contribution-kind ${kind}\n`, + ), ) ) { return undefined; @@ -750,8 +689,7 @@ const validateGeneratedProviderClient = ( discoveredProvider?: GeneratedProviderLocation, ) => Effect.gen(function* validateGeneratedProviderClientEffect() { - const provider = - discoveredProvider ?? generatedProviderLocation(file, sourceOrEmpty(sourceMap, file)); + const provider = discoveredProvider ?? generatedProviderLocation(file, sourceOrEmpty(sourceMap, file)); if (provider === undefined) { return; } @@ -788,24 +726,10 @@ const validateGeneratedProviderClient = ( const ownerApiValue = `${type}${kindDetails.apiSuffix}Api`; const endpointGroup = generatedApiGroup(contractSource, ownerApiValue) ?? ''; const expectedGroups = new Set([kindDetails.endpointGroup, `${camel}${kindDetails.apiSuffix}`]); - const hasCompleteProviderSeam = [ - contractSource.startsWith(generatedHeader), - clientSource.startsWith(generatedHeader), - providerSource.startsWith(generatedHeader), - serverSource.startsWith(generatedHeader), - expectedGroups.has(endpointGroup), - hasGeneratedProviderApiContract(contractSource, ownerApiValue, moduleId, name, kind), - hasGeneratedProviderReadContract(providerSource, moduleId, name, kind), - hasMatchingGeneratedProviderAuthorization(providerSource, manifest, moduleId, name, kind), - hasGeneratedGovernedServerContract( - serverSource, - `${camel}ReadApiLive`, - sourceOrEmpty(sourceMap, `${vertical}/shared/api.ts`), - ), - hasGeneratedOperationGatewayContract(gateway, deploymentAppId), - hasGeneratedProviderManifest(manifest, moduleId, name, kind), - hasGeneratedProviderRegistration(registration, name, kind), - hasGeneratedGovernedClientContract(clientSource, { + const providerChecks = { + apiContract: hasGeneratedProviderApiContract(contractSource, ownerApiValue, moduleId, name, kind), + authorization: hasMatchingGeneratedProviderAuthorization(providerSource, manifest, moduleId, name, kind), + clientContract: hasGeneratedGovernedClientContract(clientSource, { authorizedOperation: `load${type}ClientWithAuthorization`, defaultApiPrefix: `/${deploymentAppId}-api`, endpointGroup, @@ -815,11 +739,28 @@ const validateGeneratedProviderClient = ( ownerContractImport: `../../shared/apis/${name}-${kind}.ts`, publicOperation: `load${type}Client`, }), - ].every(Boolean); - if (!hasCompleteProviderSeam) { + clientHeader: hasGeneratedSourceHeader(clientSource, generatedHeader), + contractHeader: hasGeneratedSourceHeader(contractSource, generatedHeader), + endpointGroup: expectedGroups.has(endpointGroup), + manifest: hasGeneratedProviderManifest(manifest, moduleId, name, kind), + operationGateway: hasGeneratedOperationGatewayContract(gateway, deploymentAppId), + providerHeader: hasGeneratedSourceHeader(providerSource, generatedHeader), + readContract: hasGeneratedProviderReadContract(providerSource, moduleId, name, kind), + registration: hasGeneratedProviderRegistration(registration, name, kind), + serverContract: hasGeneratedGovernedServerContract( + serverSource, + `${camel}ReadApiLive`, + sourceOrEmpty(sourceMap, `${vertical}/shared/api.ts`), + ), + serverHeader: hasGeneratedSourceHeader(serverSource, generatedHeader), + }; + const failedChecks = Object.entries(providerChecks) + .filter(([, valid]) => !valid) + .map(([checkName]) => checkName); + if (failedChecks.length > 0) { yield* fail( file, - 'generated search and report clients require the shared client runtime, owner-local contract, operation gateway, authorization, and correlation metadata', + `generated search and report clients require the shared client runtime, owner-local contract, operation gateway, authorization, and correlation metadata (failed: ${failedChecks.join(', ')})`, ); } }); @@ -832,8 +773,7 @@ const validatePublishedProviderIdentities = ( Effect.gen(function* validatePublishedProviderIdentitiesEffect() { if ( verticalPath === undefined || - (file !== `${verticalPath}/vertical.manifest.ts` && - file !== `${verticalPath}/vertical.registration.ts`) + (file !== `${verticalPath}/vertical.manifest.ts` && file !== `${verticalPath}/vertical.registration.ts`) ) { return; } @@ -858,6 +798,7 @@ const validatePublishedProviderIdentities = ( const validateVerticalApiSource = (state: BoundaryCheckState, file: string, source: string) => Effect.gen(function* validateVerticalApiSourceEffect() { const { sourceMap } = state; + const diagnostics: string[] = []; if ( file.endsWith('/shared/api.ts') && source.includes('HttpApiEndpoint') && @@ -866,18 +807,22 @@ const validateVerticalApiSource = (state: BoundaryCheckState, file: string, sour sourceMap, file, state.owners.get(file.slice(0, -'/shared/api.ts'.length)), + diagnostics, ) ) { yield* fail( file, - 'module APIs require an approved Codesmith generator, structured api registration, trusted context, and server ModuleEntrypointGateway integration first', + `module APIs require an approved Codesmith generator, structured api registration, trusted context, and server ModuleEntrypointGateway integration first (failed: ${diagnostics.join('; ')})`, ); } if ( /\/shared\/apis\/[^/]+\.ts$/u.test(`/${file}`) && - !source.startsWith('// @generated by OntOS Codesmith module-api v1\n') && - !/^\/\/ @generated by OntOS Codesmith Governed Contribution v1\n\/\/ @ontos-contribution-kind (?:report|search-provider)\n/u.test( - source, + !hasGeneratedSourceHeader(source, '// @generated by OntOS Codesmith module-api v1\n') && + !['report', SEARCH_PROVIDER_KIND].some((kind) => + hasGeneratedSourceHeader( + source, + `// @generated by OntOS Codesmith Governed Contribution v1\n// @ontos-contribution-kind ${kind}\n`, + ), ) ) { yield* fail(file, 'module APIs must be created with scaffold:module-api'); @@ -900,16 +845,12 @@ const validateVerticalSource = ( yield* validateGeneratedProviderClient( sourceMap, file, - (verticalPath === undefined ? undefined : state.owners.get(verticalPath)) ?? - file.split('/')[1] ?? - '', + (verticalPath === undefined ? undefined : state.owners.get(verticalPath)) ?? file.split('/')[1] ?? '', ); yield* validateVerticalApiSource(state, file, source); yield* validateGovernedSource(file, source); const privateImport = importedModuleSpecifiers.some((specifier) => - /(?:verticals\/|@app\/).*\/(?:src|vertical\.registration|workers|search|reports|db)(?:\/|$)/u.test( - specifier, - ), + /(?:verticals\/|@app\/).*\/(?:src|vertical\.registration|workers|search|reports|db)(?:\/|$)/u.test(specifier), ); if (privateImport) { yield* fail( @@ -942,15 +883,10 @@ const validatePackageExports = (file: string, source: string) => if (file.startsWith('verticals/') && file.endsWith('package.json')) { const packageJson = yield* decodeVerticalPackageJson(source); const privateExport = Object.values(packageJson.exports ?? {}).some((target) => - /(?:vertical\.registration|\/src\/(?:handlers|workers|routes|search|reports|db))/u.test( - target, - ), + /(?:vertical\.registration|\/src\/(?:handlers|workers|routes|search|reports|db))/u.test(target), ); if (privateExport) { - yield* fail( - file, - 'package exports must not publish private entrypoint implementations or registrations', - ); + yield* fail(file, 'package exports must not publish private entrypoint implementations or registrations'); } } }); @@ -992,23 +928,13 @@ const validateCoreExports = (file: string, source: string) => } }); -const validateGeneralSource = ( - file: string, - source: string, - importedModuleSpecifiers: readonly string[], -) => +const validateGeneralSource = (file: string, source: string, importedModuleSpecifiers: readonly string[]) => Effect.gen(function* validateGeneralSourceEffect() { if (callsIdentifier(source, 'loadRemote') && file !== APPROVED_REMOTE_LOADER) { - yield* fail( - file, - 'raw loadRemote(...) is forbidden outside the approved Shell module-entrypoint loader', - ); + yield* fail(file, 'raw loadRemote(...) is forbidden outside the approved Shell module-entrypoint loader'); } if (importedModuleSpecifiers.some((specifier) => /\/(?:remote|exposes)\//u.test(specifier))) { - yield* fail( - file, - 'eager remote implementation imports are forbidden; pass a lazy thunk to the gateway', - ); + yield* fail(file, 'eager remote implementation imports are forbidden; pass a lazy thunk to the gateway'); } yield* validatePrivateHandlerAccess(file, source); yield* validatePackageExports(file, source); @@ -1030,24 +956,14 @@ const appendInventoryEntries = (state: BoundaryCheckState, file: string, source: return; } const deployment = - [...state.owners.entries()].find(([appPath]) => file.startsWith(`${appPath}/`))?.[1] ?? - 'shell-super-app'; + [...state.owners.entries()].find(([appPath]) => file.startsWith(`${appPath}/`))?.[1] ?? 'shell-super-app'; const entrypoints = file.endsWith(ACTION_EXTENSION) ? readActionEntrypoints(state.sourceMap, file, source) : readEntrypoints(source); for (const entrypoint of entrypoints) { - const descriptorMessage = - 'every runtime entrypoint must declare exactly one valid authorization'; - const authorization = yield* requireDefined( - entrypoint.authorization, - file, - descriptorMessage, - ); - const entrypointKey = yield* requireDefined( - entrypoint.entrypointKey, - file, - descriptorMessage, - ); + const descriptorMessage = 'every runtime entrypoint must declare exactly one valid authorization'; + const authorization = yield* requireDefined(entrypoint.authorization, file, descriptorMessage); + const entrypointKey = yield* requireDefined(entrypoint.entrypointKey, file, descriptorMessage); const owner = yield* requireDefined(entrypoint.moduleKey, file, descriptorMessage); const role = yield* requireDefined(entrypoint.role, file, descriptorMessage); state.inventoryEntries.push({ @@ -1089,11 +1005,7 @@ const collectRouteSourceKeys = (state: BoundaryCheckState) => return routeSourceKeysByDeployment; }); -const validateManifestKeys = ( - state: BoundaryCheckState, - normalizedFile: string, - sourceKeys: ReadonlySet, -) => +const validateManifestKeys = (state: BoundaryCheckState, normalizedFile: string, sourceKeys: ReadonlySet) => Effect.gen(function* validateManifestKeysEffect() { const manifestSource = sourceOrEmpty(state.sourceMap, normalizedFile); const manifestKeys = readStringProperties(manifestSource, 'entrypointKey'); @@ -1109,31 +1021,21 @@ const validateManifestKeys = ( } }); -const validateRouteManifests = ( - path: Path.Path, - files: readonly string[], - root: string, - state: BoundaryCheckState, -) => +const validateRouteManifests = (path: Path.Path, files: readonly string[], root: string, state: BoundaryCheckState) => Effect.gen(function* validateRouteManifestsEffect() { const routeSourceKeysByDeployment = yield* collectRouteSourceKeys(state); const routeManifests = files.filter((file) => file.endsWith('/ultramodern-route-metadata.ts')); const seenManifestDeployments = new Set(); for (const manifestFile of routeManifests) { const normalizedFile = relative(path, root, manifestFile); - const ownerEntry = [...state.owners.entries()].find(([appPath]) => - normalizedFile.startsWith(`${appPath}/`), - ); + const ownerEntry = [...state.owners.entries()].find(([appPath]) => normalizedFile.startsWith(`${appPath}/`)); const [, deployment] = yield* requireDefined( ownerEntry, normalizedFile, 'generated route manifest owner is absent from topology', ); if (seenManifestDeployments.has(deployment)) { - yield* fail( - normalizedFile, - `deployment ${deployment} has multiple generated route manifests`, - ); + yield* fail(normalizedFile, `deployment ${deployment} has multiple generated route manifests`); } seenManifestDeployments.add(deployment); yield* validateManifestKeys( @@ -1144,10 +1046,7 @@ const validateRouteManifests = ( } for (const deployment of routeSourceKeysByDeployment.keys()) { if (!seenManifestDeployments.has(deployment)) { - yield* fail( - 'generated route manifests', - `deployment ${deployment} is missing its route manifest`, - ); + yield* fail('generated route manifests', `deployment ${deployment} is missing its route manifest`); } } }); @@ -1178,10 +1077,8 @@ const validateGatewayRuntime = (shellApiContract: string, shellApiRuntime: strin const missingSessionHandler = !hasIssuerHandler(shellApiRuntime, 'issueGatewayContext'); if ( !shellApiContract.includes('.add(GatewayContextApiGroup)') || - shellGatewayContextContract.issueGatewayContextPath !== - gatewayContextAuthorizationEntrypoints[0]?.path || - shellGatewayContextContract.issueApiKeyGatewayContextPath !== - gatewayContextAuthorizationEntrypoints[1]?.path || + shellGatewayContextContract.issueGatewayContextPath !== gatewayContextAuthorizationEntrypoints[0]?.path || + shellGatewayContextContract.issueApiKeyGatewayContextPath !== gatewayContextAuthorizationEntrypoints[1]?.path || missingApiKeyHandler || missingSessionHandler ) { @@ -1208,33 +1105,22 @@ const hasMountedIssuerPath = ( gatewaySource: string, issuer: (typeof gatewayContextAuthorizationEntrypoints)[number], ): boolean => { - const name = - issuer.authorization.credential === 'session' - ? 'issueGatewayContext' - : 'issueApiKeyGatewayContext'; + const name = issuer.authorization.credential === 'session' ? 'issueGatewayContext' : 'issueApiKeyGatewayContext'; const endpointPath = issuer.path.slice(shellGatewayContextContract.apiPrefix.length); const group = gatewayDeclaration(gatewaySource, 'GatewayContextApiGroup'); const shellApi = gatewayDeclaration(source, 'ShellAuthenticationApi'); return ( - maskNonCode(source, true).includes( - "import { GatewayContextApiGroup } from '@app/shared-contracts'", - ) && + maskNonCode(source, true).includes("import { GatewayContextApiGroup } from '@app/shared-contracts'") && shellApi.startsWith('HttpApi.make(') && /\.add\(\s*GatewayContextApiGroup\s*\)/u.test(shellApi) && group.startsWith("HttpApiGroup.make('gatewayContext')") && - new RegExp( - `\\.add\\(\\s*HttpApiEndpoint\\.post\\(\\s*'${name}'\\s*,\\s*'${endpointPath}'\\s*,`, - 'u', - ).test(group) + new RegExp(`\\.add\\(\\s*HttpApiEndpoint\\.post\\(\\s*'${name}'\\s*,\\s*'${endpointPath}'\\s*,`, 'u').test(group) ); }; const validateGatewayContract = (state: BoundaryCheckState) => Effect.gen(function* validateGatewayContractEffect() { - const gatewayContract = sourceOrEmpty( - state.sourceMap, - 'packages/shared-contracts/src/gateway-context.ts', - ); + const gatewayContract = sourceOrEmpty(state.sourceMap, 'packages/shared-contracts/src/gateway-context.ts'); const shellApiContract = sourceOrEmpty(state.sourceMap, 'apps/shell-super-app/shared/api.ts'); const shellApiRuntime = sourceOrEmpty(state.sourceMap, 'apps/shell-super-app/api/index.ts'); yield* validateIssuerCredentials(); @@ -1276,8 +1162,7 @@ const checkModuleEntrypointBoundariesEffect = (root: string) => sourceMap, }; for (const [file, source] of sourcePairs) { - const supportedFile = - SOURCE_EXTENSIONS.has(path.extname(file)) || file.endsWith('package.json'); + const supportedFile = SOURCE_EXTENSIONS.has(path.extname(file)) || file.endsWith('package.json'); const productionFile = !file.includes('/tests/') && !file.includes('/fixtures/'); if (supportedFile && productionFile) { yield* validateProductionSource(state, file, source); @@ -1290,8 +1175,7 @@ const checkModuleEntrypointBoundariesEffect = (root: string) => Effect.mapError( () => new ModuleEntrypointBoundaryError({ - message: - 'protected entrypoint inventory: sourceRevision must be a stable revision identifier', + message: 'protected entrypoint inventory: sourceRevision must be a stable revision identifier', }), ), ); @@ -1322,8 +1206,8 @@ if (invokedPath !== undefined && invokedPath === import.meta.filename) { yield* checkModuleEntrypointBoundariesEffect(root); yield* Console.log('Module entrypoint boundaries are valid.'); }); - const runnable = Effect.scoped( - Layer.build(Layer.effectDiscard(main).pipe(Layer.provide(NodeServices.layer))), - ).pipe(Effect.asVoid); + const runnable = Effect.scoped(Layer.build(Layer.effectDiscard(main).pipe(Layer.provide(NodeServices.layer)))).pipe( + Effect.asVoid, + ); NodeRuntime.runMain(runnable); } diff --git a/app/scripts/check-ontos-module-contracts.mts b/app/scripts/check-ontos-module-contracts.mts index 9119b5a9b..6c493043a 100644 --- a/app/scripts/check-ontos-module-contracts.mts +++ b/app/scripts/check-ontos-module-contracts.mts @@ -1,8 +1,11 @@ #!/usr/bin/env node import path from 'node:path'; import { pathToFileURL } from 'node:url'; + import { NodeServices, NodeRuntime } from '@effect/platform-node'; import { Effect, Equal, FileSystem, Layer, Schema } from 'effect'; +import type { PlatformError } from 'effect/PlatformError'; + import { ONTOS_MODULE_CONTRACT_MAX_BYTES, ONTOS_MODULE_CONTRACT_PATH, @@ -16,7 +19,6 @@ import type { OntosModuleDeploymentContract, } from '../packages/core-runtime/src/index.ts'; import { deriveOntosModuleDeploymentContract } from './generate-ontos-module-contract.mts'; -import type { PlatformError } from 'effect/PlatformError'; import { MODULE_CONTRACT_GENERATOR_HEADER, MODULE_MANIFEST_ACTION_SLOT_END, @@ -89,16 +91,13 @@ export class OntosModuleContractCheckError extends Schema.TaggedError - new OntosModuleContractCheckError({ reason }); +const failure = (reason: string): OntosModuleContractCheckError => new OntosModuleContractCheckError({ reason }); const sourceExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']); type TopologyVertical = (typeof TopologySchema.Type.verticals)[number]; -const walkSourceFiles = ( - directory: string, -): Effect.Effect => +const walkSourceFiles = (directory: string): Effect.Effect => Effect.gen(function* walkSourceDirectory() { const fileSystem = yield* FileSystem.FileSystem; if (!(yield* fileSystem.exists(directory))) { @@ -145,22 +144,13 @@ const checkSharedSourceFile = (fileSystem: FileSystem.FileSystem, filePath: stri } }); -const checkOwnerSourceFile = ( - fileSystem: FileSystem.FileSystem, - ownerRoot: string, - filePath: string, -) => +const checkOwnerSourceFile = (fileSystem: FileSystem.FileSystem, ownerRoot: string, filePath: string) => Effect.gen(function* checkOwnerSource() { const content = yield* fileSystem.readFileString(filePath); - const imports = [ - ...content.matchAll(/(?:from\s+|import\s*\(|require\s*\()\s*['"](?[^'"]+)['"]/gu), - ]; + const imports = [...content.matchAll(/(?:from\s+|import\s*\(|require\s*\()\s*['"](?[^'"]+)['"]/gu)]; const hasPrivateOwnerImportViolation = imports.some((match) => { const specifier = match.groups?.specifier; - if ( - specifier === undefined || - !/vertical\.(?:manifest|registration)(?:\.ts)?$/u.test(specifier) - ) { + if (specifier === undefined || !/vertical\.(?:manifest|registration)(?:\.ts)?$/u.test(specifier)) { return false; } if (!specifier.startsWith('.')) { @@ -177,16 +167,11 @@ const checkOwnerSourceFile = ( const checkOwnerDirectory = (fileSystem: FileSystem.FileSystem, ownerRoot: string) => walkSourceFiles(ownerRoot).pipe( Effect.flatMap((ownerFiles) => - Effect.all( - ownerFiles.map((filePath) => checkOwnerSourceFile(fileSystem, ownerRoot, filePath)), - ), + Effect.all(ownerFiles.map((filePath) => checkOwnerSourceFile(fileSystem, ownerRoot, filePath))), ), ); -const assertNoPrivateDeploymentImports = ( - workspaceRoot: string, - verticals: readonly TopologyVertical[], -) => +const assertNoPrivateDeploymentImports = (workspaceRoot: string, verticals: readonly TopologyVertical[]) => Effect.gen(function* checkPrivateDeploymentImports() { const fileSystem = yield* FileSystem.FileSystem; const sharedRoots = [ @@ -194,9 +179,7 @@ const assertNoPrivateDeploymentImports = ( path.join(workspaceRoot, 'packages/core-runtime'), ]; const sharedFiles = yield* Effect.all(sharedRoots.map(walkSourceFiles)); - yield* Effect.all( - sharedFiles.flat().map((filePath) => checkSharedSourceFile(fileSystem, filePath)), - ); + yield* Effect.all(sharedFiles.flat().map((filePath) => checkSharedSourceFile(fileSystem, filePath))); yield* Effect.all( verticals.map((vertical) => { const ownerRoot = path.join(workspaceRoot, vertical.path); @@ -219,19 +202,14 @@ const validateOwner = (filePath: string, markers: readonly string[]) => if (invalidMarker !== undefined) { return yield* failure(`${filePath} must contain exactly one ${invalidMarker}`); } - const moduleId = /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(content)?.groups - ?.moduleId; + const moduleId = /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(content)?.groups?.moduleId; if (moduleId === undefined) { return yield* failure(`${filePath} is missing its generated module ID marker`); } return moduleId; }); -const validateEmittedContract = ( - verticalDirectory: string, - target: string, - expected: OntosModuleDeploymentContract, -) => +const validateEmittedContract = (verticalDirectory: string, target: string, expected: OntosModuleDeploymentContract) => Effect.gen(function* validateGeneratedContract() { const fileSystem = yield* FileSystem.FileSystem; const publicDirectory = path.join(verticalDirectory, target, 'public'); @@ -247,10 +225,9 @@ const validateEmittedContract = ( yield* failure(`${contractPath} exceeds the 1 MiB contract limit`); } const serialized = new TextDecoder().decode(content); - const contract = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(OntosModuleDeploymentContractSchema), - { onExcessProperty: 'error' }, - )(serialized); + const contract = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OntosModuleDeploymentContractSchema), { + onExcessProperty: 'error', + })(serialized); if ( contract.deployment.appId !== expected.deployment.appId || contract.manifest.module.id !== expected.manifest.module.id @@ -260,11 +237,7 @@ const validateEmittedContract = ( if (!Equal.equals(contract, expected)) { yield* failure(`${contractPath} is stale relative to its authored module contract`); } - if ( - /sourcePath|importPath|exportPath|registrationPath|handlerPath|migrationPath/u.test( - serialized, - ) - ) { + if (/sourcePath|importPath|exportPath|registrationPath|handlerPath|migrationPath/u.test(serialized)) { yield* failure(`${contractPath} contains forbidden private path metadata`); } const validateResponseHeaders = Effect.gen(function* validateResponseHeadersEffect() { @@ -331,13 +304,10 @@ const checkVertical = (workspaceRoot: string, vertical: TopologyVertical, contra const appId = vertical.id; const relativePath = vertical.path; const verticalDirectory = path.join(workspaceRoot, relativePath); - const packageSource = yield* fileSystem.readFileString( - path.join(verticalDirectory, 'package.json'), - ); - const packageJson = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(ModulePackageSchema), - { onExcessProperty: 'preserve' }, - )(packageSource); + const packageSource = yield* fileSystem.readFileString(path.join(verticalDirectory, 'package.json')); + const packageJson = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ModulePackageSchema), { + onExcessProperty: 'preserve', + })(packageSource); const manifestPath = path.join(verticalDirectory, 'vertical.manifest.ts'); const registrationPath = path.join(verticalDirectory, 'vertical.registration.ts'); const [manifestModuleId, registrationModuleId] = yield* Effect.all([ @@ -348,23 +318,16 @@ const checkVertical = (workspaceRoot: string, vertical: TopologyVertical, contra packageJson.modernjs.appId !== appId || packageJson.modernjs.ontosModule.moduleId !== manifestModuleId || registrationModuleId !== manifestModuleId || - packageJson.modernjs.ontosModule.schemaVersion !== - ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION + packageJson.modernjs.ontosModule.schemaVersion !== ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION ) { - return yield* failure( - `${appId} package, manifest, registration, and topology identities disagree`, - ); + return yield* failure(`${appId} package, manifest, registration, and topology identities disagree`); } const verticalName = path.basename(relativePath); if ( !packageJson.scripts.build?.includes(`--vertical ${verticalName} --target dist`) || - !packageJson.scripts['cloudflare:build']?.includes( - `--vertical ${verticalName} --target cloudflare-dist`, - ) + !packageJson.scripts['cloudflare:build']?.includes(`--vertical ${verticalName} --target cloudflare-dist`) ) { - return yield* failure( - `${appId} build scripts do not emit both module-contract deployment targets`, - ); + return yield* failure(`${appId} build scripts do not emit both module-contract deployment targets`); } if (!contractUrl.endsWith(ONTOS_MODULE_CONTRACT_PATH)) { return yield* failure(`${appId} development module-contract URL is invalid`); @@ -372,13 +335,9 @@ const checkVertical = (workspaceRoot: string, vertical: TopologyVertical, contra const derived = yield* deriveOntosModuleDeploymentContract({ vertical: verticalName, workspaceRoot, - }).pipe( - Effect.mapError(() => failure(`${appId} authored module contract could not be derived`)), - ); + }).pipe(Effect.mapError(() => failure(`${appId} authored module contract could not be derived`))); if (derived.deployment.appId !== appId || derived.manifest.module.id !== manifestModuleId) { - return yield* failure( - `${appId} authored module contract disagrees with generated owner metadata`, - ); + return yield* failure(`${appId} authored module contract disagrees with generated owner metadata`); } yield* Effect.all([ validateEmittedContract(verticalDirectory, 'dist', derived), @@ -403,10 +362,9 @@ const checkOntosModuleContractsEffect = (workspaceRoot: string) => const overlaySource = yield* fileSystem.readFileString( path.join(workspaceRoot, 'topology/local-overlays/development.json'), ); - const overlay = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(DevelopmentOverlaySchema), - { onExcessProperty: 'preserve' }, - )(overlaySource); + const overlay = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(DevelopmentOverlaySchema), { + onExcessProperty: 'preserve', + })(overlaySource); const appIds = topology.verticals.map((vertical) => vertical.id); const allowlistKeys = Object.keys(overlay.ontosModuleManifests); const keysMatch = @@ -421,9 +379,7 @@ const checkOntosModuleContractsEffect = (workspaceRoot: string) => ), ); const moduleIds = contracts.map((entry) => entry.contract.manifest.module.id); - const duplicateModuleId = moduleIds.find( - (moduleId, index) => moduleIds.indexOf(moduleId) !== index, - ); + const duplicateModuleId = moduleIds.find((moduleId, index) => moduleIds.indexOf(moduleId) !== index); if (duplicateModuleId !== undefined) { yield* failure(`duplicate OntOS module ID ${duplicateModuleId}`); } @@ -433,14 +389,9 @@ const checkOntosModuleContractsEffect = (workspaceRoot: string) => export const checkOntosModuleContracts = (workspaceRoot = process.cwd()) => checkOntosModuleContractsEffect(workspaceRoot); -if ( - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href -) { +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const programLayer = Layer.effectDiscard( - checkOntosModuleContracts().pipe( - Effect.tap(() => Effect.logInfo('OntOS module contracts validated')), - ), + checkOntosModuleContracts().pipe(Effect.tap(() => Effect.logInfo('OntOS module contracts validated'))), ).pipe(Layer.provide(NodeServices.layer)); NodeRuntime.runMain(Effect.scoped(Layer.build(programLayer))); } diff --git a/app/scripts/check-ultramodern-api-boundaries.mts b/app/scripts/check-ultramodern-api-boundaries.mts index 90c8bcd99..6c2d59301 100644 --- a/app/scripts/check-ultramodern-api-boundaries.mts +++ b/app/scripts/check-ultramodern-api-boundaries.mts @@ -2,6 +2,7 @@ import { NodeFileSystem, NodePath, NodeRuntime } from '@effect/platform-node'; import { Config, Console, Effect, FileSystem, Layer, Path, Schema } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; + import { hasCompleteGeneratedModuleApiSeam } from './generated-governed-http-boundary.mts'; import { configuredMicroVerticalApiStem, @@ -14,10 +15,9 @@ import { unconstrainedHttpApiContractSchemaViolation, } from './ultramodern-api-boundary-rules.mts'; -class ApiBoundaryCheckFailed extends Schema.TaggedError()( - 'ApiBoundaryCheckFailed', - { failureCount: Schema.Int }, -) {} +class ApiBoundaryCheckFailed extends Schema.TaggedError()('ApiBoundaryCheckFailed', { + failureCount: Schema.Int, +}) {} const PackageJsonSchema = Schema.Struct({ exports: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), @@ -38,9 +38,7 @@ const TopologySchema = Schema.Struct({ }), ), effect: Schema.optionalKey(Schema.Json), - readiness: Schema.optionalKey( - Schema.Struct({ endpoint: Schema.optionalKey(Schema.String) }), - ), + readiness: Schema.optionalKey(Schema.Struct({ endpoint: Schema.optionalKey(Schema.String) })), runtime: Schema.optionalKey(Schema.String), serverEntry: Schema.optionalKey(Schema.String), }), @@ -94,10 +92,7 @@ const listWorkspaceFiles = ( if (info.type === 'Directory') { yield* visit(absoluteEntry); } else if (info.type === 'File') { - const normalized = path - .relative(workspaceRoot, absoluteEntry) - .split(path.sep) - .join('/'); + const normalized = path.relative(workspaceRoot, absoluteEntry).split(path.sep).join('/'); files.push(normalized); } } @@ -111,30 +106,23 @@ const listWorkspaceFiles = ( const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(path.resolve()), - ); + const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe(Config.withDefault(path.resolve())); const failures: string[] = []; const sourceByFile = new Map(); - const exists = (relativePath: string) => - fileSystem.exists(path.join(workspaceRoot, relativePath)); + const exists = (relativePath: string) => fileSystem.exists(path.join(workspaceRoot, relativePath)); - const readText = (relativePath: string) => - fileSystem.readFileString(path.join(workspaceRoot, relativePath), 'utf-8'); + const readText = (relativePath: string) => fileSystem.readFileString(path.join(workspaceRoot, relativePath), 'utf-8'); const topology = (yield* exists('topology/reference-topology.json')) ? yield* readText('topology/reference-topology.json').pipe(Effect.flatMap(decodeTopology)) : { verticals: [] }; const verticalApiStem = (verticalPath: string): string => - configuredMicroVerticalApiStem(verticalPath, topology.verticals ?? []) ?? - path.basename(verticalPath); + configuredMicroVerticalApiStem(verticalPath, topology.verticals ?? []) ?? path.basename(verticalPath); const topologyVertical = (verticalPath: string) => - (topology.verticals ?? []).find( - (vertical) => (vertical.path ?? `verticals/${vertical.id}`) === verticalPath, - ); + (topology.verticals ?? []).find((vertical) => (vertical.path ?? `verticals/${vertical.id}`) === verticalPath); const fail = (message: string): void => { failures.push(message); @@ -146,8 +134,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { } }; - const listFiles = (startDirectory: string) => - listWorkspaceFiles({ fileSystem, path, workspaceRoot }, startDirectory); + const listFiles = (startDirectory: string) => listWorkspaceFiles({ fileSystem, path, workspaceRoot }, startDirectory); const listDirectories = (startDirectory: string) => Effect.gen(function* listDirectoriesEffect() { @@ -175,37 +162,20 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { } }); - const assertContains = ( - relativePath: string, - content: string, - pattern: RegExp, - message: string, - ): void => { + const assertContains = (relativePath: string, content: string, pattern: RegExp, message: string): void => { assert(pattern.test(content), `${relativePath}: ${message}`); }; - const assertNotContains = ( - relativePath: string, - content: string, - pattern: RegExp, - message: string, - ): void => { + const assertNotContains = (relativePath: string, content: string, pattern: RegExp, message: string): void => { assert(!pattern.test(content), `${relativePath}: ${message}`); }; - const isGeneratedInfrastructureReadinessApi = ( - verticalPath: string, - content: string, - ): boolean => { + const isGeneratedInfrastructureReadinessApi = (verticalPath: string, content: string): boolean => { const stem = verticalApiStem(verticalPath); const apiPrefix = topologyVertical(verticalPath)?.api?.bff?.prefix; - const contractStem = stem.replaceAll(/-(?[a-z0-9])/gu, (_match, letter: string) => - letter.toUpperCase(), - ); + const contractStem = stem.replaceAll(/-(?[a-z0-9])/gu, (_match, letter: string) => letter.toUpperCase()); const endpoints = [ - ...content.matchAll( - /HttpApiEndpoint\.(?get|post)\(\s*'(?[^']+)'\s*,\s*'(?[^']+)'/gu, - ), + ...content.matchAll(/HttpApiEndpoint\.(?get|post)\(\s*'(?[^']+)'\s*,\s*'(?[^']+)'/gu), ].map((match) => { const method = match.groups?.method ?? ''; const name = match.groups?.name ?? ''; @@ -223,9 +193,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { }; const assertPrivateOwnerImports = (file: string, content: string): void => { - const imports = content.matchAll( - /(?:from\s+|import\s*\(|require\s*\()\s*['"](?[^'"]+)['"]/gu, - ); + const imports = content.matchAll(/(?:from\s+|import\s*\(|require\s*\()\s*['"](?[^'"]+)['"]/gu); for (const match of imports) { const specifier = match.groups?.specifier; if (specifier !== undefined) { @@ -265,9 +233,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { ...(yield* listFiles('verticals')), ...(yield* listFiles('packages')), ]; - const textFiles = generatedFiles.filter((file) => - /\.(?:[cm]?[jt]sx?|json|md|mjs|mts|cts)$/u.test(file), - ); + const textFiles = generatedFiles.filter((file) => /\.(?:[cm]?[jt]sx?|json|md|mjs|mts|cts)$/u.test(file)); for (const file of textFiles) { sourceByFile.set(file, yield* readText(file)); @@ -277,7 +243,10 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { assertPrivateOwnerImports(file, content); const unconstrainedContractSchema = file.includes('/tests/') ? undefined - : unconstrainedHttpApiContractSchemaViolation(content, { file, sources: sourceByFile }); + : unconstrainedHttpApiContractSchemaViolation(content, { + file, + sources: sourceByFile, + }); if (unconstrainedContractSchema !== undefined) { fail(`${file}: ${unconstrainedContractSchema}.`); } @@ -378,19 +347,11 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { if (yield* exists(apiEntry)) { const entry = yield* readText(apiEntry); const usesRpcRuntime = usesStrictRpcRuntimeTopology(entry, topologyResolverFor(apiEntry)); - const runtimeTopologyViolation = strictEffectRuntimeTopologyViolation( - entry, - topologyResolverFor(apiEntry), - ); + const runtimeTopologyViolation = strictEffectRuntimeTopologyViolation(entry, topologyResolverFor(apiEntry)); if (runtimeTopologyViolation !== undefined) { fail(`${apiEntry}: ${runtimeTopologyViolation}.`); } - assertContains( - apiEntry, - entry, - /\bLayer\b/u, - 'must compose dependencies with Effect Layer.', - ); + assertContains(apiEntry, entry, /\bLayer\b/u, 'must compose dependencies with Effect Layer.'); if (!usesRpcRuntime) { assertContains( apiEntry, @@ -414,19 +375,15 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { } else if (apiPrefix === undefined || apiPrefix.length === 0) { fail(`${sharedApi}: topology must declare api.bff.prefix.`); } else { - const baselineViolation = microVerticalApiBaselineViolation( - apiStem, - path.join(workspaceRoot, sharedApi), - { - additionalPaths: apiStem === 'checkout' ? { checkoutCartPath: `${basePath}/cart` } : {}, - apiPrefix, - basePath, - effectClientPackage: '@modern-js/plugin-bff/effect-client', - ownerId: vertical.id, - readinessPath: `${basePath}/readiness`, - sharedContractsPackage: '@app/shared-contracts', - }, - ); + const baselineViolation = microVerticalApiBaselineViolation(apiStem, path.join(workspaceRoot, sharedApi), { + additionalPaths: apiStem === 'checkout' ? { checkoutCartPath: `${basePath}/cart` } : {}, + apiPrefix, + basePath, + effectClientPackage: '@modern-js/plugin-bff/effect-client', + ownerId: vertical.id, + readinessPath: `${basePath}/readiness`, + sharedContractsPackage: '@app/shared-contracts', + }); assert( baselineViolation === undefined, `${sharedApi}: ${baselineViolation ?? 'invalid MicroVertical API baseline'}.`, @@ -439,30 +396,10 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { const sharedApi = `${appPath}/shared/api.ts`; if (yield* exists(sharedApi)) { const contract = yield* readText(sharedApi); - assertContains( - sharedApi, - contract, - /\bHttpApi\.make\b/u, - 'must declare the HttpApi contract.', - ); - assertContains( - sharedApi, - contract, - /\bHttpApiGroup\.make\b/u, - 'must declare HttpApi groups.', - ); - assertContains( - sharedApi, - contract, - /\bHttpApiEndpoint\./u, - 'must declare endpoints through HttpApiEndpoint.', - ); - assertContains( - sharedApi, - contract, - /\bSchema\./u, - 'must use Schema for request, response and error shapes.', - ); + assertContains(sharedApi, contract, /\bHttpApi\.make\b/u, 'must declare the HttpApi contract.'); + assertContains(sharedApi, contract, /\bHttpApiGroup\.make\b/u, 'must declare HttpApi groups.'); + assertContains(sharedApi, contract, /\bHttpApiEndpoint\./u, 'must declare endpoints through HttpApiEndpoint.'); + assertContains(sharedApi, contract, /\bSchema\./u, 'must use Schema for request, response and error shapes.'); if (appPath.startsWith('verticals/')) { assertVerticalBaseline(appPath, sharedApi); } @@ -483,9 +420,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { assert(yield* exists(srcApiDirectory), `${srcApiDirectory} is required.`); if (yield* exists(srcApiDirectory)) { - const clientFiles = (yield* listFiles(srcApiDirectory)).filter((file) => - file.endsWith('-client.ts'), - ); + const clientFiles = (yield* listFiles(srcApiDirectory)).filter((file) => file.endsWith('-client.ts')); assert(clientFiles.length > 0, `${srcApiDirectory} must contain a generated API client.`); } @@ -553,17 +488,14 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { const validateApiPackage = Effect.gen(function* validateApiPackageEffect() { if (yield* exists(packageJsonPath)) { - const packageJson = yield* readText(packageJsonPath).pipe( - Effect.flatMap(decodePackageJson), - ); + const packageJson = yield* readText(packageJsonPath).pipe(Effect.flatMap(decodePackageJson)); const isPrivateVerticalInfrastructureApi = appPath.startsWith('verticals/') && (yield* exists(sharedApi)) && isGeneratedInfrastructureReadinessApi(appPath, yield* readText(sharedApi)); if (isPrivateVerticalInfrastructureApi) { assert( - packageJson.exports?.['./api'] === undefined && - packageJson.exports?.['./api/client'] === undefined, + packageJson.exports?.['./api'] === undefined && packageJson.exports?.['./api/client'] === undefined, `${packageJsonPath}: infrastructure-only vertical APIs must remain private deployment surfaces.`, ); } else { @@ -583,10 +515,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { const inspectApiSurfaces = Effect.gen(function* inspectApiSurfacesEffect() { for (const appPath of appDirectories) { - if ( - (yield* exists(`${appPath}/api/index.ts`)) || - (yield* exists(`${appPath}/shared/api.ts`)) - ) { + if ((yield* exists(`${appPath}/api/index.ts`)) || (yield* exists(`${appPath}/shared/api.ts`))) { yield* assertApiSurface(appPath); } } @@ -624,12 +553,9 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { } if (yield* exists('package.json')) { - const rootPackageJson = yield* readText('package.json').pipe( - Effect.flatMap(decodePackageJson), - ); + const rootPackageJson = yield* readText('package.json').pipe(Effect.flatMap(decodePackageJson)); assert( - rootPackageJson.scripts?.['api:check'] === - 'node ./scripts/check-ultramodern-api-boundaries.mts', + rootPackageJson.scripts?.['api:check'] === 'node ./scripts/check-ultramodern-api-boundaries.mts', 'Root package.json must expose api:check.', ); assert( diff --git a/app/scripts/check-ultramodern-i18n-boundaries.mts b/app/scripts/check-ultramodern-i18n-boundaries.mts index 5d8b92667..400fab87b 100644 --- a/app/scripts/check-ultramodern-i18n-boundaries.mts +++ b/app/scripts/check-ultramodern-i18n-boundaries.mts @@ -1,5 +1,6 @@ #!/usr/bin/env node import path from 'node:path'; + import { runWorkspaceSourceCheck } from '@modern-js/code-tools'; const root = path.resolve(import.meta.dirname, '..'); diff --git a/app/scripts/database-trust-audit/collect-snapshot.mts b/app/scripts/database-trust-audit/collect-snapshot.mts index 0598cd443..e78478fd6 100644 --- a/app/scripts/database-trust-audit/collect-snapshot.mts +++ b/app/scripts/database-trust-audit/collect-snapshot.mts @@ -1,5 +1,6 @@ -import type { Client, ClientBase, QueryResult, QueryResultRow } from 'pg'; import { Effect, Schema } from 'effect'; +import type { Client, ClientBase, QueryResult, QueryResultRow } from 'pg'; + import { assertDatabaseSessionIdentities, assertSameDatabaseTarget, @@ -176,8 +177,7 @@ const query = ( try: async () => await client.query(statement, values), }); -export const hasTrustedContextValue = (value: string | null): boolean => - value !== null && value.length > 0; +export const hasTrustedContextValue = (value: string | null): boolean => value !== null && value.length > 0; const probeSettingEffect = Effect.fn('probeSetting')(function* probeSetting( client: ClientBase, @@ -187,16 +187,12 @@ const probeSettingEffect = Effect.fn('probeSetting')(function* probeSetting( yield* query(client, 'begin'); const settable = yield* Effect.gen(function* probeTrustedContextSetting() { yield* query(client, 'select set_config($1, $2, true)', [setting, value]); - const current = yield* query(client, 'select current_setting($1, true) as value', [ - setting, - ]); + const current = yield* query(client, 'select current_setting($1, true) as value', [setting]); const [currentRow] = current.rows; return currentRow?.value === value; }).pipe(Effect.catch(() => Effect.succeed(false))); yield* query(client, 'rollback'); - const after = yield* query(client, 'select current_setting($1, true) as value', [ - setting, - ]); + const after = yield* query(client, 'select current_setting($1, true) as value', [setting]); const [afterRow] = after.rows; return { retainedAfterRollback: hasTrustedContextValue(afterRow?.value ?? null), @@ -209,9 +205,7 @@ export const collectSnapshot = Effect.fn('collectSnapshot')(function* collectSna runtime: Client, ): Effect.fn.Return< DatabaseTrustBoundarySnapshot, - | DatabaseSessionIdentityFailure - | DatabaseTargetMismatchFailure - | DatabaseTrustBoundarySnapshotError + DatabaseSessionIdentityFailure | DatabaseTargetMismatchFailure | DatabaseTrustBoundarySnapshotError > { const targetQuery = `select current_user::text as current_role, @@ -1070,11 +1064,7 @@ export const collectSnapshot = Effect.fn('collectSnapshot')(function* collectSna grantable`, [runtimeRole, schemaNames, administrativeRole], ); - const tenant = yield* probeSettingEffect( - runtime, - 'ontos.tenant_id', - '00000000-0000-4000-8000-000000000001', - ); + const tenant = yield* probeSettingEffect(runtime, 'ontos.tenant_id', '00000000-0000-4000-8000-000000000001'); const legalEntity = yield* probeSettingEffect( runtime, 'ontos.legal_entity_id', diff --git a/app/scripts/database-trust-audit/report.mts b/app/scripts/database-trust-audit/report.mts index 068c76988..cbdb01a59 100644 --- a/app/scripts/database-trust-audit/report.mts +++ b/app/scripts/database-trust-audit/report.mts @@ -1,6 +1,6 @@ // oxlint-disable-next-line max-classes-per-file -- This report owns the three related tagged audit failures. -import type { Client } from 'pg'; import { Cause, Option, Schema } from 'effect'; +import type { Client } from 'pg'; interface DatabasePrivileges { readonly connect: boolean; @@ -211,9 +211,7 @@ export class DatabaseSessionIdentityError extends Schema.TaggedError, -): string => { +export const getDatabaseTrustBoundaryFailureMessage = (cause: Cause.Cause): string => { const failure = Cause.findErrorOption(cause); return Option.isSome(failure) && Schema.is(DatabaseTrustBoundaryAuditError)(failure.value) ? failure.value.reason @@ -221,17 +219,10 @@ export const getDatabaseTrustBoundaryFailureMessage = ( }; const hasDml = (table: TablePrivilege): boolean => - table.privileges.delete || - table.privileges.insert || - table.privileges.select || - table.privileges.update; + table.privileges.delete || table.privileges.insert || table.privileges.select || table.privileges.update; const hasClusterPrivilege = (role: RoleAttributes): boolean => - role.superuser || - role.bypassRls || - role.canCreateDatabases || - role.canCreateRoles || - role.replication; + role.superuser || role.bypassRls || role.canCreateDatabases || role.canCreateRoles || role.replication; const compareText = (left: string, right: string): number => { if (left < right) { @@ -243,10 +234,7 @@ const compareText = (left: string, right: string): number => { return 0; }; -const sorted = ( - values: Iterable, - compare: (left: Value, right: Value) => number, -): Value[] => { +const sorted = (values: Iterable, compare: (left: Value, right: Value) => number): Value[] => { const result: Value[] = []; for (const value of values) { const insertionIndex = result.findIndex((candidate) => compare(value, candidate) < 0); @@ -285,8 +273,7 @@ export const assertSameDatabaseTarget = ( Option.none(), () => new DatabaseTargetMismatchError({ - message: - 'DATABASE_ADMIN_URL and DATABASE_URL must target the same PostgreSQL server and database', + message: 'DATABASE_ADMIN_URL and DATABASE_URL must target the same PostgreSQL server and database', }), ); } @@ -303,10 +290,7 @@ export const assertDatabaseSessionIdentities = ( administrative: DatabaseSessionIdentity, runtime: DatabaseSessionIdentity, ): void => { - if ( - administrative.currentRole !== administrative.sessionRole || - runtime.currentRole !== runtime.sessionRole - ) { + if (administrative.currentRole !== administrative.sessionRole || runtime.currentRole !== runtime.sessionRole) { Option.getOrThrowWith( Option.none(), () => @@ -320,8 +304,7 @@ export const assertDatabaseSessionIdentities = ( Option.none(), () => new DatabaseSessionIdentityError({ - message: - 'DATABASE_ADMIN_URL and DATABASE_URL must authenticate as distinct authenticated PostgreSQL roles', + message: 'DATABASE_ADMIN_URL and DATABASE_URL must authenticate as distinct authenticated PostgreSQL roles', }), ); } @@ -340,8 +323,7 @@ const hasMembershipObjectAuthority = (membership: RoleMembership): boolean => ].some((objects) => objects.length > 0); const hasPrivilegedMembership = (membership: RoleMembership): boolean => - ((membership.canSetRole || membership.canAdministerRole) && - hasClusterPrivilege(membership.attributes)) || + ((membership.canSetRole || membership.canAdministerRole) && hasClusterPrivilege(membership.attributes)) || membership.predefinedRole === true || membership.databaseCreate || hasMembershipObjectAuthority(membership); @@ -354,16 +336,12 @@ const hasUsableViewPrivileges = (table: TablePrivilege): boolean => const hasPrivilegedViewOwner = (table: TablePrivilege, administrativeRole: string): boolean => (table.securityInvoker !== true && - (table.owner === administrativeRole || - table.ownerBypassRls === true || - table.ownerSuperuser === true)) || + (table.owner === administrativeRole || table.ownerBypassRls === true || table.ownerSuperuser === true)) || table.ownerContextPrivileged === true || table.ownerContextRlsBypass === true; const isPrivilegedOwnerView = (table: TablePrivilege, administrativeRole: string): boolean => - table.kind === 'view' && - hasUsableViewPrivileges(table) && - hasPrivilegedViewOwner(table, administrativeRole); + table.kind === 'view' && hasUsableViewPrivileges(table) && hasPrivilegedViewOwner(table, administrativeRole); const hasDdlAuthority = (snapshot: DatabaseTrustBoundarySnapshot): boolean => { const { memberships, routines, schemas, sequences, tables, types } = snapshot; @@ -375,10 +353,7 @@ const hasDdlAuthority = (snapshot: DatabaseTrustBoundarySnapshot): boolean => { const inheritsOwnership = memberships.some( ({ canInheritRole, ownedRelations, ownedRoutines, ownedSchemas, ownedTypes }) => canInheritRole && - (ownedRelations.length > 0 || - ownedRoutines.length > 0 || - ownedSchemas.length > 0 || - ownedTypes.length > 0), + (ownedRelations.length > 0 || ownedRoutines.length > 0 || ownedSchemas.length > 0 || ownedTypes.length > 0), ); return ( snapshot.databasePrivileges.create || @@ -400,8 +375,7 @@ export const buildDatabaseTrustBoundaryReport = ( ); const sequences = sorted( snapshot.sequences, - (left, right) => - compareText(left.schema, right.schema) || compareText(left.sequence, right.sequence), + (left, right) => compareText(left.schema, right.schema) || compareText(left.sequence, right.sequence), ); const routines = sorted( snapshot.routines, @@ -425,9 +399,7 @@ export const buildDatabaseTrustBoundaryReport = ( compareText(left.source, right.source) || Number(left.grantable) - Number(right.grantable), ); - const memberships = sorted(snapshot.memberships, (left, right) => - compareText(left.role, right.role), - ); + const memberships = sorted(snapshot.memberships, (left, right) => compareText(left.role, right.role)); const grantOptions = sorted(snapshot.grantOptions, compareText); const grantableDefaultPrivileges = defaultPrivileges.filter(({ grantable }) => grantable); const parameterPrivileges = sorted(snapshot.parameterPrivileges, (left, right) => @@ -436,16 +408,11 @@ export const buildDatabaseTrustBoundaryReport = ( const findings: DatabaseTrustBoundaryFinding[] = []; const dmlTables = tables.filter(hasDml); - addFinding( - findings, - hasClusterPrivilege(snapshot.role) || snapshot.role.predefinedRole === true, - { - code: 'runtime_role_is_privileged', - evidence: - 'The runtime role has a PostgreSQL cluster-level privilege or is a predefined PostgreSQL role.', - severity: 'critical', - }, - ); + addFinding(findings, hasClusterPrivilege(snapshot.role) || snapshot.role.predefinedRole === true, { + code: 'runtime_role_is_privileged', + evidence: 'The runtime role has a PostgreSQL cluster-level privilege or is a predefined PostgreSQL role.', + severity: 'critical', + }); const administrativeMembership = memberships.some( ({ canAdministerRole, canInheritRole, canSetRole, role }) => (canSetRole || canAdministerRole || canInheritRole) && role === snapshot.administrativeRole, @@ -469,8 +436,7 @@ export const buildDatabaseTrustBoundaryReport = ( }); addFinding(findings, nonAdministrativeMemberships.length > privilegedMemberships.length, { code: 'runtime_role_can_assume_other_role', - evidence: - 'The runtime role can inherit, SET ROLE to, or administer at least one additional identity.', + evidence: 'The runtime role can inherit, SET ROLE to, or administer at least one additional identity.', severity: 'high', }); addFinding(findings, hasDdlAuthority(snapshot), { @@ -480,28 +446,22 @@ export const buildDatabaseTrustBoundaryReport = ( severity: 'high', }); const relationControlTables = tables.filter( - ({ privileges }) => - privileges.maintain || privileges.references || privileges.trigger || privileges.truncate, + ({ privileges }) => privileges.maintain || privileges.references || privileges.trigger || privileges.truncate, ); addFinding(findings, relationControlTables.length > 0, { code: 'runtime_role_has_relation_control_authority', - evidence: - 'The runtime role has MAINTAIN, TRUNCATE, REFERENCES, or TRIGGER on an audited table-like relation.', + evidence: 'The runtime role has MAINTAIN, TRUNCATE, REFERENCES, or TRIGGER on an audited table-like relation.', severity: 'high', }); const executableSecurityDefiners = routines.filter( - ({ executable, owner, securityDefiner }) => - executable && securityDefiner && owner !== snapshot.runtimeRole, + ({ executable, owner, securityDefiner }) => executable && securityDefiner && owner !== snapshot.runtimeRole, ); addFinding(findings, executableSecurityDefiners.length > 0, { code: 'runtime_role_can_execute_security_definer', - evidence: - 'The runtime role can execute a SECURITY DEFINER routine owned by another role in an audited schema.', + evidence: 'The runtime role can execute a SECURITY DEFINER routine owned by another role in an audited schema.', severity: 'high', }); - const privilegedOwnerViews = tables.filter((table) => - isPrivilegedOwnerView(table, snapshot.administrativeRole), - ); + const privilegedOwnerViews = tables.filter((table) => isPrivilegedOwnerView(table, snapshot.administrativeRole)); addFinding(findings, privilegedOwnerViews.length > 0, { code: 'runtime_role_can_use_privileged_owner_view', evidence: @@ -531,12 +491,10 @@ export const buildDatabaseTrustBoundaryReport = ( ); addFinding( findings, - snapshot.trustedContext.tenantSettingSettable || - snapshot.trustedContext.legalEntitySettingSettable, + snapshot.trustedContext.tenantSettingSettable || snapshot.trustedContext.legalEntitySettingSettable, { code: 'runtime_role_can_forge_trusted_context', - evidence: - 'The ordinary runtime role can set and read at least one custom GUC used by tenant RLS.', + evidence: 'The ordinary runtime role can set and read at least one custom GUC used by tenant RLS.', severity: 'high', }, ); diff --git a/app/scripts/ensure-local-environment.mts b/app/scripts/ensure-local-environment.mts index 1053881d2..2a8e805c6 100644 --- a/app/scripts/ensure-local-environment.mts +++ b/app/scripts/ensure-local-environment.mts @@ -1,17 +1,6 @@ #!/usr/bin/env node import { NodeFileSystem, NodePath } from '@effect/platform-node'; -import { - Cause, - Config, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Redacted, - Schema, -} from 'effect'; +import { Cause, Config, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema } from 'effect'; import { APP_ENV_PATH } from '../packages/core-runtime/src/environment/workspace-environment.ts'; import { localPublicClientValues, localSpiceDbValues } from './local-environment-values.mts'; @@ -44,7 +33,9 @@ const LocalEnvironmentOverrides = Config.all({ httpPort: optionalTrimmedString('LOCAL_SPICEDB_HTTP_PORT'), preSharedKey: Config.option( Config.schema( - Schema.RedactedFromValue(Schema.Trim, { label: 'LOCAL_SPICEDB_PRESHARED_KEY' }), + Schema.RedactedFromValue(Schema.Trim, { + label: 'LOCAL_SPICEDB_PRESHARED_KEY', + }), 'LOCAL_SPICEDB_PRESHARED_KEY', ), ), @@ -56,9 +47,7 @@ const nonEmptyValue = (value: Option.Option): string | undefined => Option.getOrUndefined, ); -const nonEmptyRedactedValue = ( - value: Option.Option, -): Redacted.Redacted | undefined => +const nonEmptyRedactedValue = (value: Option.Option): Redacted.Redacted | undefined => value.pipe( Option.filter((candidate) => Redacted.value(candidate).length > 0), Option.getOrUndefined, @@ -67,12 +56,8 @@ const nonEmptyRedactedValue = ( const main = Effect.gen(function* ensureLocalEnvironment() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const topologyPath = yield* path.fromFileUrl( - new URL('../topology/reference-topology.json', import.meta.url), - ); - const overlayPath = yield* path.fromFileUrl( - new URL('../topology/local-overlays/development.json', import.meta.url), - ); + const topologyPath = yield* path.fromFileUrl(new URL('../topology/reference-topology.json', import.meta.url)); + const overlayPath = yield* path.fromFileUrl(new URL('../topology/local-overlays/development.json', import.meta.url)); const [original, topologySource, overlaySource, overrides] = yield* Effect.all([ fileSystem.readFileString(APP_ENV_PATH, 'utf-8'), fileSystem.readFileString(topologyPath, 'utf-8'), @@ -120,7 +105,9 @@ const main = Effect.gen(function* ensureLocalEnvironment() { } const temporaryPath = `${APP_ENV_PATH}.tmp-${process.pid}`; - yield* fileSystem.writeFileString(temporaryPath, `${updated.join('\n')}\n`, { mode: 0o600 }); + yield* fileSystem.writeFileString(temporaryPath, `${updated.join('\n')}\n`, { + mode: 0o600, + }); yield* fileSystem.rename(temporaryPath, APP_ENV_PATH); console.log(`Updated the canonical local environment at ${APP_ENV_PATH}`); }); diff --git a/app/scripts/generate-node-backend-federation.mts b/app/scripts/generate-node-backend-federation.mts index bf3a8fac0..5aa364da9 100644 --- a/app/scripts/generate-node-backend-federation.mts +++ b/app/scripts/generate-node-backend-federation.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect, Schema } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class BackendFederationGenerationError extends Schema.TaggedError()( @@ -8,8 +9,7 @@ class BackendFederationGenerationError extends Schema.TaggedError - new BackendFederationGenerationError({ reason }); +const failure = (reason: string): BackendFederationGenerationError => new BackendFederationGenerationError({ reason }); const exit = await Effect.runPromiseExit( runUltramodernScript({ diff --git a/app/scripts/generate-ontos-module-contract.mts b/app/scripts/generate-ontos-module-contract.mts index 87ed20987..fe220e7b9 100644 --- a/app/scripts/generate-ontos-module-contract.mts +++ b/app/scripts/generate-ontos-module-contract.mts @@ -3,11 +3,13 @@ import { createHash, randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; + import { NodeServices } from '@effect/platform-node'; import { Effect, Exit, FileSystem, ManagedRuntime, Path, Predicate, Schema, Stream } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; import { HttpApi } from 'effect/unstable/httpapi'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; + import { ONTOS_MODULE_CONTRACT_MAX_BYTES, ONTOS_MODULE_CONTRACT_PATH, @@ -17,10 +19,7 @@ import { OntosModuleIdSchema, extractVerticalRuntimeSafeDescriptors, } from '../packages/core-runtime/src/index.ts'; -import type { - OntosModuleManifest, - VerticalRuntimeRegistration, -} from '../packages/core-runtime/src/index.ts'; +import type { OntosModuleManifest, VerticalRuntimeRegistration } from '../packages/core-runtime/src/index.ts'; import { MODULE_CONTRACT_GENERATOR_HEADER, MODULE_MANIFEST_ACTION_SLOT_END, @@ -85,9 +84,7 @@ const ReferenceTopologySchema = Schema.Struct({ verticals: Schema.optional( Schema.Array( Schema.Struct({ - deliveryUnit: Schema.optional( - Schema.Struct({ buildMarker: Schema.optional(Schema.String) }), - ), + deliveryUnit: Schema.optional(Schema.Struct({ buildMarker: Schema.optional(Schema.String) })), id: Schema.optional(Schema.String), moduleFederation: Schema.optional(Schema.Struct({ name: Schema.optional(Schema.String) })), package: Schema.optional(Schema.String), @@ -97,8 +94,7 @@ const ReferenceTopologySchema = Schema.Struct({ ), }); -const isOntosModuleManifest = (cause: unknown): cause is OntosModuleManifest => - Predicate.isObject(cause); +const isOntosModuleManifest = (cause: unknown): cause is OntosModuleManifest => Predicate.isObject(cause); const isVerticalRuntimeRegistration = (cause: unknown): cause is VerticalRuntimeRegistration => Predicate.isObject(cause); const LoadedOwnerModuleSchema = Schema.Struct({ @@ -112,7 +108,9 @@ const ReferenceTopologyTextSchema = Schema.fromJsonString(ReferenceTopologySchem const ContractJsonTextSchema = Schema.fromJsonString(OntosModuleDeploymentContractSchema, { space: 2, }); -const JsonDocumentTextSchema = Schema.fromJsonString(Schema.Unknown, { space: 2 }); +const JsonDocumentTextSchema = Schema.fromJsonString(Schema.Unknown, { + space: 2, +}); const JsonStringTextSchema = Schema.fromJsonString(Schema.String); const canonicalSlugPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; @@ -128,14 +126,14 @@ const failure = (message: string, cause?: unknown): OntosModuleContractGeneratio new OntosModuleContractGenerationError({ cause, message }); const repositoryEsbuildPath = (): string => { - const createEntry = require.resolve('@modern-js/create'); - return require.resolve('esbuild/bin/esbuild', { paths: [path.dirname(createEntry)] }); + const createEntry = require.resolve('@modern-js/ultramodern-create'); + return require.resolve('esbuild/bin/esbuild', { + paths: [path.dirname(createEntry)], + }); }; const assertPlainTarget = (value: string, label: string, pattern: RegExp) => - pattern.test(value) - ? Effect.succeed(value) - : Effect.fail(failure(`${label} must be one safe generated identifier`)); + pattern.test(value) ? Effect.succeed(value) : Effect.fail(failure(`${label} must be one safe generated identifier`)); const assertOwnerSlots = (verticalDirectory: string) => Effect.gen(function* assertOwnerSlotsEffect() { @@ -168,9 +166,7 @@ const assertOwnerSlots = (verticalDirectory: string) => (owner) => fileSystem.readFileString(owner.path).pipe( Effect.map((content) => ({ ...owner, content })), - Effect.mapError((cause) => - failure(`unable to read module contract owner ${owner.path}`, cause), - ), + Effect.mapError((cause) => failure(`unable to read module contract owner ${owner.path}`, cause)), ), { concurrency: 'unbounded' }, ); @@ -194,35 +190,26 @@ const loadOwnerValues = (workspaceRoot: string, verticalDirectory: string, verti const platformPath = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const temporaryDirectory = yield* fileSystem - .makeTempDirectoryScoped({ directory: verticalDirectory, prefix: '.ontos-contract-' }) - .pipe( - Effect.mapError((cause) => - failure('unable to create the module contract temporary directory', cause), - ), - ); + .makeTempDirectoryScoped({ + directory: verticalDirectory, + prefix: '.ontos-contract-', + }) + .pipe(Effect.mapError((cause) => failure('unable to create the module contract temporary directory', cause))); const entryPath = platformPath.join(temporaryDirectory, 'entry.mts'); const bundlePath = platformPath.join(temporaryDirectory, 'bundle.mjs'); const manifestPath = platformPath.join(verticalDirectory, 'vertical.manifest.ts'); const registrationPath = platformPath.join(verticalDirectory, 'vertical.registration.ts'); const prefix = toCamelCase(vertical); - const encodedManifestPath = yield* Schema.encodeEffect(JsonStringTextSchema)( - manifestPath, - ).pipe( + const encodedManifestPath = yield* Schema.encodeEffect(JsonStringTextSchema)(manifestPath).pipe( Effect.mapError((cause) => failure('unable to encode the manifest owner path', cause)), ); - const encodedRegistrationPath = yield* Schema.encodeEffect(JsonStringTextSchema)( - registrationPath, - ).pipe( + const encodedRegistrationPath = yield* Schema.encodeEffect(JsonStringTextSchema)(registrationPath).pipe( Effect.mapError((cause) => failure('unable to encode the registration owner path', cause)), ); const entry = `import { ${prefix}Manifest as manifest } from ${encodedManifestPath};\nimport { ${prefix}Registration as registration } from ${encodedRegistrationPath};\nexport { manifest, registration };\n`; yield* fileSystem .writeFileString(entryPath, entry) - .pipe( - Effect.mapError((cause) => - failure('unable to write the module contract bundle entry', cause), - ), - ); + .pipe(Effect.mapError((cause) => failure('unable to write the module contract bundle entry', cause))); const esbuildPath = yield* Effect.try({ catch: (cause) => failure('unable to resolve the repository esbuild executable', cause), try: repositoryEsbuildPath, @@ -244,14 +231,10 @@ const loadOwnerValues = (workspaceRoot: string, verticalDirectory: string, verti ) .pipe(Effect.mapError((cause) => failure('module contract owner bundle failed', cause))); const bundleOutput = yield* Stream.mkString(handle.all.pipe(Stream.decodeText())).pipe( - Effect.mapError((cause) => - failure('unable to collect module contract bundle output', cause), - ), + Effect.mapError((cause) => failure('unable to collect module contract bundle output', cause)), ); const bundleExitCode = yield* handle.exitCode.pipe( - Effect.mapError((cause) => - failure('unable to read module contract bundle exit code', cause), - ), + Effect.mapError((cause) => failure('unable to read module contract bundle exit code', cause)), ); if (bundleExitCode !== ChildProcessSpawner.ExitCode(0)) { return yield* failure(`module contract owner bundle failed: ${bundleOutput.trim()}`); @@ -259,9 +242,7 @@ const loadOwnerValues = (workspaceRoot: string, verticalDirectory: string, verti return yield* Effect.tryPromise({ catch: (cause) => failure('unable to import the bundled module contract owners', cause), try: async (): Promise => - await decodeLoadedOwnerModule( - await import(`${pathToFileURL(bundlePath).href}?build=${randomUUID()}`), - ), + await decodeLoadedOwnerModule(await import(`${pathToFileURL(bundlePath).href}?build=${randomUUID()}`)), }); }), ); @@ -272,11 +253,7 @@ const componentExposes = (verticalDirectory: string) => const platformPath = yield* Path.Path; const config = yield* fileSystem .readFileString(platformPath.join(verticalDirectory, 'module-federation.config.ts')) - .pipe( - Effect.mapError((cause) => - failure('unable to read the Module Federation configuration', cause), - ), - ); + .pipe(Effect.mapError((cause) => failure('unable to read the Module Federation configuration', cause))); const exposes = new Set(); const pattern = /['"](?\.\/[A-Za-z][A-Za-z0-9_-]*)['"]\s*:\s*['"][^'"]+['"]/gu; for (const match of config.matchAll(pattern)) { @@ -294,10 +271,7 @@ const toKebab = (value: string): string => .replaceAll('_', '-') .toLowerCase(); -const sorted = ( - values: readonly Value[], - compare: (left: Value, right: Value) => number, -): readonly Value[] => { +const sorted = (values: readonly Value[], compare: (left: Value, right: Value) => number): readonly Value[] => { const result: Value[] = []; for (const value of values) { const insertionIndex = result.findIndex((existing) => compare(value, existing) < 0); @@ -327,34 +301,26 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw const verticalDirectory = platformPath.join(workspaceRoot, 'verticals', vertical); const packageJsonSource = yield* fileSystem .readFileString(platformPath.join(verticalDirectory, 'package.json')) - .pipe( - Effect.mapError((cause) => failure('unable to read the vertical package metadata', cause)), - ); - const packageJson = yield* Schema.decodeUnknownEffect(PackageJsonTextSchema)( - packageJsonSource, - ).pipe(Effect.mapError((cause) => failure('vertical package metadata is invalid', cause))); + .pipe(Effect.mapError((cause) => failure('unable to read the vertical package metadata', cause))); + const packageJson = yield* Schema.decodeUnknownEffect(PackageJsonTextSchema)(packageJsonSource).pipe( + Effect.mapError((cause) => failure('vertical package metadata is invalid', cause)), + ); const topologySource = yield* fileSystem .readFileString(platformPath.join(workspaceRoot, 'topology/reference-topology.json')) .pipe(Effect.mapError((cause) => failure('unable to read the reference topology', cause))); - const topology = yield* Schema.decodeUnknownEffect(ReferenceTopologyTextSchema)( - topologySource, - ).pipe(Effect.mapError((cause) => failure('reference topology is invalid', cause))); + const topology = yield* Schema.decodeUnknownEffect(ReferenceTopologyTextSchema)(topologySource).pipe( + Effect.mapError((cause) => failure('reference topology is invalid', cause)), + ); const matchesOwnerModule = () => packageJson.modernjs?.ontosModule?.moduleId === owner.manifest.module.id && - packageJson.modernjs.ontosModule.schemaVersion === - ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION; + packageJson.modernjs.ontosModule.schemaVersion === ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION; const validateDeploymentIdentity = Effect.gen(function* validateDeploymentIdentityEffect() { const appId = packageJson.modernjs?.appId; const topologyEntries = topology.verticals?.filter( - (entry) => - entry.id === appId && - entry.package === packageJson.name && - entry.path === `verticals/${vertical}`, + (entry) => entry.id === appId && entry.package === packageJson.name && entry.path === `verticals/${vertical}`, ); if (appId === undefined || topologyEntries?.length !== 1) { - return yield* failure( - 'vertical package and topology deployment identity do not match exactly', - ); + return yield* failure('vertical package and topology deployment identity do not match exactly'); } if (!matchesOwnerModule()) { return yield* failure('generated package module marker does not match the owner manifest'); @@ -373,9 +339,7 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw const componentKeys = Object.keys(owner.manifest.publicSurface.components); for (const key of componentKeys) { if (!exposes.has(`./${toPascalCase(key)}`)) { - return yield* failure( - `public component ${key} has no matching Module Federation exposure`, - ); + return yield* failure(`public component ${key} has no matching Module Federation exposure`); } } const safeRuntime = extractVerticalRuntimeSafeDescriptors(owner.registration); @@ -388,9 +352,7 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw manifestActionKeys.length !== runtimeActionKeys.length || manifestActionKeys.some((actionKey, index) => actionKey !== runtimeActionKeys[index]) ) { - return yield* failure( - 'manifest Actions and private runtime Action descriptors do not match', - ); + return yield* failure('manifest Actions and private runtime Action descriptors do not match'); } return { componentKeys, safeRuntime }; @@ -399,9 +361,7 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw const events = yield* Effect.forEach( owner.manifest.publicSurface.events, (event) => - Schema.encodeEffect(JsonDocumentTextSchema)( - Schema.toJsonSchemaDocument(event.payloadSchema), - ).pipe( + Schema.encodeEffect(JsonDocumentTextSchema)(Schema.toJsonSchemaDocument(event.payloadSchema)).pipe( Effect.map((payloadDocument) => ({ key: event.key, owningModuleId: event.owningModuleId, @@ -410,30 +370,24 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw tense: event.tense, visibility: event.visibility, })), - Effect.mapError((cause) => - failure(`unable to encode the ${event.key} event payload contract`, cause), - ), + Effect.mapError((cause) => failure(`unable to encode the ${event.key} event payload contract`, cause)), ), { concurrency: 'unbounded' }, ); - const apiContracts = yield* Effect.forEach( - Object.entries(owner.manifest.publicSurface.api), - ([key, value]) => { - if (!HttpApi.isHttpApi(value)) { - return Effect.fail(failure(`public API ${key} is not an HttpApi`)); - } - return Effect.succeed({ - key: `${owner.manifest.module.id}.${toKebab(key)}`, - operationKeys: deriveApiOperationKeys(value), - }); - }, - ); + const apiContracts = yield* Effect.forEach(Object.entries(owner.manifest.publicSurface.api), ([key, value]) => { + if (!HttpApi.isHttpApi(value)) { + return Effect.fail(failure(`public API ${key} is not an HttpApi`)); + } + return Effect.succeed({ + key: `${owner.manifest.module.id}.${toKebab(key)}`, + operationKeys: deriveApiOperationKeys(value), + }); + }); const contract = { deployment: { appId, buildMarker: - topologyEntry.deliveryUnit?.buildMarker ?? - sha256(`${appId}:${packageJson.version ?? '0.0.0'}`).slice(0, 16), + topologyEntry.deliveryUnit?.buildMarker ?? sha256(`${appId}:${packageJson.version ?? '0.0.0'}`).slice(0, 16), }, manifest: { activation: owner.manifest.activation, @@ -461,17 +415,13 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw } as const; return yield* Schema.decodeUnknownEffect(OntosModuleDeploymentContractSchema, { onExcessProperty: 'error', - })(contract).pipe( - Effect.mapError((cause) => failure('derived OntOS module contract is invalid', cause)), - ); + })(contract).pipe(Effect.mapError((cause) => failure('derived OntOS module contract is invalid', cause))); }); export const deriveOntosModuleDeploymentContract = (input: DeriveOntosModuleContractInput) => Effect.gen(function* deriveDeploymentContractProgram() { const platformPath = yield* Path.Path; - const workspaceRoot = platformPath.resolve( - input.workspaceRoot ?? platformPath.join(import.meta.dirname, '..'), - ); + const workspaceRoot = platformPath.resolve(input.workspaceRoot ?? platformPath.join(import.meta.dirname, '..')); const vertical = yield* assertPlainTarget(input.vertical, 'vertical', canonicalSlugPattern); const verticalDirectory = platformPath.join(workspaceRoot, 'verticals', vertical); yield* assertOwnerSlots(verticalDirectory); @@ -485,15 +435,16 @@ export const generateOntosModuleContract = (input: GenerateInput) => Effect.gen(function* generateContractProgram() { const fileSystem = yield* FileSystem.FileSystem; const platformPath = yield* Path.Path; - const workspaceRoot = platformPath.resolve( - input.workspaceRoot ?? platformPath.join(import.meta.dirname, '..'), - ); + const workspaceRoot = platformPath.resolve(input.workspaceRoot ?? platformPath.join(import.meta.dirname, '..')); const vertical = yield* assertPlainTarget(input.vertical, 'vertical', canonicalSlugPattern); - const target = yield* Schema.decodeUnknownEffect(OntosModuleContractTargetSchema)( - input.target, - ).pipe(Effect.mapError(() => failure('target must be dist or cloudflare-dist'))); + const target = yield* Schema.decodeUnknownEffect(OntosModuleContractTargetSchema)(input.target).pipe( + Effect.mapError(() => failure('target must be dist or cloudflare-dist')), + ); const verticalDirectory = platformPath.join(workspaceRoot, 'verticals', vertical); - const contract = yield* deriveOntosModuleDeploymentContract({ vertical, workspaceRoot }); + const contract = yield* deriveOntosModuleDeploymentContract({ + vertical, + workspaceRoot, + }); const encodedContract = yield* Schema.encodeEffect(ContractJsonTextSchema)(contract).pipe( Effect.mapError((cause) => failure('unable to encode the OntOS module contract', cause)), ); @@ -510,39 +461,20 @@ export const generateOntosModuleContract = (input: GenerateInput) => ); yield* fileSystem .makeDirectory(platformPath.dirname(outputPath), { recursive: true }) - .pipe( - Effect.mapError((cause) => - failure('unable to create the module contract output directory', cause), - ), - ); + .pipe(Effect.mapError((cause) => failure('unable to create the module contract output directory', cause))); const temporaryPath = `${outputPath}.tmp-${randomUUID()}`; yield* fileSystem .writeFileString(temporaryPath, content) - .pipe( - Effect.mapError((cause) => failure('unable to write the temporary module contract', cause)), - ); + .pipe(Effect.mapError((cause) => failure('unable to write the temporary module contract', cause))); yield* fileSystem .rename(temporaryPath, outputPath) - .pipe( - Effect.mapError((cause) => - failure('unable to publish the generated module contract', cause), - ), - ); + .pipe(Effect.mapError((cause) => failure('unable to publish the generated module contract', cause))); const etag = `"${sha256(content)}"`; - const headersPath = platformPath.join( - verticalDirectory, - outputRootByTarget[target], - 'public', - '_headers', - ); + const headersPath = platformPath.join(verticalDirectory, outputRootByTarget[target], 'public', '_headers'); const headers = `${ONTOS_MODULE_CONTRACT_PATH}\n Cache-Control: no-cache\n Content-Type: application/json\n ETag: ${etag}\n`; yield* fileSystem .writeFileString(headersPath, headers) - .pipe( - Effect.mapError((cause) => - failure('unable to write the module contract response headers', cause), - ), - ); + .pipe(Effect.mapError((cause) => failure('unable to write the module contract response headers', cause))); return { bytes, etag, path: outputPath }; }); @@ -559,10 +491,7 @@ const cli = Command.make( ), ); -if ( - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href -) { +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const moduleContractRuntime = ManagedRuntime.make(NodeServices.layer); const exit = await moduleContractRuntime.runPromiseExit( Command.run({ version: '1.0.0' })(cli).pipe(Effect.tapError((error) => Effect.logError(error))), diff --git a/app/scripts/generate-outbox-worker-deployment.mjs b/app/scripts/generate-outbox-worker-deployment.mjs index 62157ab24..614754ff2 100644 --- a/app/scripts/generate-outbox-worker-deployment.mjs +++ b/app/scripts/generate-outbox-worker-deployment.mjs @@ -1,6 +1,7 @@ import { NodeServices } from '@effect/platform-node'; import { Effect, FileSystem, ManagedRuntime, Path, Schema } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; + import { outboxWorkerDelivery } from './outbox-worker-delivery.mjs'; const TopologySchema = Schema.fromJsonString( @@ -38,9 +39,7 @@ const generateOutboxWorkerDeploymentEffect = (root, source) => Effect.gen(function* generateDeployment() { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const topologySource = yield* fs.readFileString( - path.join(root, 'topology/reference-topology.json'), - ); + const topologySource = yield* fs.readFileString(path.join(root, 'topology/reference-topology.json')); const topology = yield* Schema.decodeUnknownEffect(TopologySchema)(topologySource); let result = source.replace( /\n {2}# [\s\S]*? {2}# <\/generated-outbox-worker-deployments>\n?/u, @@ -77,25 +76,17 @@ const generateOutboxWorkerDeploymentEffect = (root, source) => .split('\n') .filter( (line) => - !line.includes(' run build') && - !line.includes("- cp 'app/topology/") && - !line.includes('VERTICAL_'), + !line.includes(' run build') && !line.includes("- cp 'app/topology/") && !line.includes('VERTICAL_'), ) .map((line) => line.includes('run zerops:materialize') - ? line.replace( - 'cd app && ', - 'cd app && ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" ', - ) + ? line.replace('cd app && ', 'cd app && ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" ') : line, ) .join('\n') .replace(/(?run zerops:materialize[^\n]*)/u, '$ --worker') .replaceAll(`/${vertical.id}-api/${vertical.id}/readiness`, '/ready') - .replace( - `ULTRAMODERN_ZEROPS_SERVICE: ${vertical.id}`, - `ULTRAMODERN_ZEROPS_SERVICE: ${delivery.id}`, - ) + .replace(`ULTRAMODERN_ZEROPS_SERVICE: ${vertical.id}`, `ULTRAMODERN_ZEROPS_SERVICE: ${delivery.id}`) .replace( ` PORT: '${port}'`, ` PORT: '${port}'\n OUTBOX_WORKER_HEALTH_PORT: '${port}'\n DATABASE_URL: \${${ownerServiceHostname}_DATABASE_URL}`, @@ -130,22 +121,17 @@ const runCommand = ({ write }) => yield* fs.writeFileString(file, generated); } else if (source !== generated) { yield* Effect.fail( - failure( - 'Worker deployment drift: run node scripts/generate-outbox-worker-deployment.mjs --write', - ), + failure('Worker deployment drift: run node scripts/generate-outbox-worker-deployment.mjs --write'), ); } }); const command = Command.make( 'generate-outbox-worker-deployment', - { write: Flag.boolean('write') }, + { write: Flag.boolean('write').pipe(Flag.withDefault(false)) }, runCommand, ); -/** @type {ImportMeta & { main?: boolean }} */ -const moduleMetadata = import.meta; - -if (moduleMetadata.main === true) { +if (import.meta.main) { void nodeRuntime.runPromise(Command.run(command, { version: '1.0.0' })); } diff --git a/app/scripts/generate-public-surface-assets.mts b/app/scripts/generate-public-surface-assets.mts index d10399f34..68ab3e5aa 100644 --- a/app/scripts/generate-public-surface-assets.mts +++ b/app/scripts/generate-public-surface-assets.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect, Schema } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class PublicSurfaceGenerationError extends Schema.TaggedError()( @@ -8,8 +9,7 @@ class PublicSurfaceGenerationError extends Schema.TaggedError - new PublicSurfaceGenerationError({ reason }); +const failure = (reason: string): PublicSurfaceGenerationError => new PublicSurfaceGenerationError({ reason }); const exit = await Effect.runPromiseExit( runUltramodernScript({ diff --git a/app/scripts/generate-tanstack-routes.mts b/app/scripts/generate-tanstack-routes.mts index 3388c9fed..3b8aad83f 100644 --- a/app/scripts/generate-tanstack-routes.mts +++ b/app/scripts/generate-tanstack-routes.mts @@ -1,33 +1,17 @@ #!/usr/bin/env node import { NodeRuntime, NodeServices } from '@effect/platform-node'; -import { - Array as EffectArray, - Console, - Effect, - FileSystem, - Layer, - Order, - Path, - Random, - Schema, -} from 'effect'; +import { Array as EffectArray, Console, Effect, FileSystem, Layer, Order, Path, Random, Schema } from 'effect'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import { launchUltramodern, resolveUltramodernInvocation } from './shared/ultramodern-command.mts'; + import { ModuleEntrypointSchema } from '../packages/core-runtime/src/modules/module-entrypoint.ts'; +import { launchUltramodern, resolveUltramodernInvocation } from './shared/ultramodern-command.mts'; const RouteMetadataIdentifierSchema = Schema.String.pipe(Schema.brand('RouteMetadataIdentifier')); -const JsonPrimitiveSchema = Schema.Union([ - Schema.Null, - Schema.Number, - Schema.Boolean, - Schema.String, -]); +const JsonPrimitiveSchema = Schema.Union([Schema.Null, Schema.Number, Schema.Boolean, Schema.String]); const RouteMetadataValueSchema = Schema.Tree(JsonPrimitiveSchema); const RouteMetadataFieldsSchema = Schema.Record(Schema.String, RouteMetadataValueSchema); -const RouteEntrypointSchema = Schema.StructWithRest(ModuleEntrypointSchema, [ - RouteMetadataFieldsSchema, -]); +const RouteEntrypointSchema = Schema.StructWithRest(ModuleEntrypointSchema, [RouteMetadataFieldsSchema]); const RouteMetadataSchema = Schema.StructWithRest( Schema.Struct({ @@ -79,27 +63,20 @@ const PackageConfigSchema = Schema.Struct({ ), }); -class RouteGenerationError extends Schema.TaggedError()( - 'RouteGenerationError', - { reason: Schema.String }, -) {} +class RouteGenerationError extends Schema.TaggedError()('RouteGenerationError', { + reason: Schema.String, +}) {} const failure = (reason: string): RouteGenerationError => new RouteGenerationError({ reason }); -const decodeUltramodernConfig = Schema.decodeUnknownEffect( - Schema.fromJsonString(UltramodernConfigSchema), -); +const decodeUltramodernConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(UltramodernConfigSchema)); const decodePackageConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageConfigSchema)); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); -const encodeJson = Schema.encodeEffect( - Schema.fromJsonString(RouteMetadataValueSchema, { space: 2 }), -); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(RouteMetadataValueSchema, { space: 2 })); const isJsonArray = Schema.is(Schema.Array(RouteMetadataValueSchema)); const isJsonObject = Schema.is(RouteMetadataFieldsSchema); -const sortJsonValue = ( - value: typeof RouteMetadataValueSchema.Type, -): typeof RouteMetadataValueSchema.Type => { +const sortJsonValue = (value: typeof RouteMetadataValueSchema.Type): typeof RouteMetadataValueSchema.Type => { if (isJsonArray(value)) { return value.map(sortJsonValue); } @@ -116,7 +93,9 @@ const findRouteMetadataFiles = ( Effect.gen(function* findRouteMetadataFilesEffect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const entries = yield* fileSystem.readDirectory(directory, { recursive: true }); + const entries = yield* fileSystem.readDirectory(directory, { + recursive: true, + }); const routeFiles = entries .filter((entry) => path.basename(entry) === 'route.meta.ts') .map((entry) => path.resolve(directory, entry)); @@ -148,23 +127,24 @@ const loadRouteMetadataFile = ( .pipe(Effect.mapError(() => failure(`Unable to resolve ${metadataFile}`))); const cacheNonce = yield* Random.nextInt; const moduleUrl = `${moduleFileUrl.href}?generated=${cacheNonce}`; - const routeModule = yield* Effect.tryPromise({ + const decodedModule = yield* Effect.tryPromise({ catch: () => failure(`Unable to import route metadata from ${metadataFile}`), - try: async () => - await Schema.decodeUnknownPromise(RouteMetadataModuleSchema)(await import(moduleUrl)), + try: async () => { + const importedModule: unknown = await import(moduleUrl); + return Schema.decodeUnknownResult(RouteMetadataModuleSchema)(importedModule); + }, }); + const routeModule = yield* Effect.fromResult(decodedModule).pipe( + Effect.mapError(() => failure(`Invalid route metadata in ${metadataFile}`)), + ); const route = routeModule.routeMeta ?? routeModule.default; if (route === undefined) { - return yield* Effect.fail( - failure(`${metadataFile} must export routeMeta or a default route metadata object`), - ); + return yield* Effect.fail(failure(`${metadataFile} must export routeMeta or a default route metadata object`)); } const expectedScope = appId.startsWith('shell-') ? 'system' : 'tenant'; if (!isGovernedPageEntrypoint(route, appId, moduleId, expectedScope)) { return yield* Effect.fail( - failure( - `${metadataFile} must declare one governed ${expectedScope} page entrypoint owned by ${appId}`, - ), + failure(`${metadataFile} must declare one governed ${expectedScope} page entrypoint owned by ${appId}`), ); } return route; @@ -191,30 +171,18 @@ const createLocalisedUrls = ( if (route.canonicalPath === '/') { return []; } - return EffectArray.sort( - [...new Set([route.canonicalPath, ...Object.values(route.localisedPaths)])], - Order.String, - ).map((pathname) => [pathname, route.localisedPaths]); + return [[route.canonicalPath, route.localisedPaths]]; }), ); -const runCommand = ( - executable: string, - args: readonly string[], - options: ChildProcess.CommandOptions, -) => +const runCommand = (executable: string, args: readonly string[], options: ChildProcess.CommandOptions) => Effect.gen(function* runCommandEffect() { const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* processSpawner.exitCode(ChildProcess.make(executable, args, options)); return Number(exitCode); }); -const generateRouteMetadataManifest = ( - appDirectory: string, - appId: string, - moduleId: string, - workspaceRoot: string, -) => +const generateRouteMetadataManifest = (appDirectory: string, appId: string, moduleId: string, workspaceRoot: string) => Effect.gen(function* generateRouteMetadataManifestEffect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -233,7 +201,7 @@ const generateRouteMetadataManifest = ( const encodedLocalisedUrls = yield* encodeJson(sortJsonValue(localisedUrls)).pipe( Effect.mapError(() => failure(`Unable to encode localised URLs for ${appId}`)), ); - const content = `// @generated by @modern-js/create. + const content = `// @generated by @modern-js/ultramodern-create. // Author route metadata in colocated src/routes/**/route.meta.ts files. // This compatibility manifest is regenerated from route-owned metadata. @@ -256,11 +224,7 @@ export const ultramodernLocalisedUrls = ${encodedLocalisedUrls} as const; stdout: 'inherit', }).pipe(Effect.mapError(() => failure(`Unable to launch the formatter for ${manifestPath}`))); if (formatStatus !== 0) { - yield* Effect.fail( - failure( - `Failed to format generated route metadata at ${manifestPath}: exit ${formatStatus}`, - ), - ); + yield* Effect.fail(failure(`Failed to format generated route metadata at ${manifestPath}: exit ${formatStatus}`)); } }); @@ -275,13 +239,6 @@ const program = Effect.gen(function* generateTanstackRoutesEffect() { moduleUrl: import.meta.url, }); const { forwardedArgs, workspaceRoot } = invocation; - const generationStatus = yield* launchUltramodern(invocation); - if (generationStatus !== 0) { - yield* Console.warn( - '[ultramodern] Framework route-artifact generation failed; continuing with the repository compatibility manifest. The application build remains the authoritative route-artifact gate.', - ); - } - const ultramodernConfigPath = path.join(workspaceRoot, '.modernjs/ultramodern.json'); const ultramodernConfigText = yield* fileSystem .readFileString(ultramodernConfigPath) @@ -307,16 +264,16 @@ const program = Effect.gen(function* generateTanstackRoutesEffect() { Effect.mapError(() => failure(`${packageConfigPath} is invalid`)), ); const moduleId = packageConfig.modernjs?.ontosModule?.moduleId ?? app.id; - yield* generateRouteMetadataManifest( - path.join(workspaceRoot, app.path), - app.id, - moduleId, - workspaceRoot, - ); + yield* generateRouteMetadataManifest(path.join(workspaceRoot, app.path), app.id, moduleId, workspaceRoot); yield* Console.log(`[ultramodern] Route metadata manifest generated: ${app.id}`); }), { concurrency: 1, discard: true }, ); + + const generationStatus = yield* launchUltramodern(invocation); + if (generationStatus !== 0) { + yield* Effect.fail(failure(`Framework route-artifact generation failed: exit ${generationStatus}`)); + } }); const reportFailure = (error: RouteGenerationError) => Console.error(error.reason); @@ -324,4 +281,6 @@ const MainLayer = Layer.effectDiscard(program.pipe(Effect.tapError(reportFailure Layer.provide(NodeServices.layer), ); -NodeRuntime.runMain(Effect.scoped(Layer.build(MainLayer)), { disableErrorReporting: true }); +NodeRuntime.runMain(Effect.scoped(Layer.build(MainLayer)), { + disableErrorReporting: true, +}); diff --git a/app/scripts/generated-governed-http-boundary.mts b/app/scripts/generated-governed-http-boundary.mts index 9c1a36e02..909f0b836 100644 --- a/app/scripts/generated-governed-http-boundary.mts +++ b/app/scripts/generated-governed-http-boundary.mts @@ -1,18 +1,17 @@ -import { - matchingDelimiter, - separatedSource, - topLevelSeparators, -} from './boundary-source-structure.mts'; -import { toCamelCase, toPascalCase, isCodePosition, maskNonCode } from './scaffolding/shared.mts'; import path from 'node:path'; + +import { Schema } from 'effect'; + +import { matchingDelimiter, separatedSource, topLevelSeparators } from './boundary-source-structure.mts'; import { hasGeneratedGovernedClientContract, + hasGeneratedSourceHeader, hasGeneratedModuleApiContract, hasGeneratedModuleApiReadContract, hasGeneratedOperationGatewayContract, hasGeneratedOperationPrincipalContract, } from './generated-module-api-boundary.mts'; -import { Schema } from 'effect'; +import { toCamelCase, toPascalCase, isCodePosition, maskNonCode } from './scaffolding/shared.mts'; const GOVERNED_API_SLOT_END = '// '; const GOVERNED_API_SLOT_START = '// '; @@ -28,12 +27,9 @@ const SEARCH_PROVIDER_KIND = 'search-provider'; const GOVERNED_HANDLER_LAYER_SLOT_START = '// '; const GOVERNED_HANDLER_LAYER_SLOT_END = '// '; const HTTP_API_CONTRACT_MODULE = 'effect/unstable/httpapi'; +const GOVERNED_HTTP_API_IDENTITY_ALIAS = 'governedHttpApiIdentity'; -const GovernedReadKindSchema = Schema.Literals([ - MODULE_API_KIND, - REPORT_KIND, - SEARCH_PROVIDER_KIND, -]); +const GovernedReadKindSchema = Schema.Literals([MODULE_API_KIND, REPORT_KIND, SEARCH_PROVIDER_KIND]); type GovernedReadKind = typeof GovernedReadKindSchema.Type; const isGovernedReadKind = Schema.is(GovernedReadKindSchema); @@ -43,8 +39,7 @@ interface GovernedReadContribution { readonly name: string; } -const escapeRegExp = (value: string): string => - value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); +const escapeRegExp = (value: string): string => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); const matches = (source: string | undefined, expression: RegExp): boolean => source !== undefined && expression.test(source); @@ -52,20 +47,12 @@ const matches = (source: string | undefined, expression: RegExp): boolean => const hasExactlyOne = (source: string | undefined, expression: RegExp): boolean => source !== undefined && [...source.matchAll(expression)].length === 1; -const matchingDelimiterEnd = ( - source: string, - start: number, - opening: string, - closing: string, -): number | undefined => matchingDelimiter(maskNonCode(source), start, opening, closing); +const matchingDelimiterEnd = (source: string, start: number, opening: string, closing: string): number | undefined => + matchingDelimiter(maskNonCode(source), start, opening, closing); const maskComments = (source: string): string => maskNonCode(source, true); -const callArgument = ( - source: string | undefined, - declaration: RegExp, - argumentIndex = 0, -): string | undefined => { +const callArgument = (source: string | undefined, declaration: RegExp, argumentIndex = 0): string | undefined => { if (source === undefined) { return undefined; } @@ -83,19 +70,12 @@ const callArgument = ( if (callEnd === undefined) { return undefined; } - return separatedSource( - code, - topLevelSeparators(structure, ',', callStart + 1, callEnd), - callStart + 1, - callEnd, - )[argumentIndex]; + return separatedSource(code, topLevelSeparators(structure, ',', callStart + 1, callEnd), callStart + 1, callEnd)[ + argumentIndex + ]; }; -const objectArgument = ( - source: string | undefined, - declaration: RegExp, - argumentIndex = 0, -): string | undefined => { +const objectArgument = (source: string | undefined, declaration: RegExp, argumentIndex = 0): string | undefined => { const argument = callArgument(source, declaration, argumentIndex); if (argument === undefined) { return undefined; @@ -184,8 +164,7 @@ const codeDepthBeforePosition = (source: string, target: number): number => const codeDepthAtPosition = (source: string, target: number): number | undefined => isCodePosition(source, target) ? codeDepthBeforePosition(source, target) : undefined; -const isTopLevelCodePosition = (source: string, target: number): boolean => - codeDepthAtPosition(source, target) === 0; +const isTopLevelCodePosition = (source: string, target: number): boolean => codeDepthAtPosition(source, target) === 0; interface SourceRange { readonly end: number; @@ -215,7 +194,11 @@ const assignedExpressionRange = (source: string, declaration: RegExp): SourceRan const value = code.slice(start, index); const valueStart = start + value.length - value.trimStart().length; const valueEnd = start + value.trimEnd().length; - return { end: valueEnd, start: valueStart, value: code.slice(valueStart, valueEnd) }; + return { + end: valueEnd, + start: valueStart, + value: code.slice(valueStart, valueEnd), + }; }; const assignedExpression = (source: string, declaration: RegExp): string | undefined => @@ -228,9 +211,7 @@ const isEffectFnCallback = (source: string): boolean => { const opening = source.indexOf('('); const closing = matchingDelimiterEnd(source, opening, '(', ')'); const invocation = closing === undefined ? '' : source.slice(closing + 1).trim(); - const callback = invocation.startsWith('(') - ? callArgument(`invoke${invocation}`, /^invoke\(/u) - : undefined; + const callback = invocation.startsWith('(') ? callArgument(`invoke${invocation}`, /^invoke\(/u) : undefined; return ( callback !== undefined && matchingDelimiterEnd(invocation, 0, '(', ')') === invocation.length - 1 && @@ -257,9 +238,7 @@ const isExecutableCallback = (candidate: string | undefined, ownerSource?: strin ownerSource, new RegExp(`(?:export\\s+)?const ${escapeRegExp(candidate)}\\s*=\\s*`, 'u'), ); - return ( - initializer !== undefined && initializer !== candidate && isExecutableCallback(initializer) - ); + return initializer !== undefined && initializer !== candidate && isExecutableCallback(initializer); }; const callbackReturnedExpression = (handlerSource: string): string | undefined => { @@ -290,10 +269,7 @@ const isReadHandlerCallback = (candidate: string | undefined, ownerSource: strin return false; } const resolved = /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(candidate) - ? assignedExpression( - ownerSource, - new RegExp(`(?:export\\s+)?const ${escapeRegExp(candidate)}\\s*=\\s*`, 'u'), - ) + ? assignedExpression(ownerSource, new RegExp(`(?:export\\s+)?const ${escapeRegExp(candidate)}\\s*=\\s*`, 'u')) : candidate; if (resolved === undefined) { return false; @@ -302,9 +278,7 @@ const isReadHandlerCallback = (candidate: string | undefined, ownerSource: strin return /\byield\*|\.pipe\(/u.test(maskNonCode(resolved, true)); } const expression = callbackReturnedExpression(resolved); - return /\bEffect\.[A-Za-z_$][A-Za-z0-9_$]*\s*\(|\.pipe\(/u.test( - maskNonCode(expression ?? '', true), - ); + return /\bEffect\.[A-Za-z_$][A-Za-z0-9_$]*\s*\(|\.pipe\(/u.test(maskNonCode(expression ?? '', true)); }; interface GeneratedSlotRange { @@ -315,11 +289,7 @@ interface GeneratedSlotRange { readonly markerStart: number; } -const generatedSlotRange = ( - source: string, - startMarker: string, - endMarker: string, -): GeneratedSlotRange | undefined => { +const generatedSlotRange = (source: string, startMarker: string, endMarker: string): GeneratedSlotRange | undefined => { const markerStart = source.indexOf(startMarker); const markerEnd = source.indexOf(endMarker); const depth = markerStart === -1 ? undefined : codeDepthBeforePosition(source, markerStart); @@ -383,8 +353,7 @@ const entriesAcrossSlots = ( }; const objectEntryKey = (entry: string): string | undefined => { - const match = - /^(?:'(?[^']+)'|"(?[^"]+)"|(?[A-Za-z][A-Za-z0-9-]*))\s*:/u.exec(entry); + const match = /^(?:'(?[^']+)'|"(?[^"]+)"|(?[A-Za-z][A-Za-z0-9-]*))\s*:/u.exec(entry); return match?.groups?.single ?? match?.groups?.double ?? match?.groups?.bare; }; @@ -401,15 +370,15 @@ const slotHasExactlyOneCodeMatch = ( } const flags = expression.flags.includes('g') ? expression.flags : `${expression.flags}g`; return ( - [ - ...source.slice(slot.bodyStart, slot.bodyEnd).matchAll(new RegExp(expression.source, flags)), - ].filter((candidate) => { - if (candidate.index === undefined) { - return false; - } - const depth = codeDepthAtPosition(source, slot.bodyStart + candidate.index); - return depth !== undefined && (depthOffset === null || depth === slot.depth + depthOffset); - }).length === 1 + [...source.slice(slot.bodyStart, slot.bodyEnd).matchAll(new RegExp(expression.source, flags))].filter( + (candidate) => { + if (candidate.index === undefined) { + return false; + } + const depth = codeDepthAtPosition(source, slot.bodyStart + candidate.index); + return depth !== undefined && (depthOffset === null || depth === slot.depth + depthOffset); + }, + ).length === 1 ); }; @@ -444,24 +413,18 @@ const defaultExportExpression = (source: string): string | undefined => const returnedEffectBffDefinition = (source: string): string | undefined => { const structure = maskNonCode(source); - const returnedCalls = [ - ...structure.matchAll(/\breturn\s+(?:defineEffectBff|assembleEffectBffRuntime)\(/gu), - ]; + const returnedCalls = [...structure.matchAll(/\breturn\s+(?:defineEffectBff|assembleEffectBffRuntime)\(/gu)]; if (returnedCalls.length !== 1 || returnedCalls[0]?.index === undefined) { return undefined; } const callStart = - returnedCalls[0].index + - returnedCalls[0][0].search(/(?:defineEffectBff|assembleEffectBffRuntime)\(/u); + returnedCalls[0].index + returnedCalls[0][0].search(/(?:defineEffectBff|assembleEffectBffRuntime)\(/u); const opening = structure.indexOf('(', callStart); const closing = matchingDelimiterEnd(structure, opening, '(', ')'); if (closing === undefined) { return undefined; } - return objectArgument( - source.slice(callStart, closing + 1), - /^(?:defineEffectBff|assembleEffectBffRuntime)\(/u, - ); + return objectArgument(source.slice(callStart, closing + 1), /^(?:defineEffectBff|assembleEffectBffRuntime)\(/u); }; const exportedRuntimeFactory = (source: string): SourceRange | undefined => { @@ -469,26 +432,15 @@ const exportedRuntimeFactory = (source: string): SourceRange | undefined => { if (exported === undefined || !/^[A-Za-z][A-Za-z0-9]*$/u.test(exported)) { return undefined; } - const runtimeInitializer = assignedExpression( - source, - new RegExp(`const ${escapeRegExp(exported)}\\s*=\\s*`, 'u'), - ); - const factory = /^(?make[A-Za-z][A-Za-z0-9]*ApiRuntime)\(/u.exec( - runtimeInitializer ?? '', - )?.groups?.factory; + const runtimeInitializer = assignedExpression(source, new RegExp(`const ${escapeRegExp(exported)}\\s*=\\s*`, 'u')); + const factory = /^(?make[A-Za-z][A-Za-z0-9]*ApiRuntime)\(/u.exec(runtimeInitializer ?? '')?.groups?.factory; const factoryExpression = factory === undefined ? undefined - : assignedExpressionRange( - source, - new RegExp(`export const ${escapeRegExp(factory)}\\s*=\\s*`, 'u'), - ); + : assignedExpressionRange(source, new RegExp(`export const ${escapeRegExp(factory)}\\s*=\\s*`, 'u')); return factoryExpression === undefined || runtimeInitializer === undefined || - !isWholeCallExpression( - runtimeInitializer, - new RegExp(`^${escapeRegExp(factory ?? '')}\\(`, 'u'), - ) + !isWholeCallExpression(runtimeInitializer, new RegExp(`^${escapeRegExp(factory ?? '')}\\(`, 'u')) ? undefined : factoryExpression; }; @@ -510,28 +462,26 @@ const effectBffDefinition = (source: string): string | undefined => { return factory === undefined ? undefined : returnedEffectBffDefinition(factory.value); }; -const hasExactValueImport = (source: string, value: string, modulePath: string): boolean => { +const hasExactValueImport = ( + source: string, + value: string, + modulePath: string, + allowAdditionalImports = false, +): boolean => { const code = maskComments(source); const imports = [ ...source.matchAll( - new RegExp( - `^\\s*import\\s*\\{(?[^}]*)\\}\\s*from\\s*'${escapeRegExp(modulePath)}';`, - 'gmu', - ), + new RegExp(`^\\s*import\\s*\\{(?[^}]*)\\}\\s*from\\s*'${escapeRegExp(modulePath)}';`, 'gmu'), ), - ].filter( - (candidate) => candidate.index !== undefined && isTopLevelCodePosition(source, candidate.index), - ); + ].filter((candidate) => candidate.index !== undefined && isTopLevelCodePosition(source, candidate.index)); const importedValues = imports.flatMap((candidate) => (candidate.groups?.values ?? '').split(',').map((entry) => entry.trim()), ); + const importedBinding = /\bas\s+(?[A-Za-z_$][A-Za-z0-9_$]*)\s*$/u.exec(value)?.groups?.binding ?? value; return ( - imports.length === 1 && + (allowAdditionalImports || imports.length === 1) && importedValues.filter((candidate) => candidate === value).length === 1 && - !matches( - code, - new RegExp(`\\b(?:class|const|function|let|var)\\s+${escapeRegExp(value)}\\b`, 'u'), - ) + !matches(code, new RegExp(`\\b(?:class|const|function|let|var)\\s+${escapeRegExp(importedBinding)}\\b`, 'u')) ); }; @@ -558,21 +508,13 @@ const slotIsMountedByAssembler = ( if (handlers === undefined || !/^[A-Za-z][A-Za-z0-9]*$/u.test(handlers)) { return false; } - const resolved = assignedExpression( - runtimeSource, - new RegExp(`const ${escapeRegExp(handlers)}\\s*=\\s*`, 'u'), - ); + const resolved = assignedExpression(runtimeSource, new RegExp(`const ${escapeRegExp(handlers)}\\s*=\\s*`, 'u')); return ( - resolved !== undefined && - isWholeCallExpression(resolved, new RegExp(`^${escapeRegExp(layerName)}\\.pipe\\(`, 'u')) + resolved !== undefined && isWholeCallExpression(resolved, new RegExp(`^${escapeRegExp(layerName)}\\.pipe\\(`, 'u')) ); }; -const definesExpectedRuntime = ( - definition: string | undefined, - expectedApi: string, - runtimeName: string, -): boolean => +const definesExpectedRuntime = (definition: string | undefined, expectedApi: string, runtimeName: string): boolean => definition !== undefined && !definition.includes('...') && objectPropertyValue(definition, 'api') === expectedApi && @@ -591,8 +533,7 @@ const legacyRuntimeMount = ( layerName: string, expectedApi: string, ): boolean => { - const runtimeDeclaration = - /const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApiBuilder\.layer\(/u.exec(runtimeSource); + const runtimeDeclaration = /const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApiBuilder\.layer\(/u.exec(runtimeSource); const runtimeName = runtimeDeclaration?.groups?.name; if (runtimeDeclaration === null || runtimeName === undefined) { return false; @@ -606,10 +547,7 @@ const legacyRuntimeMount = ( const definition = effectBffDefinition(source); return ( callArgument(runtimeSlice, /HttpApiBuilder\.layer\(/u) === expectedApi && - matches( - runtimeSlice, - new RegExp(`(?:GovernedReadLayer|Layer)\\.provide\\(${escapeRegExp(layerName)}\\)`, 'u'), - ) && + matches(runtimeSlice, new RegExp(`(?:GovernedReadLayer|Layer)\\.provide\\(${escapeRegExp(layerName)}\\)`, 'u')) && hasExactlyOne(code, /\bdefineEffectBff\(/gu) && definesExpectedRuntime(definition, expectedApi, runtimeName) ); @@ -629,9 +567,7 @@ const slotIsInsideMountedLayer = ( const declarations = [ ...code .slice(0, slot.markerStart) - .matchAll( - /(?:export\s+)?const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*(?:GovernedReadLayer|Layer)\.mergeAll\(/gu, - ), + .matchAll(/(?:export\s+)?const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*(?:GovernedReadLayer|Layer)\.mergeAll\(/gu), ]; let declaration: RegExpExecArray | undefined; for (const candidate of declarations) { @@ -676,9 +612,7 @@ export const governedApiBinding = (source: string): string | undefined => { return undefined; } const candidates = [ - ...maskComments(source).matchAll( - /export const (?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApi\.make\(/gu, - ), + ...maskComments(source).matchAll(/export const (?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApi\.make\(/gu), ] .filter((match) => isTopLevelCodePosition(source, match.index)) .map((match) => match.groups?.name) @@ -686,10 +620,7 @@ export const governedApiBinding = (source: string): string | undefined => { if (name === undefined) { return false; } - const root = assignedExpressionRange( - source, - new RegExp(`export const ${escapeRegExp(name)}\\s*=\\s*`, 'u'), - ); + const root = assignedExpressionRange(source, new RegExp(`export const ${escapeRegExp(name)}\\s*=\\s*`, 'u')); const statementEnd = root === undefined ? undefined : apiStatementEnd(source, root.start); return ( root !== undefined && @@ -706,10 +637,7 @@ const governedSharedApiRoot = (source: string): SourceRange | undefined => { const apiRoot = binding === undefined ? undefined - : assignedExpressionRange( - source, - new RegExp(`export const ${escapeRegExp(binding)}\\s*=\\s*`, 'u'), - ); + : assignedExpressionRange(source, new RegExp(`export const ${escapeRegExp(binding)}\\s*=\\s*`, 'u')); const slot = generatedSlotRange(source, GOVERNED_API_SLOT_START, GOVERNED_API_SLOT_END); if (apiRoot === undefined || slot === undefined) { return undefined; @@ -720,18 +648,18 @@ const governedSharedApiRoot = (source: string): SourceRange | undefined => { } const additions = maskNonCode(source.slice(slot.bodyStart, slot.bodyEnd), true).trim(); const trailing = maskComments( - source - .slice(slot.markerEnd + GOVERNED_API_SLOT_END.length, statementEnd) - .replace(/^;(?=\r?\n)/u, ''), + source.slice(slot.markerEnd + GOVERNED_API_SLOT_END.length, statementEnd).replace(/^;(?=\r?\n)/u, ''), ).trim(); + const hasStableIdentityTail = + trailing === `.pipe(${GOVERNED_HTTP_API_IDENTITY_ALIAS})` && + hasExactValueImport(source, `identity as ${GOVERNED_HTTP_API_IDENTITY_ALIAS}`, 'effect', true); return /^(?:\.addHttpApi\([A-Za-z][A-Za-z0-9]*\)\s*)*$/u.test(additions) && - (trailing === '' || trailing === '.pipe(identity)') + (trailing === '' || trailing === '.pipe(identity)' || hasStableIdentityTail) ? apiRoot : undefined; }; -const hasGovernedSharedApiRoot = (source: string): boolean => - governedSharedApiRoot(source) !== undefined; +const hasGovernedSharedApiRoot = (source: string): boolean => governedSharedApiRoot(source) !== undefined; const hasInjectedGovernedReadRuntime = (source: string): boolean => { const factory = exportedRuntimeFactory(source); @@ -742,9 +670,7 @@ const hasInjectedGovernedReadRuntime = (source: string): boolean => { // The generated binding may be the first explicitly typed injection in an owner runtime // factory. Its caller and mounted layers are checked separately, and TS checks the layer type. const injection = - /const\s+\[\s*governedReadRuntimeLive,\s*[A-Za-z0-9_,\s]+\]\s*=\s*(?[A-Za-z][A-Za-z0-9]*)\s*;/u.exec( - code, - ); + /const\s+\[\s*governedReadRuntimeLive,\s*[A-Za-z0-9_,\s]+\]\s*=\s*(?[A-Za-z][A-Za-z0-9]*)\s*;/u.exec(code); const args = injection?.groups?.args; if ( args === undefined || @@ -753,15 +679,12 @@ const hasInjectedGovernedReadRuntime = (source: string): boolean => { ) { return false; } - const signature = new RegExp( - `\\.\\.\\.${escapeRegExp(args)}:\\s*(?[A-Za-z][A-Za-z0-9]*)`, - 'u', - ).exec(code); + const signature = new RegExp(`\\.\\.\\.${escapeRegExp(args)}:\\s*(?[A-Za-z][A-Za-z0-9]*)`, 'u').exec(code); const type = signature?.groups?.type; return ( type !== undefined && new RegExp( - `type ${escapeRegExp(type)}\\s*=\\s*readonly\\s*\\[\\s*readRuntime:\\s*Layer\\.Layer]`, + `type ${escapeRegExp(type)}\\s*=\\s*readonly\\s*\\[\\s*readRuntime:\\s*Layer\\.Layer<\\s*ReadRuntime\\s*[,>]`, 'u', ).test(maskNonCode(source)) && effectBffDefinition(source) !== undefined @@ -786,8 +709,7 @@ const hasGovernedHandlerRoot = (source: string): boolean => { const ownerRuntime = assignedExpression(source, /const readRuntimeLive\s*=\s*/u); if ( !wholeCall(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) || - callArgument(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) !== - 'Layer.provide(readRuntimeDependenciesLive)' + callArgument(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) !== 'Layer.provide(readRuntimeDependenciesLive)' ) { return false; } @@ -805,23 +727,12 @@ const hasGovernedSupportSlots = (handlerRoot: string): boolean => { const supportImportStart = '// '; const supportLayerStart = '// '; const supportImports = handlerRoot.includes(supportImportStart) - ? generatedSlotRange( - handlerRoot, - supportImportStart, - '// ', - ) + ? generatedSlotRange(handlerRoot, supportImportStart, '// ') : null; const supportLayers = handlerRoot.includes(supportLayerStart) - ? generatedSlotRange( - handlerRoot, - supportLayerStart, - '// ', - ) + ? generatedSlotRange(handlerRoot, supportLayerStart, '// ') : null; - const generatedHandlers = assignedExpressionRange( - handlerRoot, - /export const governedReadApiHandlersLive\s*=\s*/u, - ); + const generatedHandlers = assignedExpressionRange(handlerRoot, /export const governedReadApiHandlersLive\s*=\s*/u); return ( (supportImports === null || supportImports?.depth === 0) && (supportLayers === null || @@ -832,10 +743,7 @@ const hasGovernedSupportSlots = (handlerRoot: string): boolean => { ); }; -export const hasValidGovernedHttpCompositionRoot = ( - sharedApi: string, - handlerRoot: string, -): boolean => { +export const hasValidGovernedHttpCompositionRoot = (sharedApi: string, handlerRoot: string): boolean => { const sharedRoot = governedSharedApiRoot(sharedApi); const expectedApi = governedApiBinding(sharedApi); const sharedImports = generatedSlotRange( @@ -866,17 +774,12 @@ export const hasValidGovernedHttpCompositionRoot = ( ); }; -const governedReadContribution = ( - contractStem: string, - source: string, -): GovernedReadContribution | undefined => { - if (source.startsWith(MODULE_API_HEADER)) { +const governedReadContribution = (contractStem: string, source: string): GovernedReadContribution | undefined => { + if (hasGeneratedSourceHeader(source, MODULE_API_HEADER)) { return { contractStem, kind: MODULE_API_KIND, name: contractStem }; } - const candidateKind = /^\/\/ @ontos-contribution-kind (?report|search-provider)$/mu.exec( - source, - )?.groups?.kind; - if (!source.startsWith(GOVERNED_CONTRIBUTION_HEADER) || !isGovernedReadKind(candidateKind)) { + const candidateKind = /^\/\/ @ontos-contribution-kind (?report|search-provider)$/mu.exec(source)?.groups?.kind; + if (!hasGeneratedSourceHeader(source, GOVERNED_CONTRIBUTION_HEADER) || !isGovernedReadKind(candidateKind)) { return undefined; } const kind = candidateKind; @@ -892,9 +795,7 @@ const governedReadContribution = ( }; const generatedHeader = (kind: GovernedReadKind): string => - kind === MODULE_API_KIND - ? MODULE_API_HEADER - : `${GOVERNED_CONTRIBUTION_HEADER}// @ontos-contribution-kind ${kind}\n`; + kind === MODULE_API_KIND ? MODULE_API_HEADER : `${GOVERNED_CONTRIBUTION_HEADER}// @ontos-contribution-kind ${kind}\n`; const contributionProfiles = { 'module-api': { @@ -937,8 +838,7 @@ const contributionGroup = (kind: GovernedReadKind, name: string): string => `${toCamelCase(name)}${contributionProfiles[kind].typeSuffix}`; const contributionApiValue = (kind: GovernedReadKind, name: string): string => `${toPascalCase(name)}${contributionProfiles[kind].typeSuffix}Api`; -const contributionReadDirectory = (kind: GovernedReadKind): string => - contributionProfiles[kind].directory; +const contributionReadDirectory = (kind: GovernedReadKind): string => contributionProfiles[kind].directory; const contributionSchemaStem = (kind: GovernedReadKind, name: string): string => `${toPascalCase(name)}${contributionProfiles[kind].schemaSuffix}`; const contributionEndpoint = (contribution: GovernedReadContribution, moduleId: string): string => @@ -950,43 +850,27 @@ const hasObjectProperties = (source: string, expected: Readonly objectProperty(source, property) === value); const hasContractImports = (source: string, expected: Readonly>): boolean => - Object.entries(expected).every(([name, specifier]) => - hasExactValueImport(source, name, specifier), - ); + Object.entries(expected).every(([name, specifier]) => hasExactValueImport(source, name, specifier)); -const isWholeObjectCall = ( - expression: string | undefined, - value: string | undefined, - callee: RegExp, -): boolean => +const isWholeObjectCall = (expression: string | undefined, value: string | undefined, callee: RegExp): boolean => expression !== undefined && value !== undefined && !value.includes('...') && isWholeCallExpression(expression, callee); -const hasReadDescriptorPolicy = ( - read: string, - allowedAccessKinds: ReadonlySet, -): boolean => { +const hasReadDescriptorPolicy = (read: string, allowedAccessKinds: ReadonlySet): boolean => { const policies = objectProperty(read, 'policies'); return ( allowedAccessKinds.has(objectProperty(read, 'accessKind') ?? '') && matches(objectProperty(read, 'legalEntityScope'), /^'(?:required|optional|forbidden)'$/u) && - matches( - objectProperty(read, 'permissionTarget'), - /^'(?:legal_entity|module|resource|tenant)'$/u, - ) && + matches(objectProperty(read, 'permissionTarget'), /^'(?:legal_entity|module|resource|tenant)'$/u) && policies !== undefined && policies.startsWith('[') && matchingDelimiterEnd(policies, 0, '[', ']') === policies.length - 1 ); }; -const hasReadCallbacks = ( - source: string, - readExpression: string, - kind: GovernedReadKind, -): boolean => { +const hasReadCallbacks = (source: string, readExpression: string, kind: GovernedReadKind): boolean => { const handler = callArgument(readExpression, /^defineRead\(/u, 1); const callbacks = [2, 3]; if (kind === SEARCH_PROVIDER_KIND) { @@ -994,28 +878,16 @@ const hasReadCallbacks = ( } return ( isReadHandlerCallback(handler, source) && - callbacks.every((index) => - isExecutableCallback(callArgument(readExpression, /^defineRead\(/u, index), source), - ) + callbacks.every((index) => isExecutableCallback(callArgument(readExpression, /^defineRead\(/u, index), source)) ); }; -const hasReadEntrypoint = ( - entrypoint: string, - identity: Readonly>, -): boolean => { +const hasReadEntrypoint = (entrypoint: string, identity: Readonly>): boolean => { const access = objectProperty(entrypoint, 'access'); - return ( - (access === "'read'" || access === "'historical_read'") && - hasObjectProperties(entrypoint, identity) - ); + return (access === "'read'" || access === "'historical_read'") && hasObjectProperties(entrypoint, identity); }; -const hasReadContract = ( - source: string, - contribution: GovernedReadContribution, - moduleId: string, -): boolean => { +const hasReadContract = (source: string, contribution: GovernedReadContribution, moduleId: string): boolean => { const camel = toCamelCase(contribution.name); const schemaStem = contributionSchemaStem(contribution.kind, contribution.name); const escapedCamel = escapeRegExp(camel); @@ -1026,10 +898,7 @@ const hasReadContract = ( source, new RegExp(`(?:export )?const ${escapedCamel}Entrypoint\\s*=\\s*`, 'u'), ); - const readExpression = assignedExpression( - source, - new RegExp(`export const ${escapedCamel}Read\\s*=\\s*`, 'u'), - ); + const readExpression = assignedExpression(source, new RegExp(`export const ${escapedCamel}Read\\s*=\\s*`, 'u')); const entrypoint = objectArgument(entrypointExpression, /^defineTenantModuleEntrypoint\(/u); const read = objectArgument(readExpression, /^defineRead\(/u); const inputSchema = `${schemaStem}RequestSchema`; @@ -1137,10 +1006,7 @@ const hasOnlyThinServerStatements = ( } const imports = /\bimport\s+\{[^}]*\}\s+from\s+['"](?[^'"]+)['"]\s*;/gu; for (const match of source.matchAll(imports)) { - if ( - isTopLevelCodePosition(source, match.index) && - allowedImports.has(match.groups?.module ?? '') - ) { + if (isTopLevelCodePosition(source, match.index) && allowedImports.has(match.groups?.module ?? '')) { spans.push({ end: match.index + match[0].length, start: match.index }); } } @@ -1205,10 +1071,7 @@ const hasServerContract = ( apiBinding: string, ): boolean => { const code = maskComments(source); - const layerExpression = assignedExpression( - code, - new RegExp(`export const ${escapedCamel}ReadApiLive\\s*=\\s*`, 'u'), - ); + const layerExpression = assignedExpression(code, new RegExp(`export const ${escapedCamel}ReadApiLive\\s*=\\s*`, 'u')); const options = serverHandlerOptions(layerExpression, escapedGroup, apiBinding); return ( options !== undefined && @@ -1234,22 +1097,12 @@ const hasServerContract = ( makeGovernedReadHttpHandler: GOVERNED_READ_HTTP_MODULE, }) && hasProblemSet(source, schemaStem, contractImport) && - [/HttpApiBuilder\.group\(/gu, /makeGovernedReadHttpHandler\(/gu].every((pattern) => - hasExactlyOne(code, pattern), - ) + [/HttpApiBuilder\.group\(/gu, /makeGovernedReadHttpHandler\(/gu].every((pattern) => hasExactlyOne(code, pattern)) ); }; -const hasProblemSchemaContract = ( - source: string, - schema: string, - status: number, - retryable: boolean, -): boolean => { - const expression = assignedExpression( - source, - new RegExp(`export const ${escapeRegExp(schema)}\\s*=\\s*`, 'u'), - ); +const hasProblemSchemaContract = (source: string, schema: string, status: number, retryable: boolean): boolean => { + const expression = assignedExpression(source, new RegExp(`export const ${escapeRegExp(schema)}\\s*=\\s*`, 'u')); const factory = retryable ? 'makeRetryableProblemDetailsSchema' : 'makeProblemDetailsSchema'; const call = new RegExp(`^${factory}\\(`, 'u'); return ( @@ -1263,11 +1116,7 @@ const hasProblemSchemaContract = ( ); }; -const addedContractMember = ( - expression: string | undefined, - factory: string, - name: string, -): string | undefined => { +const addedContractMember = (expression: string | undefined, factory: string, name: string): string | undefined => { if (expression === undefined || !expression.startsWith(`${factory}.make(`)) { return undefined; } @@ -1298,10 +1147,7 @@ const hasHttpContract = ( endpointPath: string, ): boolean => { const escapedApiValue = escapeRegExp(apiValue); - const expression = assignedExpression( - source, - new RegExp(`export const ${escapedApiValue}\\s*=\\s*`, 'u'), - ); + const expression = assignedExpression(source, new RegExp(`export const ${escapedApiValue}\\s*=\\s*`, 'u')); const groupExpression = addedContractMember(expression, 'HttpApi', apiValue); const endpointExpression = addedContractMember(groupExpression, 'HttpApiGroup', group); if (!isExecuteEndpoint(endpointExpression, endpointPath)) { @@ -1338,40 +1184,25 @@ const hasHttpContract = ( ); }; -const hasManifestContract = ( - manifest: string, - contribution: GovernedReadContribution, - moduleId: string, - escapedContractStem: string, - escapedApiValue: string, -): boolean => { +const hasManifestContract = (manifest: string, contribution: GovernedReadContribution, moduleId: string): boolean => { const allOwnerManifestEntries = entriesAcrossSlots(manifest, [ [MANIFEST_API_SLOT_START, MANIFEST_API_SLOT_END], ['// ', '// '], ['// ', '// '], ]); if (contribution.kind === MODULE_API_KIND) { - const apiEntries = generatedSlotEntries( - manifest, - MANIFEST_API_SLOT_START, - MANIFEST_API_SLOT_END, - ); + const apiEntries = generatedSlotEntries(manifest, MANIFEST_API_SLOT_START, MANIFEST_API_SLOT_END); return ( - slotHasExactlyOneCodeMatch( + hasExactValueImport( manifest, - '// ', - '// ', - new RegExp( - `import \\{ ${escapedApiValue} \\} from './shared/apis/${escapedContractStem}\\.ts';`, - 'gu', - ), + contributionApiValue(contribution.kind, contribution.name), + `./shared/apis/${contribution.contractStem}.ts`, ) && [apiEntries, allOwnerManifestEntries].every( (entries) => entries?.filter( (entry) => - entry === - `'${contribution.contractStem}': ${contributionApiValue(contribution.kind, contribution.name)}`, + entry === `'${contribution.contractStem}': ${contributionApiValue(contribution.kind, contribution.name)}`, ).length === 1, ) ); @@ -1390,18 +1221,12 @@ const hasManifestContract = ( ); const manifestKey = `${moduleId}.${contribution.name}`; const entrypointKey = `${moduleId}.${role}.${contribution.name}`; - const published = manifestEntries?.filter( - (entry) => objectProperty(entry, 'key') === `'${manifestKey}'`, - ); - const allPublished = allOwnerManifestEntries?.filter( - (entry) => objectProperty(entry, 'key') === `'${manifestKey}'`, - ); - const shellFunction = - contribution.kind === REPORT_KIND ? 'reportContribution' : 'searchContribution'; + const published = manifestEntries?.filter((entry) => objectProperty(entry, 'key') === `'${manifestKey}'`); + const allPublished = allOwnerManifestEntries?.filter((entry) => objectProperty(entry, 'key') === `'${manifestKey}'`); + const shellFunction = contribution.kind === REPORT_KIND ? 'reportContribution' : 'searchContribution'; const shell = shellEntries?.filter((entry) => { const shellObject = objectArgument(entry, new RegExp(`^${shellFunction}\\(`, 'u')); - const entrypoint = - shellObject === undefined ? undefined : objectProperty(shellObject, 'entrypoint'); + const entrypoint = shellObject === undefined ? undefined : objectProperty(shellObject, 'entrypoint'); return ( shellObject !== undefined && isWholeCallExpression(entry, new RegExp(`^${shellFunction}\\(`, 'u')) && @@ -1414,10 +1239,7 @@ const hasManifestContract = ( return published?.length === 1 && allPublished?.length === 1 && shell?.length === 1; }; -const hasPublishedRegistration = ( - registration: string, - contribution: GovernedReadContribution, -): boolean => { +const hasPublishedRegistration = (registration: string, contribution: GovernedReadContribution): boolean => { const escapedName = escapeRegExp(contribution.name); const escapedContractStem = escapeRegExp(contribution.contractStem); const { registrationCategory, registrationSection } = contributionProfiles[contribution.kind]; @@ -1449,8 +1271,7 @@ const hasPublishedRegistration = ( (file === name || file === `${name}-report` || file === `${name}-search`) ); }) === true && - allRegistrationEntries?.filter((entry) => objectEntryKey(entry) === contribution.name) - .length === 1 && + allRegistrationEntries?.filter((entry) => objectEntryKey(entry) === contribution.name).length === 1 && slotIsInsideObjectProperty( registration, registrationCategory, @@ -1460,30 +1281,19 @@ const hasPublishedRegistration = ( ); }; -const publishesSharedApiContribution = ( - sharedApi: string, - escapedApiValue: string, - escapedContractStem: string, -): boolean => - hasExactlyOne( - maskComments(sharedApi), - new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), - ) && - slotHasExactlyOneCodeMatch( - sharedApi, - '// ', - '// ', - new RegExp( - `import \\{ ${escapedApiValue} \\} from './apis/${escapedContractStem}\\.ts';`, - 'gu', - ), - ) && - slotHasExactlyOneCodeMatch( - sharedApi, - GOVERNED_API_SLOT_START, - GOVERNED_API_SLOT_END, - new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), +const publishesSharedApiContribution = (sharedApi: string, apiValue: string, contractStem: string): boolean => { + const escapedApiValue = escapeRegExp(apiValue); + return ( + hasExactlyOne(maskComments(sharedApi), new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu')) && + hasExactValueImport(sharedApi, apiValue, `./apis/${contractStem}.ts`) && + slotHasExactlyOneCodeMatch( + sharedApi, + GOVERNED_API_SLOT_START, + GOVERNED_API_SLOT_END, + new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), + ) ); +}; const hasPublishedContract = ( sharedApi: string, @@ -1492,15 +1302,13 @@ const hasPublishedContract = ( handlerRoot: string, contribution: GovernedReadContribution, moduleId: string, + diagnostics?: string[], ): boolean => { const expectedApi = governedApiBinding(sharedApi); const apiValue = contributionApiValue(contribution.kind, contribution.name); const camel = toCamelCase(contribution.name); const serverStem = contributionServerStem(contribution); - const escapedContractStem = escapeRegExp(contribution.contractStem); - const escapedApiValue = escapeRegExp(apiValue); const escapedCamel = escapeRegExp(camel); - const escapedServerStem = escapeRegExp(serverStem); const handlerLayers = generatedSlotEntries( handlerRoot, GOVERNED_HANDLER_LAYER_SLOT_START, @@ -1516,57 +1324,44 @@ const hasPublishedContract = ( return false; } let index = 1; - let argument = callArgument( - entry, - new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), - index, - ); + let argument = callArgument(entry, new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), index); while (argument !== undefined) { if (argument === '') { - return ( - callArgument( - entry, - new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), - index + 1, - ) === undefined - ); + return callArgument(entry, new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), index + 1) === undefined; } if (!/^Layer\.provide\([A-Za-z][A-Za-z0-9]*\)$/u.test(argument)) { return false; } index += 1; - argument = callArgument( - entry, - new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), - index, - ); + argument = callArgument(entry, new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'), index); } return entry.startsWith(`${expectedLayer}.pipe(`); }; - return ( - expectedApi !== undefined && - hasGovernedSharedApiRoot(sharedApi) && - hasGovernedHandlerRoot(handlerRoot) && - publishesSharedApiContribution(sharedApi, escapedApiValue, escapedContractStem) && - hasManifestContract(manifest, contribution, moduleId, escapedContractStem, escapedApiValue) && - hasPublishedRegistration(registration, contribution) && - slotHasExactlyOneCodeMatch( - handlerRoot, - '// ', - '// ', - new RegExp( - `import \\{ ${escapedCamel}ReadApiLive \\} from './${escapedServerStem}-server\\.ts';`, - 'gu', + const publicationChecks = { + apiBinding: expectedApi !== undefined, + handlerImport: hasExactValueImport(handlerRoot, `${camel}ReadApiLive`, `./${serverStem}-server.ts`), + handlerLayer: handlerLayers?.filter(isExpectedHandlerLayer).length === 1, + handlerRoot: hasGovernedHandlerRoot(handlerRoot), + manifest: hasManifestContract(manifest, contribution, moduleId), + mountedLayer: + expectedApi !== undefined && + slotIsInsideMountedLayer( + handlerRoot, + GOVERNED_HANDLER_LAYER_SLOT_START, + GOVERNED_HANDLER_LAYER_SLOT_END, + expectedApi, ), - ) && - handlerLayers?.filter(isExpectedHandlerLayer).length === 1 && - slotIsInsideMountedLayer( - handlerRoot, - GOVERNED_HANDLER_LAYER_SLOT_START, - GOVERNED_HANDLER_LAYER_SLOT_END, - expectedApi, - ) - ); + registration: hasPublishedRegistration(registration, contribution), + sharedContribution: publishesSharedApiContribution(sharedApi, apiValue, contribution.contractStem), + sharedRoot: hasGovernedSharedApiRoot(sharedApi), + }; + const failed = Object.entries(publicationChecks) + .filter(([, valid]) => !valid) + .map(([name]) => name); + if (failed.length > 0) { + diagnostics?.push(`${contribution.name} publication: ${failed.join(', ')}`); + } + return failed.length === 0; }; /** @@ -1579,10 +1374,7 @@ const hasActionBoundaryAuthentication = (source: string | undefined): boolean => if (source === undefined || !source.startsWith(actionBoundaryHeader)) { return false; } - const expression = assignedExpression( - source, - /export const authenticateOperationPrincipal\s*=\s*/u, - ); + const expression = assignedExpression(source, /export const authenticateOperationPrincipal\s*=\s*/u); return ( expression !== undefined && isWholeCallExpression(expression, /^makeMicroverticalHttpPrincipalAuthentication\(/u) && @@ -1597,8 +1389,7 @@ const hasActionBoundaryAuthentication = (source: string | undefined): boolean => const hasActionBoundaryGateway = (source: string | undefined, appId: string): boolean => source !== undefined && source.startsWith(actionBoundaryHeader) && - assignedExpression(source, /export const operationGateway\s*=\s*/u) === - 'makeOperationGateway()' && + assignedExpression(source, /export const operationGateway\s*=\s*/u) === 'makeOperationGateway()' && hasGeneratedOperationGatewayContract(source, appId); type GeneratedContribution = readonly [string, string, GovernedReadContribution | undefined]; @@ -1608,11 +1399,7 @@ const hasMatchingModuleApiSlots = ( contributions: readonly GeneratedContribution[], ): boolean => { const moduleApiCount = contributions.filter((entry) => entry[2]?.kind === MODULE_API_KIND).length; - const manifestApis = generatedSlotEntries( - manifest, - MANIFEST_API_SLOT_START, - MANIFEST_API_SLOT_END, - ); + const manifestApis = generatedSlotEntries(manifest, MANIFEST_API_SLOT_START, MANIFEST_API_SLOT_END); const registrationApis = generatedSlotEntries( registration, '// ', @@ -1631,15 +1418,12 @@ const generatedReadContributions = ( candidate.startsWith(contractPrefix) && candidate.endsWith('.ts') && !candidate.slice(contractPrefix.length).includes('/') && - (source.startsWith(MODULE_API_HEADER) || source.startsWith(GOVERNED_CONTRIBUTION_HEADER)), + (hasGeneratedSourceHeader(source, MODULE_API_HEADER) || + hasGeneratedSourceHeader(source, GOVERNED_CONTRIBUTION_HEADER)), ); return generatedContracts.map( ([candidate, source]) => - [ - candidate, - source, - governedReadContribution(path.posix.basename(candidate, '.ts'), source), - ] as const, + [candidate, source, governedReadContribution(path.posix.basename(candidate, '.ts'), source)] as const, ); }; @@ -1663,30 +1447,32 @@ const loadContributionSources = ( const { readSuffix } = contributionProfiles[contribution.kind]; const readPath = `src/${readDirectory}/${contribution.name}${readSuffix}.ts`; const readSource = sources.get(`${verticalPath}/${readPath}`); - const clientSource = sources.get( - `${verticalPath}/src/api/${contribution.contractStem}-client.ts`, - ); - const serverSource = sources.get( - `${verticalPath}/api/${contributionServerStem(contribution)}-server.ts`, - ); + const clientSource = sources.get(`${verticalPath}/src/api/${contribution.contractStem}-client.ts`); + const serverSource = sources.get(`${verticalPath}/api/${contributionServerStem(contribution)}-server.ts`); const header = generatedHeader(contribution.kind); if ( readSource === undefined || clientSource === undefined || serverSource === undefined || ![contractSource, readSource, clientSource, serverSource].every((source) => - source.startsWith(header), + hasGeneratedSourceHeader(source, header), ) ) { return undefined; } - return { clientSource, readImport: `../${readPath}`, readSource, serverSource }; + return { + clientSource, + readImport: `../${readPath}`, + readSource, + serverSource, + }; }; export const hasCompleteGeneratedModuleApiSeam = ( sources: ReadonlyMap, sharedApiFile: string, deploymentAppId = path.posix.basename(sharedApiFile.slice(0, -'/shared/api.ts'.length)), + diagnostics?: string[], ): boolean => { const verticalPath = sharedApiFile.slice(0, -'/shared/api.ts'.length); const sharedApi = sources.get(sharedApiFile); @@ -1705,10 +1491,8 @@ export const hasCompleteGeneratedModuleApiSeam = ( ) { return false; } - if ( - !hasActionBoundaryAuthentication(principal) || - !hasActionBoundaryGateway(gateway, deploymentAppId) - ) { + if (!hasActionBoundaryAuthentication(principal) || !hasActionBoundaryGateway(gateway, deploymentAppId)) { + diagnostics?.push('owner authentication or gateway'); return false; } @@ -1718,6 +1502,7 @@ export const hasCompleteGeneratedModuleApiSeam = ( } if (!hasMatchingModuleApiSlots(manifest, registration, contributions)) { + diagnostics?.push('manifest or registration contribution inventory'); return false; } @@ -1730,6 +1515,7 @@ export const hasCompleteGeneratedModuleApiSeam = ( const apiValue = contributionApiValue(contribution.kind, contribution.name); const loaded = loadContributionSources(sources, verticalPath, contribution, contractSource); if (loaded === undefined) { + diagnostics?.push(`${contribution.name}: missing source or provenance marker`); return false; } const { clientSource, readImport, readSource, serverSource } = loaded; @@ -1751,6 +1537,7 @@ export const hasCompleteGeneratedModuleApiSeam = ( handlerRoot, contribution, moduleId, + diagnostics, ), read: hasReadContract(readSource, contribution, moduleId) && @@ -1766,7 +1553,13 @@ export const hasCompleteGeneratedModuleApiSeam = ( governedApiBinding(sharedApi) ?? '', ), }; - return Object.values(checks).every(Boolean); + const failed = Object.entries(checks) + .filter(([, valid]) => !valid) + .map(([name]) => name); + if (failed.length > 0) { + diagnostics?.push(`${contribution.name}: ${failed.join(', ')}`); + } + return failed.length === 0; }); }; @@ -1787,12 +1580,7 @@ export const hasGeneratedGovernedServerContract = ( source, ); const [, readPath, directory, name] = readImport ?? []; - if ( - readPath === undefined || - name === undefined || - directory === undefined || - camel !== toCamelCase(name) - ) { + if (readPath === undefined || name === undefined || directory === undefined || camel !== toCamelCase(name)) { return false; } const kind = readDirectoryKinds.get(directory); @@ -1802,7 +1590,7 @@ export const hasGeneratedGovernedServerContract = ( const stem = kind === MODULE_API_KIND ? name : `${name}-${contributionRole(kind)}`; const schemaStem = contributionSchemaStem(kind, name); return ( - source.startsWith(generatedHeader(kind)) && + hasGeneratedSourceHeader(source, generatedHeader(kind)) && hasServerContract( source, escapeRegExp(camel), diff --git a/app/scripts/generated-module-api-boundary.mts b/app/scripts/generated-module-api-boundary.mts index 13168e04a..1ad87828b 100644 --- a/app/scripts/generated-module-api-boundary.mts +++ b/app/scripts/generated-module-api-boundary.mts @@ -1,6 +1,7 @@ -import { DelimiterDepth, toCamelCase } from './boundary-source-structure.mts'; import { LanguageVariant, SyntaxKind, createScanner } from '@typescript/native/unstable/ast'; +import { DelimiterDepth, toCamelCase } from './boundary-source-structure.mts'; + const REGISTRATION_API_SLOT = [ '// ', '// ', @@ -40,8 +41,7 @@ export interface GovernedClientToken { /** Out-of-range lookahead is a nonmatching token, never an invented identifier. */ const tokenKind = (tokens: readonly GovernedClientToken[], index: number): SyntaxKind | undefined => tokens[index]?.kind; -const tokenValue = (tokens: readonly GovernedClientToken[], index: number): string | undefined => - tokens[index]?.value; +const tokenValue = (tokens: readonly GovernedClientToken[], index: number): string | undefined => tokens[index]?.value; const tokenDelimiter = new Map([ [SyntaxKind.OpenBraceToken, '{'], @@ -81,8 +81,7 @@ const scanTemplateDelimiter = ( if (kind === SyntaxKind.TemplateHead) { templateExpressionBraceDepths.push(0); } else if (kind === SyntaxKind.OpenBraceToken && templateDepthIndex >= 0) { - templateExpressionBraceDepths[templateDepthIndex] = - (templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1; + templateExpressionBraceDepths[templateDepthIndex] = (templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1; } else if (kind === SyntaxKind.CloseBraceToken && templateDepthIndex >= 0) { const braceDepth = templateExpressionBraceDepths[templateDepthIndex] ?? 0; if (braceDepth === 0) { @@ -106,8 +105,7 @@ export const tokenizeGovernedClient = (source: string): readonly GovernedClientT let kind: SyntaxKind = scannedKind; if ( kind === SyntaxKind.SlashToken && - (tokens.length === 0 || - REGULAR_EXPRESSION_PRECEDERS.has(tokens.at(-1)?.kind ?? SyntaxKind.Unknown)) + (tokens.length === 0 || REGULAR_EXPRESSION_PRECEDERS.has(tokens.at(-1)?.kind ?? SyntaxKind.Unknown)) ) { kind = scanner.reScanSlashToken(); } @@ -118,6 +116,50 @@ export const tokenizeGovernedClient = (source: string): readonly GovernedClientT return tokens; }; +const importStateAfter = (kind: SyntaxKind, inImport: boolean): boolean | undefined => { + if (kind === SyntaxKind.SingleLineCommentTrivia || kind === SyntaxKind.MultiLineCommentTrivia) { + return inImport; + } + if (kind === SyntaxKind.ImportKeyword) { + return true; + } + return inImport ? kind !== SyntaxKind.SemicolonToken : undefined; +}; + +// Comments from the leading import section, with undefined marking interruptions. +const leadingSourceComments = function* leadingSourceComments(source: string): Generator { + const scanner = createScanner(false, LanguageVariant.Standard, source); + let inImport = false; + for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) { + if (kind === SyntaxKind.WhitespaceTrivia || kind === SyntaxKind.NewLineTrivia) { + continue; + } + yield kind === SyntaxKind.SingleLineCommentTrivia ? scanner.getTokenText().trim() : undefined; + const nextState = importStateAfter(kind, inImport); + if (nextState === undefined) { + return; + } + inImport = nextState; + } +}; + +/** Import sorting may move provenance comments between leading imports. */ +export const hasGeneratedSourceHeader = (source: string, header: string): boolean => { + const expected = header.trim().split(/\r?\n/u); + let matched = 0; + for (const comment of leadingSourceComments(source)) { + if (comment === expected[matched]) { + matched += 1; + } else { + matched = comment === expected[0] ? 1 : 0; + } + if (matched === expected.length) { + return true; + } + } + return false; +}; + const matchesToken = (token: GovernedClientToken | undefined, expected: ExpectedToken): boolean => token?.kind === expected[0] && (expected[1] === undefined || token.value === expected[1]); @@ -127,11 +169,7 @@ const matchesSequence = ( expected: readonly ExpectedToken[], ): boolean => expected.every((token, offset) => matchesToken(tokens[start + offset], token)); -const isOptionalTrailingComma = ( - tokens: readonly GovernedClientToken[], - next: number, - close: number, -): boolean => +const isOptionalTrailingComma = (tokens: readonly GovernedClientToken[], next: number, close: number): boolean => next === close || (tokenKind(tokens, next) === SyntaxKind.CommaToken && next + 1 === close); const findSequence = ( @@ -181,10 +219,7 @@ const sequenceOccurrencesAtBraceDepth = ( return count; }; -const generatedSlotSource = ( - source: string, - [start, end]: readonly [string, string], -): string | undefined => { +const generatedSlotSource = (source: string, [start, end]: readonly [string, string]): string | undefined => { const startIndex = source.indexOf(start); const endIndex = source.indexOf(end); if ( @@ -206,10 +241,8 @@ const findTopLevelSequence = ( end: number, ): number | undefined => findSequenceAtBraceDepth(tokens, expected, start, end, 0); -const hasTopLevelSequence = ( - tokens: readonly GovernedClientToken[], - expected: readonly ExpectedToken[], -): boolean => findTopLevelSequence(tokens, expected, 0, tokens.length) !== undefined; +const hasTopLevelSequence = (tokens: readonly GovernedClientToken[], expected: readonly ExpectedToken[]): boolean => + findTopLevelSequence(tokens, expected, 0, tokens.length) !== undefined; const findRootExpressionSequence = ( tokens: readonly GovernedClientToken[], @@ -227,10 +260,7 @@ const findRootExpressionSequence = ( return undefined; }; -const findClosingBrace = ( - tokens: readonly GovernedClientToken[], - openBraceIndex: number, -): number | undefined => { +const findClosingBrace = (tokens: readonly GovernedClientToken[], openBraceIndex: number): number | undefined => { let depth = 0; for (let index = openBraceIndex; index < tokens.length; index += 1) { const token = tokens[index]; @@ -266,11 +296,7 @@ const findClosingParenthesis = ( return undefined; }; -const isNamedObjectProperty = ( - tokens: readonly GovernedClientToken[], - index: number, - property: string, -): boolean => { +const isNamedObjectProperty = (tokens: readonly GovernedClientToken[], index: number, property: string): boolean => { const token = tokens[index]; return ( (token?.kind === SyntaxKind.Identifier || token?.kind === SyntaxKind.StringLiteral) && @@ -305,8 +331,7 @@ const findObjectPropertyValue = ( openBraceIndex: number, closeBraceIndex: number, property: string, -): number | undefined => - objectPropertyValuePositions(tokens, openBraceIndex, closeBraceIndex, property)[0]; +): number | undefined => objectPropertyValuePositions(tokens, openBraceIndex, closeBraceIndex, property)[0]; const directObjectPropertyOccurrences = ( tokens: readonly GovernedClientToken[], @@ -351,10 +376,7 @@ const directObjectPropertyNames = ( return properties; }; -const hasExactProperties = ( - properties: readonly string[] | undefined, - expected: ReadonlySet, -): boolean => +const hasExactProperties = (properties: readonly string[] | undefined, expected: ReadonlySet): boolean => properties !== undefined && properties.length === expected.size && properties.every((property) => expected.has(property)); @@ -391,11 +413,7 @@ interface GovernedClientExpectation { const MODULE_API_INVOCATION_KIND = 'module-api'; -export const hasUniqueExactNamedImport = ( - source: string, - importedName: string, - moduleSpecifier: string, -): boolean => { +export const hasUniqueExactNamedImport = (source: string, importedName: string, moduleSpecifier: string): boolean => { const tokens = tokenizeGovernedClient(source); let matchCount = 0; let bindingCount = 0; @@ -405,9 +423,7 @@ export const hasUniqueExactNamedImport = ( if (closeBrace !== undefined) { bindingCount += tokens .slice(index + 2, closeBrace) - .filter( - ({ kind, value }) => kind === SyntaxKind.Identifier && value === importedName, - ).length; + .filter(({ kind, value }) => kind === SyntaxKind.Identifier && value === importedName).length; } } if ( @@ -445,18 +461,12 @@ export const hasGeneratedOperationPrincipalContract = (source: string): boolean [SyntaxKind.SemicolonToken], ]); -const hasExclusiveNamedImportFrom = ( - source: string, - importedName: string, - moduleSpecifier: string, -): boolean => { +const hasExclusiveNamedImportFrom = (source: string, importedName: string, moduleSpecifier: string): boolean => { const tokens = tokenizeGovernedClient(source); let bindingCount = 0; let exactBindingCount = 0; for (let index = 0; index < tokens.length; index += 1) { - if ( - !matchesSequence(tokens, index, [[SyntaxKind.ImportKeyword], [SyntaxKind.OpenBraceToken]]) - ) { + if (!matchesSequence(tokens, index, [[SyntaxKind.ImportKeyword], [SyntaxKind.OpenBraceToken]])) { continue; } const closeBrace = findClosingBrace(tokens, index + 1); @@ -484,9 +494,7 @@ const hasExclusiveNamedImportFrom = ( export const hasNamedImportBinding = (source: string, importedName: string): boolean => { const tokens = tokenizeGovernedClient(source); for (let index = 0; index < tokens.length; index += 1) { - if ( - !matchesSequence(tokens, index, [[SyntaxKind.ImportKeyword], [SyntaxKind.OpenBraceToken]]) - ) { + if (!matchesSequence(tokens, index, [[SyntaxKind.ImportKeyword], [SyntaxKind.OpenBraceToken]])) { continue; } const closeBrace = findClosingBrace(tokens, index + 1); @@ -571,10 +579,7 @@ const hasExactGeneratedImports = ( return tokens.filter(({ kind }) => kind === SyntaxKind.ImportKeyword).length === expected.length; }; -const identifierOccurrences = ( - tokens: readonly GovernedClientToken[], - identifier: string, -): number => +const identifierOccurrences = (tokens: readonly GovernedClientToken[], identifier: string): number => tokens.filter(({ kind, value }) => kind === SyntaxKind.Identifier && value === identifier).length; interface ExportedConst { @@ -605,13 +610,12 @@ const exportedConsts = (tokens: readonly GovernedClientToken[]): readonly Export ) { const name = tokenValue(tokens, index + 2); if (name !== undefined) { - const nextExport = findSequence( - tokens, - [[SyntaxKind.ExportKeyword]], - index + 1, - tokens.length, - ); - declarations.push({ end: nextExport ?? tokens.length, name, start: index }); + const nextExport = findSequence(tokens, [[SyntaxKind.ExportKeyword]], index + 1, tokens.length); + declarations.push({ + end: nextExport ?? tokens.length, + name, + start: index, + }); } } } @@ -672,10 +676,7 @@ const findClientHelper = ( return undefined; }; -const findStatementSemicolon = ( - tokens: readonly GovernedClientToken[], - start: number, -): number | undefined => +const findStatementSemicolon = (tokens: readonly GovernedClientToken[], start: number): number | undefined => findRootExpressionSequence(tokens, [[SyntaxKind.SemicolonToken]], start, tokens.length); const hasOnlyAllowedModuleStatements = ( @@ -690,10 +691,7 @@ const hasOnlyAllowedModuleStatements = ( .replace(/^execute/u, '') .replace(/^load/u, '') .replace(/Client$/u, ''); - const allowedOptionsInterfaces = new Set([ - `${apiStem}ClientOptions`, - `${operationStem}ClientOptions`, - ]); + const allowedOptionsInterfaces = new Set([`${apiStem}ClientOptions`, `${operationStem}ClientOptions`]); let optionsInterfaceSeen = false; const consumeOptionsInterface = (index: number): number | undefined => { const name = tokenValue(tokens, index + 2); @@ -722,11 +720,7 @@ const hasOnlyAllowedModuleStatements = ( if (index === tokens.length) { return true; } - if ( - [SyntaxKind.ImportKeyword, SyntaxKind.TypeKeyword].includes( - tokenKind(tokens, index) ?? SyntaxKind.Unknown, - ) - ) { + if ([SyntaxKind.ImportKeyword, SyntaxKind.TypeKeyword].includes(tokenKind(tokens, index) ?? SyntaxKind.Unknown)) { const end = findStatementSemicolon(tokens, index); return end !== undefined && acceptsFrom(end + 1); } @@ -759,31 +753,35 @@ const hasGovernedTransportInvocation = ( ownerApiValue: string, defaultApiPrefix: string, ): boolean => { - const expected = [ - [SyntaxKind.Identifier, 'makeGovernedEffectBffClient'], - [SyntaxKind.OpenParenToken], - [SyntaxKind.OpenBraceToken], - [SyntaxKind.Identifier, 'api'], - [SyntaxKind.ColonToken], - [SyntaxKind.Identifier, ownerApiValue], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'credential'], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'defaultApiPrefix'], - [SyntaxKind.ColonToken], - [SyntaxKind.StringLiteral, defaultApiPrefix], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'requestCorrelation'], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'options'], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ] satisfies readonly ExpectedToken[]; - return ( - helper.end === helper.start + expected.length && matchesSequence(tokens, helper.start, expected) + const expected = (objectTrailingComma: boolean, callTrailingComma: boolean) => + [ + [SyntaxKind.Identifier, 'makeGovernedEffectBffClient'], + [SyntaxKind.OpenParenToken], + [SyntaxKind.OpenBraceToken], + [SyntaxKind.Identifier, 'api'], + [SyntaxKind.ColonToken], + [SyntaxKind.Identifier, ownerApiValue], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'credential'], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'defaultApiPrefix'], + [SyntaxKind.ColonToken], + [SyntaxKind.StringLiteral, defaultApiPrefix], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'requestCorrelation'], + ...(objectTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'options'], + ...(callTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), + [SyntaxKind.CloseParenToken], + [SyntaxKind.SemicolonToken], + ] satisfies readonly ExpectedToken[]; + return [false, true].some((objectTrailingComma) => + [false, true].some((callTrailingComma) => { + const sequence = expected(objectTrailingComma, callTrailingComma); + return helper.end === helper.start + sequence.length && matchesSequence(tokens, helper.start, sequence); + }), ); }; @@ -853,9 +851,7 @@ interface GeneratedClientTypeNames { request: string; } -const generatedClientTypeNames = ( - expectation: GovernedClientExpectation, -): GeneratedClientTypeNames => { +const generatedClientTypeNames = (expectation: GovernedClientExpectation): GeneratedClientTypeNames => { const operationBase = expectation.invocationKind === MODULE_API_INVOCATION_KIND ? expectation.ownerApiValue.slice(0, -'Api'.length) @@ -863,10 +859,7 @@ const generatedClientTypeNames = ( return { authorizedInvocation: `${expectation.ownerApiValue.slice(0, -'Api'.length)}AuthorizedInvocation`, operationInvocation: `${expectation.ownerApiValue.slice(0, -'Api'.length)}OperationInvocation`, - options: [ - `${operationBase}ClientOptions`, - `${expectation.ownerApiValue.slice(0, -'Api'.length)}ClientOptions`, - ], + options: [`${operationBase}ClientOptions`, `${expectation.ownerApiValue.slice(0, -'Api'.length)}ClientOptions`], request: governedRequestType(expectation), }; }; @@ -928,43 +921,35 @@ const hasExactGeneratedOperationParameters = ( ] satisfies readonly ExpectedToken[]; return ( types.options.some((optionsType) => - hasExactParameterTokens(tokens, helperOpen, helper.parametersEnd, [ - [SyntaxKind.Identifier, 'credential'], - [SyntaxKind.ColonToken], - [SyntaxKind.Identifier, 'Redacted'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'Redacted'], - [SyntaxKind.LessThanToken], - [SyntaxKind.StringKeyword], - [SyntaxKind.GreaterThanToken], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'requestCorrelation'], - [SyntaxKind.ColonToken], - [SyntaxKind.StringKeyword], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'options'], - [SyntaxKind.ColonToken], - [SyntaxKind.Identifier, optionsType], - [SyntaxKind.CommaToken], - ]), + [false, true].some((trailingComma) => + hasExactParameterTokens(tokens, helperOpen, helper.parametersEnd, [ + [SyntaxKind.Identifier, 'credential'], + [SyntaxKind.ColonToken], + [SyntaxKind.Identifier, 'Redacted'], + [SyntaxKind.DotToken], + [SyntaxKind.Identifier, 'Redacted'], + [SyntaxKind.LessThanToken], + [SyntaxKind.StringKeyword], + [SyntaxKind.GreaterThanToken], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'requestCorrelation'], + [SyntaxKind.ColonToken], + [SyntaxKind.StringKeyword], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'options'], + [SyntaxKind.ColonToken], + [SyntaxKind.Identifier, optionsType], + ...(trailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), + ]), + ), ) && authorizedClose !== undefined && [false, true].some((trailingComma) => - hasExactParameterTokens( - tokens, - authorizedOpen, - authorizedClose, - authorizedExpected(trailingComma), - ), + hasExactParameterTokens(tokens, authorizedOpen, authorizedClose, authorizedExpected(trailingComma)), ) && operationClose !== undefined && [false, true].some((trailingComma) => - hasExactParameterTokens( - tokens, - operationOpen, - operationClose, - operationExpected(trailingComma), - ), + hasExactParameterTokens(tokens, operationOpen, operationClose, operationExpected(trailingComma)), ) ); }; @@ -996,19 +981,18 @@ const matchingSequenceEnd = ( return match === undefined ? undefined : start + match.length; }; -const hasInvocationClosure = ( - tokens: readonly GovernedClientToken[], - start: number | undefined, -): boolean => +const hasInvocationClosure = (tokens: readonly GovernedClientToken[], start: number | undefined): boolean => start !== undefined && [false, true].some((trailingComma) => - matchesSequence(tokens, start, [ - ...(trailingComma ? [[SyntaxKind.CommaToken] as const] : []), - [SyntaxKind.CloseParenToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ]), + [false, true].some((outerTrailingComma) => + matchesSequence(tokens, start, [ + ...(trailingComma ? [[SyntaxKind.CommaToken] as const] : []), + [SyntaxKind.CloseParenToken], + ...(outerTrailingComma ? [[SyntaxKind.CommaToken] as const] : []), + [SyntaxKind.CloseParenToken], + [SyntaxKind.SemicolonToken], + ]), + ), ); const generatedInvocationPayloads = ( @@ -1063,12 +1047,8 @@ const clientHelperShadowsImports = ( expectation.ownerApiValue, ]); return ( - parametersBindIdentifier( - tokens, - helper.parametersStart, - helper.parametersEnd, - requiredHelperImports, - ) || hasTopLevelDeclaration(tokens, helper.start, helper.end, requiredHelperImports) + parametersBindIdentifier(tokens, helper.parametersStart, helper.parametersEnd, requiredHelperImports) || + hasTopLevelDeclaration(tokens, helper.start, helper.end, requiredHelperImports) ); }; @@ -1106,18 +1086,8 @@ const exportedOperationsUseClientHelperAndGateway = ( return false; } const [authorized, operation] = declarations; - const authorizedArrow = findSequence( - tokens, - [[SyntaxKind.EqualsGreaterThanToken]], - authorized.start, - authorized.end, - ); - const operationArrow = findSequence( - tokens, - [[SyntaxKind.EqualsGreaterThanToken]], - operation.start, - operation.end, - ); + const authorizedArrow = findSequence(tokens, [[SyntaxKind.EqualsGreaterThanToken]], authorized.start, authorized.end); + const operationArrow = findSequence(tokens, [[SyntaxKind.EqualsGreaterThanToken]], operation.start, operation.end); if (authorizedArrow === undefined || operationArrow === undefined) { return false; } @@ -1155,11 +1125,7 @@ const exportedOperationsUseClientHelperAndGateway = ( [SyntaxKind.OpenParenToken], ] satisfies readonly ExpectedToken[]; const authorizedInvocationEnd = authorizedArrow + 1 + authorizedInvocation.length; - const authorizedInvocationTail = matchingSequenceEnd( - tokens, - authorizedInvocationEnd, - invocationPayloads, - ); + const authorizedInvocationTail = matchingSequenceEnd(tokens, authorizedInvocationEnd, invocationPayloads); const authorizedUsesHelper = matchesSequence(tokens, authorizedArrow + 1, authorizedInvocation) && hasInvocationClosure(tokens, authorizedInvocationTail); @@ -1261,8 +1227,7 @@ export const generatedApiGroup = (source: string, ownerApiValue: string): string const endpointAdd = group + sequence.length; if ( endpointAdd === outerCallClose || - (tokenKind(tokens, endpointAdd) === SyntaxKind.CommaToken && - endpointAdd + 1 === outerCallClose) + (tokenKind(tokens, endpointAdd) === SyntaxKind.CommaToken && endpointAdd + 1 === outerCallClose) ) { return true; } @@ -1276,10 +1241,7 @@ export const generatedApiGroup = (source: string, ownerApiValue: string): string return false; } const endpointAddClose = findClosingParenthesis(tokens, endpointAdd + 2, outerCallClose + 1); - return ( - endpointAddClose !== undefined && - isOptionalTrailingComma(tokens, endpointAddClose + 1, outerCallClose) - ); + return endpointAddClose !== undefined && isOptionalTrailingComma(tokens, endpointAddClose + 1, outerCallClose); }; const makeOpen = apiDeclaration + 7; const makeClose = @@ -1317,8 +1279,7 @@ const hasGeneratedEndpointContract = ( 0, tokens.length, ); - const declarationEnd = - declaration === undefined ? undefined : findStatementSemicolon(tokens, declaration); + const declarationEnd = declaration === undefined ? undefined : findStatementSemicolon(tokens, declaration); if (declaration === undefined || declarationEnd === undefined) { return false; } @@ -1419,10 +1380,7 @@ const topLevelCallObject = ( const open = declaration + (requireExport ? 6 : 5); const close = findClosingBrace(tokens, open); const callClose = findClosingParenthesis(tokens, open - 1, declarationEnd); - return close !== undefined && - close < declarationEnd && - callClose !== undefined && - callClose + 1 === declarationEnd + return close !== undefined && close < declarationEnd && callClose !== undefined && callClose + 1 === declarationEnd ? [open, close, declarationEnd] : undefined; }; @@ -1474,9 +1432,27 @@ const withoutTrailingCommas = (tokens: readonly GovernedClientToken[]) => SyntaxKind.CloseBraceToken, SyntaxKind.CloseParenToken, SyntaxKind.CloseBracketToken, + SyntaxKind.GreaterThanToken, ].includes(tokens[index + 1]?.kind ?? SyntaxKind.Unknown), ); +const engagementLifecyclePayloadUnionPrefix: readonly ExpectedToken[] = [ + [SyntaxKind.TypeKeyword, 'type'], + [SyntaxKind.Identifier, 'EngagementLifecyclePayload'], + [SyntaxKind.EqualsToken], + [SyntaxKind.BarToken], +]; + +const withoutLeadingEngagementLifecycleUnionPipe = ( + tokens: readonly GovernedClientToken[], +): readonly GovernedClientToken[] => { + const prefix = findSequence(tokens, engagementLifecyclePayloadUnionPrefix); + if (prefix === undefined) { + return tokens; + } + return [...tokens.slice(0, prefix + 3), ...tokens.slice(prefix + 4)]; +}; + const SOURCE_VALUE_TOKEN_KINDS = new Set([ SyntaxKind.Identifier, SyntaxKind.StringLiteral, @@ -1489,19 +1465,30 @@ const SOURCE_VALUE_TOKEN_KINDS = new Set([ SyntaxKind.RegularExpressionLiteral, ]); -const hasExactSourceTokens = ( +const sourceTokensMatch = ( + actualTokens: readonly GovernedClientToken[], + expectedTokens: readonly GovernedClientToken[], +): boolean => + actualTokens.length === expectedTokens.length && + expectedTokens.every( + (token, index) => + actualTokens[index]?.kind === token.kind && + (!SOURCE_VALUE_TOKEN_KINDS.has(token.kind) || actualTokens[index]?.value === token.value), + ); + +const hasExactSourceTokens = (tokens: readonly GovernedClientToken[], expected: string): boolean => + sourceTokensMatch(withoutTrailingCommas(tokens), withoutTrailingCommas(tokenizeGovernedClient(expected))); + +const hasExactSourceTokensAllowingEngagementLifecycleUnionPipe = ( tokens: readonly GovernedClientToken[], expected: string, ): boolean => { const actualTokens = withoutTrailingCommas(tokens); const expectedTokens = withoutTrailingCommas(tokenizeGovernedClient(expected)); return ( - actualTokens.length === expectedTokens.length && - expectedTokens.every( - (token, index) => - actualTokens[index]?.kind === token.kind && - (!SOURCE_VALUE_TOKEN_KINDS.has(token.kind) || actualTokens[index]?.value === token.value), - ) + sourceTokensMatch(actualTokens, expectedTokens) || + sourceTokensMatch(actualTokens, withoutLeadingEngagementLifecycleUnionPipe(expectedTokens)) || + sourceTokensMatch(withoutLeadingEngagementLifecycleUnionPipe(actualTokens), expectedTokens) ); }; @@ -1510,13 +1497,12 @@ export const hasEngagementLifecycleRegistrationContract = ( registrationSource: string, action: string, ): boolean => { - const identity = - /^(?archive|unarchive)-(?organization|person)-engagement$/u.exec( - action, - )?.groups; + const identity = /^(?archive|unarchive)-(?organization|person)-engagement$/u.exec( + action, + )?.groups; if ( identity === undefined || - !hasExactSourceTokens( + !hasExactSourceTokensAllowingEngagementLifecycleUnionPipe( tokenizeGovernedClient(registrationSource), engagementLifecycleRegistrationContract, ) @@ -1556,12 +1542,9 @@ export const hasEngagementLifecycleRegistrationContract = ( resultSchema: ${result}Schema, }`, ) && - [ - 'defineAction', - 'engagementLifecycleRegistration', - `${payload}Schema`, - `${result}Schema`, - ].every((binding) => identifierOccurrences(tokens, binding) === 2) + ['defineAction', 'engagementLifecycleRegistration', `${payload}Schema`, `${result}Schema`].every( + (binding) => identifierOccurrences(tokens, binding) === 2, + ) ); }; @@ -1583,9 +1566,7 @@ const objectHasExactStrings = ( close: number, expected: Readonly>, ): boolean => - Object.entries(expected).every(([property, value]) => - objectHasExactString(tokens, open, close, property, value), - ); + Object.entries(expected).every(([property, value]) => objectHasExactString(tokens, open, close, property, value)); const objectReferencesEntrypoint = ( tokens: readonly GovernedClientToken[], @@ -1610,8 +1591,7 @@ const objectStringProperty = ( property: string, ): string | undefined => { const start = findObjectPropertyValue(tokens, open, close, property); - return start !== undefined && - hasExactObjectPropertyValue(tokens, start, [[SyntaxKind.StringLiteral]]) + return start !== undefined && hasExactObjectPropertyValue(tokens, start, [[SyntaxKind.StringLiteral]]) ? tokenValue(tokens, start) : undefined; }; @@ -1670,8 +1650,7 @@ const matchesGeneratedReadAuthorization = ( expected: GeneratedReadAuthorization | undefined, ): boolean => actual !== undefined && - (expected === undefined || - (actual.kind === expected.kind && actual.permission === expected.permission)); + (expected === undefined || (actual.kind === expected.kind && actual.permission === expected.permission)); const hasGeneratedReadContract = ( source: string, @@ -1683,12 +1662,7 @@ const hasGeneratedReadContract = ( const tokens = tokenizeGovernedClient(source); const camel = toCamelCase(name); const entrypointName = `${camel}Entrypoint`; - const entrypoint = topLevelCallObject( - tokens, - entrypointName, - 'defineTenantModuleEntrypoint', - false, - ); + const entrypoint = topLevelCallObject(tokens, entrypointName, 'defineTenantModuleEntrypoint', false); const read = topLevelCallObject(tokens, `${camel}Read`, 'defineRead'); if (entrypoint === undefined || read === undefined) { return false; @@ -1702,10 +1676,7 @@ const hasGeneratedReadContract = ( generatedReadAuthorization(tokens, entrypointOpen, entrypointClose), authorization, ) && - matchesSequence(tokens, entrypointClose + 1, [ - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ]) && + matchesSequence(tokens, entrypointClose + 1, [[SyntaxKind.CloseParenToken], [SyntaxKind.SemicolonToken]]) && entrypointClose + 2 === entrypointEnd && objectHasExactStrings(tokens, entrypointOpen, entrypointClose, { access: 'read', @@ -1790,20 +1761,13 @@ export const hasMatchingGeneratedProviderAuthorization = ( shellTokens.length, 1, ); - const contribution = - identity === undefined ? undefined : enclosingBraceRange(shellTokens, identity); + const contribution = identity === undefined ? undefined : enclosingBraceRange(shellTokens, identity); const manifestEntrypoint = - contribution === undefined - ? undefined - : nestedObjectRange(shellTokens, ...contribution, 'entrypoint'); + contribution === undefined ? undefined : nestedObjectRange(shellTokens, ...contribution, 'entrypoint'); if (manifestEntrypoint === undefined) { return false; } - const providerAuthorization = generatedReadAuthorization( - providerTokens, - entrypoint[0], - entrypoint[1], - ); + const providerAuthorization = generatedReadAuthorization(providerTokens, entrypoint[0], entrypoint[1]); const manifestAuthorization = generatedReadAuthorization(shellTokens, ...manifestEntrypoint); return matchesGeneratedReadAuthorization(providerAuthorization, manifestAuthorization); }; @@ -1817,12 +1781,7 @@ export const hasGeneratedModuleApiReadContract = ( const tokens = tokenizeGovernedClient(source); const camel = toCamelCase(name); const entrypointName = `${camel}Entrypoint`; - const entrypoint = topLevelCallObject( - tokens, - entrypointName, - 'defineTenantModuleEntrypoint', - false, - ); + const entrypoint = topLevelCallObject(tokens, entrypointName, 'defineTenantModuleEntrypoint', false); const read = topLevelCallObject(tokens, `${camel}Read`, 'defineRead'); if (entrypoint === undefined || read === undefined) { return false; @@ -1855,11 +1814,8 @@ export const hasGeneratedModuleApiReadContract = ( ); }; -export const hasGeneratedGovernedClientContract = ( - source: string, - expectation: GovernedClientExpectation, -): boolean => { - if (!source.startsWith(expectation.generatedHeader)) { +export const hasGeneratedGovernedClientContract = (source: string, expectation: GovernedClientExpectation): boolean => { + if (!hasGeneratedSourceHeader(source, expectation.generatedHeader)) { return false; } const tokens = tokenizeGovernedClient(source); @@ -1872,24 +1828,13 @@ export const hasGeneratedGovernedClientContract = ( return ( helper !== undefined && hasExactGeneratedImports(tokens, expectation) && - hasGovernedTransportInvocation( - tokens, - helper, - expectation.ownerApiValue, - expectation.defaultApiPrefix, - ) && + hasGovernedTransportInvocation(tokens, helper, expectation.ownerApiValue, expectation.defaultApiPrefix) && exportedOperationsUseClientHelperAndGateway(tokens, helper, expectation) && hasOnlyAllowedModuleStatements(tokens, helper, expectation) && - [ - 'makeGovernedEffectBffClient', - 'operationGateway', - expectation.ownerApiValue, - 'Effect', - helper.name, - ].every((name) => identifierOccurrences(tokens, name) === 2) && - !tokens.some( - ({ value }) => value === 'makeEffectHttpApiClient' || value === 'HttpClientRequest', - ) + ['makeGovernedEffectBffClient', 'operationGateway', expectation.ownerApiValue, 'Effect', helper.name].every( + (name) => identifierOccurrences(tokens, name) === 2, + ) && + !tokens.some(({ value }) => value === 'makeEffectHttpApiClient' || value === 'HttpClientRequest') ); }; @@ -1900,13 +1845,7 @@ const slotHasDirectPropertyKey = (source: string | undefined, key: string): bool const tokens = tokenizeGovernedClient(source); return [SyntaxKind.StringLiteral, SyntaxKind.Identifier].some( (keyKind) => - findSequenceAtBraceDepth( - tokens, - [[keyKind, key], [SyntaxKind.ColonToken]], - 0, - tokens.length, - 0, - ) !== undefined, + findSequenceAtBraceDepth(tokens, [[keyKind, key], [SyntaxKind.ColonToken]], 0, tokens.length, 0) !== undefined, ); }; @@ -1977,10 +1916,7 @@ const hasRelatedSequenceInObject = ( ): boolean => { const anchorIndex = findSequenceAtBraceDepth(tokens, anchor, 0, tokens.length, 1); const range = anchorIndex === undefined ? undefined : enclosingBraceRange(tokens, anchorIndex); - return ( - range !== undefined && - findSequenceAtBraceDepth(tokens, related, range[0], range[1], 1) !== undefined - ); + return range !== undefined && findSequenceAtBraceDepth(tokens, related, range[0], range[1], 1) !== undefined; }; const registrationIdentityIsExclusiveToSlot = ( @@ -2026,10 +1962,7 @@ export const hasGeneratedProviderRegistration = ( directPropertyKeyOccurrences(slot, name) === 1 && [SyntaxKind.StringLiteral, SyntaxKind.Identifier].some((keyKind) => { const key: ExpectedToken = [keyKind, name]; - return ( - findSequenceAtBraceDepth(tokens, [key, ...registrationTail], 0, tokens.length, 0) !== - undefined - ); + return findSequenceAtBraceDepth(tokens, [key, ...registrationTail], 0, tokens.length, 0) !== undefined; }) ); }; @@ -2047,12 +1980,8 @@ const hasProviderShellEntrypoint = ( ); }; -const slotOmitsIdentity = ( - source: string | undefined, - identity: readonly ExpectedToken[], -): boolean => - source === undefined || - sequenceOccurrencesAtBraceDepth(tokenizeGovernedClient(source), identity, 1) === 0; +const slotOmitsIdentity = (source: string | undefined, identity: readonly ExpectedToken[]): boolean => + source === undefined || sequenceOccurrencesAtBraceDepth(tokenizeGovernedClient(source), identity, 1) === 0; const hasOwnedProviderDescriptor = ( tokens: readonly GovernedClientToken[], @@ -2098,18 +2027,8 @@ export const hasGeneratedProviderManifest = ( ): boolean => { const [descriptorMarkers, shellMarkers, otherDescriptorMarkers, otherShellMarkers] = kind === 'report' - ? [ - MANIFEST_REPORT_SLOT, - MANIFEST_SHELL_REPORT_SLOT, - MANIFEST_SEARCH_SLOT, - MANIFEST_SHELL_SEARCH_SLOT, - ] - : [ - MANIFEST_SEARCH_SLOT, - MANIFEST_SHELL_SEARCH_SLOT, - MANIFEST_REPORT_SLOT, - MANIFEST_SHELL_REPORT_SLOT, - ]; + ? [MANIFEST_REPORT_SLOT, MANIFEST_SHELL_REPORT_SLOT, MANIFEST_SEARCH_SLOT, MANIFEST_SHELL_SEARCH_SLOT] + : [MANIFEST_SEARCH_SLOT, MANIFEST_SHELL_SEARCH_SLOT, MANIFEST_REPORT_SLOT, MANIFEST_SHELL_REPORT_SLOT]; const descriptorSlot = generatedSlotSource(manifest, descriptorMarkers); const shellSlot = generatedSlotSource(manifest, shellMarkers); if (descriptorSlot === undefined || shellSlot === undefined) { @@ -2131,17 +2050,9 @@ export const hasGeneratedProviderManifest = ( ] satisfies readonly ExpectedToken[]; const otherDescriptorSlot = generatedSlotSource(manifest, otherDescriptorMarkers); const otherShellSlot = generatedSlotSource(manifest, otherShellMarkers); - const shellIdentityIndex = findSequenceAtBraceDepth( - shellTokens, - shellIdentity, - 0, - shellTokens.length, - 1, - ); + const shellIdentityIndex = findSequenceAtBraceDepth(shellTokens, shellIdentity, 0, shellTokens.length, 1); const shellContribution = - shellIdentityIndex === undefined - ? undefined - : enclosingBraceRange(shellTokens, shellIdentityIndex); + shellIdentityIndex === undefined ? undefined : enclosingBraceRange(shellTokens, shellIdentityIndex); return ( hasOwnedProviderDescriptor(descriptorTokens, descriptorIdentity, moduleId) && hasOwnedProviderShellContribution( @@ -2198,9 +2109,7 @@ const slotProviderNames = ( }; const registrationProviderNames = (source: string | undefined): readonly string[] => - (directSlotPropertyNames(source) ?? []).filter((name) => - /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name), - ); + (directSlotPropertyNames(source) ?? []).filter((name) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name)); const providerIdentitySlotIsExact = ( source: string | undefined, @@ -2211,11 +2120,7 @@ const providerIdentitySlotIsExact = ( source !== undefined && sequenceOccurrencesAtBraceDepth( tokenizeGovernedClient(source), - [ - [SyntaxKind.Identifier, identityProperty], - [SyntaxKind.ColonToken], - [SyntaxKind.StringLiteral], - ], + [[SyntaxKind.Identifier, identityProperty], [SyntaxKind.ColonToken], [SyntaxKind.StringLiteral]], depth, ) === names.length; @@ -2239,12 +2144,9 @@ export const hasExactGeneratedProviderIdentityTopology = ( moduleId: string, ): boolean => { const slotsPresent = - [ - MANIFEST_REPORT_SLOT, - MANIFEST_SEARCH_SLOT, - MANIFEST_SHELL_REPORT_SLOT, - MANIFEST_SHELL_SEARCH_SLOT, - ].some((slot) => generatedSlotSource(manifest, slot) !== undefined) || + [MANIFEST_REPORT_SLOT, MANIFEST_SEARCH_SLOT, MANIFEST_SHELL_REPORT_SLOT, MANIFEST_SHELL_SEARCH_SLOT].some( + (slot) => generatedSlotSource(manifest, slot) !== undefined, + ) || [REGISTRATION_REPORT_SLOT, REGISTRATION_SEARCH_SLOT].some( (slot) => generatedSlotSource(registration, slot) !== undefined, ); @@ -2376,10 +2278,7 @@ const hasGatewayBindingMutation = (tokens: readonly GovernedClientToken[]): bool return false; }; -export const hasGeneratedOperationGatewayContract = ( - source: string, - deploymentAppId: string, -): boolean => { +export const hasGeneratedOperationGatewayContract = (source: string, deploymentAppId: string): boolean => { const header = `// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n// @ontos-action-boundary-owner ${deploymentAppId}\n`; if (!source.startsWith(header)) { return false; diff --git a/app/scripts/initialize-local-development.mts b/app/scripts/initialize-local-development.mts index 449238359..8fa534291 100644 --- a/app/scripts/initialize-local-development.mts +++ b/app/scripts/initialize-local-development.mts @@ -1,39 +1,20 @@ -import { - bootstrapPrincipalRecord, - bootstrapRelationshipRequest, - selectBootstrapLegalEntities, - selectBootstrapPrincipals, - selectBootstrapAuthBindings, -} from '../packages/core-runtime/src/install/context-bootstrap-shared.ts'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { NodeServices } from '@effect/platform-node'; + import { v1 } from '@authzed/authzed-node'; +import { NodeServices } from '@effect/platform-node'; import { betterAuth } from 'better-auth'; import { verifyPassword } from 'better-auth/crypto'; -import { admin } from 'better-auth/plugins'; +import { admin } from 'better-auth/plugins/admin'; import { and, eq, or } from 'drizzle-orm'; -import { - Config, - ConfigProvider, - Console, - Effect, - FileSystem, - Layer, - Path, - Redacted, - Schema, -} from 'effect'; +import { Config, ConfigProvider, Console, Effect, FileSystem, Layer, Path, Redacted, Schema } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; + import { AuthConfig } from '../apps/shell-super-app/api/auth/config.ts'; import { AuthDatabase, AuthDatabaseLive } from '../apps/shell-super-app/api/auth/db/client.ts'; -import { CoreDatabase, CoreDatabaseLive } from '../packages/core-runtime/src/db/client.ts'; -import type { - CoreDatabaseExecutor, - CoreTransaction, -} from '../packages/core-runtime/src/db/types.ts'; import { account, user } from '../apps/shell-super-app/api/auth/db/schema.ts'; +import { CoreDatabase, CoreDatabaseLive } from '../packages/core-runtime/src/db/client.ts'; import { DatabaseConfig, parseDatabaseConfig, @@ -46,12 +27,20 @@ import { tenantModuleStates, tenants, } from '../packages/core-runtime/src/db/schema.ts'; +import type { CoreDatabaseExecutor, CoreTransaction } from '../packages/core-runtime/src/db/types.ts'; +import { + bootstrapPrincipalRecord, + bootstrapRelationshipRequest, + selectBootstrapLegalEntities, + selectBootstrapPrincipals, + selectBootstrapAuthBindings, +} from '../packages/core-runtime/src/install/context-bootstrap-shared.ts'; +import { spiceDbClientSecurity } from '../packages/core-runtime/src/permissions/client.ts'; +import { parseSpiceDbConfig } from '../packages/core-runtime/src/permissions/config.ts'; import { toLegalEntityAccessObjectId, toModuleAccessObjectId, } from '../packages/core-runtime/src/permissions/context-access.ts'; -import { spiceDbClientSecurity } from '../packages/core-runtime/src/permissions/client.ts'; -import { parseSpiceDbConfig } from '../packages/core-runtime/src/permissions/config.ts'; import { deriveOntosModuleDeploymentContract } from './generate-ontos-module-contract.mts'; export interface LocalDevelopmentEnvironment { @@ -136,9 +125,7 @@ const failure = ( const loopbackHosts = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']); -const validateLoopbackHttpOrigin = ( - value: string, -): Effect.Effect => { +const validateLoopbackHttpOrigin = (value: string): Effect.Effect => { const parsed = URL.parse(value); if ( parsed === null || @@ -146,9 +133,7 @@ const validateLoopbackHttpOrigin = ( parsed.origin !== value || !loopbackHosts.has(parsed.hostname) ) { - return Effect.fail( - failure('local_configuration_invalid', 'BETTER_AUTH_URL must be an exact local HTTP origin'), - ); + return Effect.fail(failure('local_configuration_invalid', 'BETTER_AUTH_URL must be an exact local HTTP origin')); } return Effect.succeed(value); }; @@ -168,17 +153,14 @@ const localDevelopmentConfigSource = Config.all({ spiceDbPreSharedKey: Config.redacted('SPICEDB_PRESHARED_KEY'), }); -const environmentProvider = (environment: LocalDevelopmentEnvironment) => - ConfigProvider.fromUnknown(environment); +const environmentProvider = (environment: LocalDevelopmentEnvironment) => ConfigProvider.fromUnknown(environment); const parseLocalDevelopmentConfigurationFromProvider = (provider: ConfigProvider.ConfigProvider) => Effect.gen(function* parseConfiguration() { const source = yield* localDevelopmentConfigSource .parse(provider) .pipe( - Effect.mapError(() => - failure('local_configuration_invalid', 'The local development configuration is invalid'), - ), + Effect.mapError(() => failure('local_configuration_invalid', 'The local development configuration is invalid')), ); if (source.deploymentEnvironment !== 'development') { return yield* failure( @@ -188,23 +170,14 @@ const parseLocalDevelopmentConfigurationFromProvider = (provider: ConfigProvider } const authSecret = Redacted.make(Redacted.value(source.authSecret).trim()); if (Redacted.value(authSecret).length < 32) { - return yield* failure( - 'local_configuration_invalid', - 'BETTER_AUTH_SECRET must contain at least 32 characters', - ); + return yield* failure('local_configuration_invalid', 'BETTER_AUTH_SECRET must contain at least 32 characters'); } const databasePair = yield* parseDatabaseConnectionPair({ DATABASE_ADMIN_URL: source.databaseAdminUrl, DATABASE_URL: source.databaseUrl, }).pipe(Effect.mapError((error) => failure('local_configuration_invalid', error.reason))); - if ( - !loopbackHosts.has(databasePair.admin.host) || - !loopbackHosts.has(databasePair.runtime.host) - ) { - return yield* failure( - 'local_configuration_invalid', - 'Both PostgreSQL endpoints must be local', - ); + if (!loopbackHosts.has(databasePair.admin.host) || !loopbackHosts.has(databasePair.runtime.host)) { + return yield* failure('local_configuration_invalid', 'Both PostgreSQL endpoints must be local'); } const spiceDbPreSharedKey = Redacted.make(Redacted.value(source.spiceDbPreSharedKey).trim()); const spiceDb = yield* parseSpiceDbConfig({ @@ -219,10 +192,7 @@ const parseLocalDevelopmentConfigurationFromProvider = (provider: ConfigProvider !loopbackHosts.has(parsedSpiceDbEndpoint.hostname) || !spiceDb.insecureLocal ) { - return yield* failure( - 'local_configuration_invalid', - 'SpiceDB must use insecure transport on a local endpoint', - ); + return yield* failure('local_configuration_invalid', 'SpiceDB must use insecure transport on a local endpoint'); } const authBaseUrl = yield* validateLoopbackHttpOrigin(source.authBaseUrl); return { @@ -295,36 +265,21 @@ export const deriveActivatedModuleIds = ( const pathService = yield* Path.Path; const topologySource = yield* fileSystem .readFileString(pathService.join(workspaceRoot, 'topology/reference-topology.json')) - .pipe( - Effect.mapError(() => - failure('local_contract_invalid', 'The authoritative topology could not be read'), - ), - ); + .pipe(Effect.mapError(() => failure('local_contract_invalid', 'The authoritative topology could not be read'))); const topology = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TopologySchema), { onExcessProperty: 'preserve', })(topologySource).pipe( - Effect.mapError(() => - failure('local_contract_invalid', 'The authoritative topology is invalid'), - ), + Effect.mapError(() => failure('local_contract_invalid', 'The authoritative topology is invalid')), ); if (topology.verticals.length === 0) { - return yield* failure( - 'local_contract_invalid', - 'The authoritative topology has no MicroVerticals', - ); + return yield* failure('local_contract_invalid', 'The authoritative topology has no MicroVerticals'); } const verticals = topology.verticals.map(({ id }) => id); if (new Set(verticals).size !== verticals.length) { - return yield* failure( - 'local_contract_invalid', - 'The authoritative topology has duplicate verticals', - ); + return yield* failure('local_contract_invalid', 'The authoritative topology has duplicate verticals'); } if (new Set(activatedVerticals).size !== activatedVerticals.length) { - return yield* failure( - 'local_contract_invalid', - 'Local activation contains duplicate MicroVerticals', - ); + return yield* failure('local_contract_invalid', 'Local activation contains duplicate MicroVerticals'); } const missingVerticals = activatedVerticals.filter((vertical) => !verticals.includes(vertical)); if (missingVerticals.length > 0) { @@ -338,26 +293,18 @@ export const deriveActivatedModuleIds = ( (vertical) => deriveContract({ vertical, workspaceRoot }).pipe( Effect.mapError(() => - failure( - 'local_contract_invalid', - `The ${vertical} deployment contract could not be derived`, - ), + failure('local_contract_invalid', `The ${vertical} deployment contract could not be derived`), ), ), { concurrency: 'unbounded' }, ); const moduleIds = contracts.map((contract) => contract.manifest.module.id); if (new Set(moduleIds).size !== moduleIds.length) { - return yield* failure( - 'local_contract_invalid', - 'Generated contracts contain duplicate module IDs', - ); + return yield* failure('local_contract_invalid', 'Generated contracts contain duplicate module IDs'); } const sortedModuleIds: string[] = []; for (const moduleId of moduleIds) { - const insertionIndex = sortedModuleIds.findIndex( - (existing) => moduleId.localeCompare(existing) < 0, - ); + const insertionIndex = sortedModuleIds.findIndex((existing) => moduleId.localeCompare(existing) < 0); if (insertionIndex === -1) { sortedModuleIds.push(moduleId); } else { @@ -380,20 +327,11 @@ export const moduleStateIdFor = (moduleId: string): string => { export const buildLocalDevelopmentRelationships = Effect.fn('LocalDevelopment.buildRelationships')( function* buildRelationships( moduleIds: readonly string[], - ): Effect.fn.Return< - readonly LocalDevelopmentRelationship[], - LocalDevelopmentInitializationError - > { + ): Effect.fn.Return { const context = LOCAL_DEVELOPMENT_CONTEXT; - const legalEntityObjectId = toLegalEntityAccessObjectId( - context.tenantId, - context.legalEntityId, - ); + const legalEntityObjectId = toLegalEntityAccessObjectId(context.tenantId, context.legalEntityId); if (legalEntityObjectId === undefined) { - return yield* failure( - 'local_contract_invalid', - 'The local Legal Entity authorization ID is invalid', - ); + return yield* failure('local_contract_invalid', 'The local Legal Entity authorization ID is invalid'); } const shared: LocalDevelopmentRelationship[] = [ { @@ -419,16 +357,9 @@ export const buildLocalDevelopmentRelationships = Effect.fn('LocalDevelopment.bu }, ]; for (const moduleId of moduleIds) { - const moduleObjectId = toModuleAccessObjectId( - context.tenantId, - context.legalEntityId, - moduleId, - ); + const moduleObjectId = toModuleAccessObjectId(context.tenantId, context.legalEntityId, moduleId); if (moduleObjectId === undefined) { - return yield* failure( - 'local_contract_invalid', - `Module ${moduleId} has an invalid authorization ID`, - ); + return yield* failure('local_contract_invalid', `Module ${moduleId} has an invalid authorization ID`); } shared.push( { @@ -460,11 +391,7 @@ const ensureAuthUser = Effect.fn('LocalDevelopment.ensureAuthUser')(function* en .from(user) .where(eq(user.email, configuration.email)) .limit(2) - .pipe( - Effect.mapError(() => - failure('local_persistence_failed', 'The local Better Auth user could not be loaded'), - ), - ); + .pipe(Effect.mapError(() => failure('local_persistence_failed', 'The local Better Auth user could not be loaded'))); if (existingUsers.length > 1) { return yield* failure('local_conflict', 'Multiple Better Auth users use the local email'); } @@ -481,23 +408,16 @@ const ensureAuthUser = Effect.fn('LocalDevelopment.ensureAuthUser')(function* en .limit(2) .pipe( Effect.mapError(() => - failure( - 'local_persistence_failed', - 'The local Better Auth credentials could not be loaded', - ), + failure('local_persistence_failed', 'The local Better Auth credentials could not be loaded'), ), ); const [credential] = credentials.length === 1 ? credentials : []; if (credential?.password === null || credential?.password === undefined) { - return yield* failure( - 'local_conflict', - 'The existing local user has conflicting credentials', - ); + return yield* failure('local_conflict', 'The existing local user has conflicting credentials'); } const storedPassword = credential.password; const validPassword = yield* Effect.tryPromise({ - catch: () => - failure('local_persistence_failed', 'The local Better Auth password could not be verified'), + catch: () => failure('local_persistence_failed', 'The local Better Auth password could not be verified'), try: async () => await verifyPassword({ hash: storedPassword, @@ -505,21 +425,21 @@ const ensureAuthUser = Effect.fn('LocalDevelopment.ensureAuthUser')(function* en }), }); if (!validPassword) { - return yield* failure( - 'local_conflict', - 'The existing local user has conflicting credentials', - ); + return yield* failure('local_conflict', 'The existing local user has conflicting credentials'); } return { status: 'existing' as const, userId: existingUser.id }; } const created = yield* Effect.tryPromise({ - catch: () => - failure('local_persistence_failed', 'The local Better Auth user could not be created'), + catch: () => failure('local_persistence_failed', 'The local Better Auth user could not be created'), try: async () => { const authentication = betterAuth({ baseURL: configuration.authBaseUrl, database: adapter, - emailAndPassword: { autoSignIn: false, disableSignUp: true, enabled: true }, + emailAndPassword: { + autoSignIn: false, + disableSignUp: true, + enabled: true, + }, logger: { disabled: true }, plugins: [admin()], secret: Redacted.value(configuration.authSecret), @@ -536,51 +456,46 @@ const ensureAuthUser = Effect.fn('LocalDevelopment.ensureAuthUser')(function* en return { status: 'created' as const, userId: created.user.id }; }); -const reconcileLocalModules = Effect.fn('LocalDevelopment.reconcileLocalModules')( - function* reconcileModuleStates(transaction: CoreTransaction, moduleIds: readonly string[]) { - const context = LOCAL_DEVELOPMENT_CONTEXT; - for (const moduleId of moduleIds) { - const moduleStateId = moduleStateIdFor(moduleId); - const moduleCandidates = yield* transaction - .select({ - moduleKey: tenantModuleStates.moduleKey, - state: tenantModuleStates.state, - tenantId: tenantModuleStates.tenantId, - tenantModuleStateId: tenantModuleStates.tenantModuleStateId, - }) - .from(tenantModuleStates) - .where( - or( - eq(tenantModuleStates.tenantModuleStateId, moduleStateId), - and( - eq(tenantModuleStates.tenantId, context.tenantId), - eq(tenantModuleStates.moduleKey, moduleId), - ), - ), - ) - .limit(2); - if (moduleCandidates.length > 1) { - return yield* failure('local_conflict', `The ${moduleId} module-state identity conflicts`); - } - const expectedModuleState = { - moduleKey: moduleId, - state: 'active', - tenantId: context.tenantId, - tenantModuleStateId: moduleStateId, - } as const; - if ( - (yield* classifyLocalModuleState( - `${moduleId} module state`, - moduleCandidates[0], - expectedModuleState, - )) === 'create' - ) { - yield* transaction.insert(tenantModuleStates).values(expectedModuleState); - } +const reconcileLocalModules = Effect.fn('LocalDevelopment.reconcileLocalModules')(function* reconcileModuleStates( + transaction: CoreTransaction, + moduleIds: readonly string[], +) { + const context = LOCAL_DEVELOPMENT_CONTEXT; + for (const moduleId of moduleIds) { + const moduleStateId = moduleStateIdFor(moduleId); + const moduleCandidates = yield* transaction + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + tenantId: tenantModuleStates.tenantId, + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, + }) + .from(tenantModuleStates) + .where( + or( + eq(tenantModuleStates.tenantModuleStateId, moduleStateId), + and(eq(tenantModuleStates.tenantId, context.tenantId), eq(tenantModuleStates.moduleKey, moduleId)), + ), + ) + .limit(2); + if (moduleCandidates.length > 1) { + return yield* failure('local_conflict', `The ${moduleId} module-state identity conflicts`); } - return yield* Effect.void; - }, -); + const expectedModuleState = { + moduleKey: moduleId, + state: 'active', + tenantId: context.tenantId, + tenantModuleStateId: moduleStateId, + } as const; + if ( + (yield* classifyLocalModuleState(`${moduleId} module state`, moduleCandidates[0], expectedModuleState)) === + 'create' + ) { + yield* transaction.insert(tenantModuleStates).values(expectedModuleState); + } + } + return yield* Effect.void; +}); export const reconcileCoreContext = ( database: CoreDatabaseExecutor, @@ -612,10 +527,7 @@ export const reconcileCoreContext = ( status: 'active', tenantId: context.tenantId, } as const; - if ( - (yield* classifyExactLocalRecord('tenant', tenantCandidates[0], expectedTenant)) === - 'create' - ) { + if ((yield* classifyExactLocalRecord('tenant', tenantCandidates[0], expectedTenant)) === 'create') { yield* transaction.insert(tenants).values(expectedTenant); } @@ -631,33 +543,17 @@ export const reconcileCoreContext = ( status: 'active', tenantId: context.tenantId, } as const; - if ( - (yield* classifyExactLocalRecord( - 'Legal Entity', - legalCandidates[0], - expectedLegalEntity, - )) === 'create' - ) { + if ((yield* classifyExactLocalRecord('Legal Entity', legalCandidates[0], expectedLegalEntity)) === 'create') { yield* transaction.insert(legalEntities).values(expectedLegalEntity); } const expectedPrincipal = bootstrapPrincipalRecord(context); const principalCandidates = yield* selectBootstrapPrincipals(transaction, context); - if ( - (yield* classifyExactLocalRecord( - 'principal', - principalCandidates[0], - expectedPrincipal, - )) === 'create' - ) { + if ((yield* classifyExactLocalRecord('principal', principalCandidates[0], expectedPrincipal)) === 'create') { yield* transaction.insert(principals).values(expectedPrincipal); } - const bindingCandidates = yield* selectBootstrapAuthBindings( - transaction, - context, - authUserId, - ); + const bindingCandidates = yield* selectBootstrapAuthBindings(transaction, context, authUserId); if (bindingCandidates.length > 1) { return yield* failure('local_conflict', 'The local authentication binding conflicts'); } @@ -671,11 +567,8 @@ export const reconcileCoreContext = ( tenantId: context.tenantId, } as const; if ( - (yield* classifyExactLocalRecord( - 'authentication binding', - bindingCandidates[0], - expectedBinding, - )) === 'create' + (yield* classifyExactLocalRecord('authentication binding', bindingCandidates[0], expectedBinding)) === + 'create' ) { yield* transaction.insert(principalAuthBindings).values(expectedBinding); } @@ -686,9 +579,7 @@ export const reconcileCoreContext = ( ) .pipe( // Native SQL commit/rollback errors are defects; preserve unrelated defects. - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect))), Effect.catchTag('EffectDrizzleQueryError', () => failure('local_persistence_failed', 'The local Core context could not be reconciled'), ), @@ -700,8 +591,7 @@ export const reconcileCoreContext = ( const acquireSpiceDbClient = (configuration: LocalDevelopmentConfiguration) => Effect.acquireRelease( Effect.try({ - catch: () => - failure('local_persistence_failed', 'The local authorization client could not be created'), + catch: () => failure('local_persistence_failed', 'The local authorization client could not be created'), try: () => { const preSharedKey = Redacted.value(configuration.spiceDbPreSharedKey); return v1.NewClient( @@ -726,10 +616,7 @@ const touchRelationships = ( const client = yield* acquireSpiceDbClient(configuration); yield* Effect.tryPromise({ catch: () => - failure( - 'local_persistence_failed', - 'The local authorization relationships could not be reconciled', - ), + failure('local_persistence_failed', 'The local authorization relationships could not be reconciled'), try: async () => { await client.promises.writeRelationships(bootstrapRelationshipRequest(relationships)); }, @@ -741,18 +628,13 @@ const loadRootConfiguration = () => Effect.gen(function* loadConfiguration() { const fileProvider = yield* ConfigProvider.fromDotEnv({ path: path.join(import.meta.dirname, '..', '.env'), - }).pipe( - Effect.mapError(() => failure('local_configuration_invalid', 'Unable to load app/.env')), - ); + }).pipe(Effect.mapError(() => failure('local_configuration_invalid', 'Unable to load app/.env'))); const provider = ConfigProvider.orElse(ConfigProvider.fromEnv(), fileProvider); return yield* parseLocalDevelopmentConfigurationFromProvider(provider); }); export const initializeLocalDevelopment = ( - environmentEffect?: Effect.Effect< - LocalDevelopmentEnvironment, - LocalDevelopmentInitializationError - >, + environmentEffect?: Effect.Effect, ): Effect.Effect< LocalDevelopmentInitializationResult, LocalDevelopmentInitializationError, @@ -785,26 +667,19 @@ export const initializeLocalDevelopment = ( ), ), Effect.catchTag('AuthDatabaseConnectionError', () => - failure( - 'local_persistence_failed', - 'The local authentication database could not be opened', - ), + failure('local_persistence_failed', 'The local authentication database could not be opened'), ), ); const databaseConfiguration = yield* parseDatabaseConfig({ DATABASE_URL: configuration.databaseAdminUrl, }).pipe( - Effect.mapError(() => - failure('local_configuration_invalid', 'The local Core database configuration is invalid'), - ), + Effect.mapError(() => failure('local_configuration_invalid', 'The local Core database configuration is invalid')), ); yield* Effect.gen(function* initializeCore() { const database = yield* CoreDatabase; yield* reconcileCoreContext(database.executor, authUser.userId, moduleIds); }).pipe( - Effect.provide( - CoreDatabaseLive.pipe(Layer.provide(Layer.succeed(DatabaseConfig, databaseConfiguration))), - ), + Effect.provide(CoreDatabaseLive.pipe(Layer.provide(Layer.succeed(DatabaseConfig, databaseConfiguration)))), Effect.catchTag('DatabaseConnectionError', () => failure('local_persistence_failed', 'The local Core database could not be opened'), ), @@ -828,13 +703,8 @@ const runLocalDevelopmentInitialization = Effect.matchEffect(initializeLocalDeve ).pipe(Effect.as(true)), }); -if ( - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href -) { - const succeeded = await Effect.runPromise( - runLocalDevelopmentInitialization.pipe(Effect.provide(NodeServices.layer)), - ); +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const succeeded = await Effect.runPromise(runLocalDevelopmentInitialization.pipe(Effect.provide(NodeServices.layer))); if (!succeeded) { process.exitCode = 1; } diff --git a/app/scripts/install-zerops-node.sh b/app/scripts/install-zerops-node.sh index bf88357ad..46ef7b05f 100644 --- a/app/scripts/install-zerops-node.sh +++ b/app/scripts/install-zerops-node.sh @@ -1,9 +1,9 @@ #!/bin/sh set -eu -node_version='26.5.0' +node_version='26.7.0' node_archive="node-v${node_version}-linux-x64-musl.tar.gz" -node_checksum='00f1398411a4216c5a6ecaad3b825a0da5ec00e79ee8c173ab65a094d97b9ad8' +node_checksum='84fc4e29e5f86022a40bac50a28a1b9275dd1f32eebbf4db499e2573ff822124' node_root="${ZEROPS_NODE_ROOT:-${HOME:-/var/www}}" node_directory="${node_root}/.local/node-${node_version}" temporary_directory="$(mktemp -d)" diff --git a/app/scripts/local-environment-values.mts b/app/scripts/local-environment-values.mts index 4237a01e2..8532a5c93 100644 --- a/app/scripts/local-environment-values.mts +++ b/app/scripts/local-environment-values.mts @@ -24,24 +24,16 @@ export interface LocalPublicClientTopology { readonly shellPort: number; } -export const localPublicClientValues = ( - lines: readonly string[], - topology: LocalPublicClientTopology, -) => { +export const localPublicClientValues = (lines: readonly string[], topology: LocalPublicClientTopology) => { const existing = existingValues(lines); return { - ONTOS_PARTY_REGISTRY_API_BASE_URL: - existing.ONTOS_PARTY_REGISTRY_API_BASE_URL ?? topology.partyRegistryApiBaseUrl, + ONTOS_PARTY_REGISTRY_API_BASE_URL: existing.ONTOS_PARTY_REGISTRY_API_BASE_URL ?? topology.partyRegistryApiBaseUrl, ONTOS_SHELL_GATEWAY_BASE_URL: - existing.ONTOS_SHELL_GATEWAY_BASE_URL ?? - `http://localhost:${topology.shellPort}/${topology.shellId}-api`, + existing.ONTOS_SHELL_GATEWAY_BASE_URL ?? `http://localhost:${topology.shellPort}/${topology.shellId}-api`, }; }; -export const localSpiceDbValues = ( - lines: readonly string[], - overrides: LocalEnvironmentOverrides, -) => { +export const localSpiceDbValues = (lines: readonly string[], overrides: LocalEnvironmentOverrides) => { const existing = existingValues(lines); const grpcPort = overrides.grpcPort ?? existing.SPICEDB_GRPC_PORT ?? '50051'; const httpPort = overrides.httpPort ?? existing.SPICEDB_HTTP_PORT ?? '8443'; diff --git a/app/scripts/local-environment-values.test.mts b/app/scripts/local-environment-values.test.mts index e9fd8c7c0..e5842b9e6 100644 --- a/app/scripts/local-environment-values.test.mts +++ b/app/scripts/local-environment-values.test.mts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import { localPublicClientValues, localSpiceDbValues } from './local-environment-values.mts'; const spiceDbGrpcPort = '50052'; @@ -27,10 +28,10 @@ it('preserves canonical SpiceDB values when no local override is supplied', () = }); it('applies explicit local port overrides as one consistent endpoint', () => { - const values = localSpiceDbValues( - ['SPICEDB_ENDPOINT=localhost:50051', 'SPICEDB_GRPC_PORT=50051'], - { grpcPort: spiceDbGrpcPort, httpPort: spiceDbHttpPort }, - ); + const values = localSpiceDbValues(['SPICEDB_ENDPOINT=localhost:50051', 'SPICEDB_GRPC_PORT=50051'], { + grpcPort: spiceDbGrpcPort, + httpPort: spiceDbHttpPort, + }); expect(values.SPICEDB_ENDPOINT).toBe(spiceDbEndpoint); expect(values.SPICEDB_GRPC_PORT).toBe(spiceDbGrpcPort); diff --git a/app/scripts/materialize-outbox-worker.mjs b/app/scripts/materialize-outbox-worker.mjs index 83b147851..3c6c0f5d9 100644 --- a/app/scripts/materialize-outbox-worker.mjs +++ b/app/scripts/materialize-outbox-worker.mjs @@ -1,7 +1,9 @@ import { isBuiltin } from 'node:module'; + import { NodeServices } from '@effect/platform-node'; -import { build } from 'esbuild'; import { Config, Effect, FileSystem, ManagedRuntime, Path, Schema } from 'effect'; +import { build } from 'esbuild'; + import { outboxWorkerDelivery } from './outbox-worker-delivery.mjs'; const TopologySchema = Schema.fromJsonString( @@ -102,9 +104,7 @@ const makeProductionDependenciesPlugin = ({ packages, path, workspaceRoot }) => if (isBuiltin(args.path)) { return { external: true, path: args.path }; } - const name = args.path.startsWith('@') - ? args.path.split('/').slice(0, 2).join('/') - : args.path.split('/').at(0); + const name = args.path.startsWith('@') ? args.path.split('/').slice(0, 2).join('/') : args.path.split('/').at(0); if (args.path === '@app/core-runtime') { return { path: path.join(workspaceRoot, 'packages/core-runtime/src/outbox/worker-entrypoint.ts'), @@ -140,9 +140,7 @@ const collectProductionDependency = (importedPath, packages, dependencies) => }); const uniqueVersions = [...new Set(versions)]; if (uniqueVersions.length !== 1) { - return yield* Effect.fail( - failure(`Worker dependency ${name} must have one declared production version`), - ); + return yield* Effect.fail(failure(`Worker dependency ${name} must have one declared production version`)); } const [version] = uniqueVersions; if (version === undefined) { @@ -166,26 +164,14 @@ const collectProductionDependency = (importedPath, packages, dependencies) => * Bundle owner + Core code; retain exact production dependencies, never workspace links. * @param {MaterializeOptions} options Materialization identity and paths. */ -const materializeOutboxWorkerEffect = ({ - appId, - packageDir, - packageName, - runtimeDir, - workspaceRoot, -}) => +const materializeOutboxWorkerEffect = ({ appId, packageDir, packageName, runtimeDir, workspaceRoot }) => Effect.gen(function* materializeWorker() { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const topologySource = yield* fs.readFileString( - path.join(workspaceRoot, 'topology/reference-topology.json'), - ); + const topologySource = yield* fs.readFileString(path.join(workspaceRoot, 'topology/reference-topology.json')); const topology = yield* Schema.decodeUnknownEffect(TopologySchema)(topologySource); const vertical = topology.verticals.find((candidate) => candidate.id === appId); - if ( - vertical === undefined || - vertical.package !== packageName || - vertical.path !== packageDir - ) { + if (vertical === undefined || vertical.package !== packageName || vertical.path !== packageDir) { return yield* Effect.fail(failure('Worker identity must match its topology owner')); } const delivery = yield* outboxWorkerDelivery(workspaceRoot, vertical).pipe( @@ -232,7 +218,13 @@ const materializeOutboxWorkerEffect = ({ metafile: true, outfile: path.join(runtimeDir, WORKER_ENTRY), platform: 'node', - plugins: [makeProductionDependenciesPlugin({ packages, path, workspaceRoot })], + plugins: [ + makeProductionDependenciesPlugin({ + packages, + path, + workspaceRoot, + }), + ], target: 'node26', }) ), @@ -251,8 +243,7 @@ const materializeOutboxWorkerEffect = ({ const sourceRevision = yield* Config.option(Config.string('ULTRAMODERN_SOURCE_REVISION')); const artifactAppId = yield* Schema.decodeUnknownEffect(AppIdSchema)(appId); const artifactServiceId = yield* Schema.decodeUnknownEffect(ServiceIdSchema)(delivery.id); - const { inputs: sourceInputMetadata } = - yield* Schema.decodeUnknownEffect(MetafileInputsSchema)(metafile); + const { inputs: sourceInputMetadata } = yield* Schema.decodeUnknownEffect(MetafileInputsSchema)(metafile); /** @type {string[]} */ const sourceInputs = []; for (const sourceInput in sourceInputMetadata) { @@ -289,5 +280,4 @@ const materializeOutboxWorkerEffect = ({ * type: string, * }>} Materialized runtime package manifest. */ -export const materializeOutboxWorker = (options) => - nodeRuntime.runPromise(materializeOutboxWorkerEffect(options)); +export const materializeOutboxWorker = (options) => nodeRuntime.runPromise(materializeOutboxWorkerEffect(options)); diff --git a/app/scripts/materialize-zerops-runtime.mjs b/app/scripts/materialize-zerops-runtime.mjs index 980c660c9..ea9b7cd00 100644 --- a/app/scripts/materialize-zerops-runtime.mjs +++ b/app/scripts/materialize-zerops-runtime.mjs @@ -33,14 +33,12 @@ const CompactConfigSchema = Schema.Struct({ const decodeRuntimePackage = Schema.decodeUnknownEffect(RuntimePackageSchema, { onExcessProperty: 'preserve', }); -const decodeRuntimePackageJson = Schema.decodeUnknownEffect( - Schema.fromJsonString(RuntimePackageSchema), - { onExcessProperty: 'preserve' }, -); -const decodeCompactConfigJson = Schema.decodeUnknownEffect( - Schema.fromJsonString(CompactConfigSchema), - { onExcessProperty: 'preserve' }, -); +const decodeRuntimePackageJson = Schema.decodeUnknownEffect(Schema.fromJsonString(RuntimePackageSchema), { + onExcessProperty: 'preserve', +}); +const decodeCompactConfigJson = Schema.decodeUnknownEffect(Schema.fromJsonString(CompactConfigSchema), { + onExcessProperty: 'preserve', +}); /** @typedef {typeof Schema.Json.Type} JsonValue */ /** @type {import('effect/Schema').Codec} */ const JsonValueSchema = Schema.suspend(() => @@ -173,20 +171,12 @@ const normalizeRuntimePackageDependencies = (runtimeManifest, workspaceRoot, pat const modernPackageVersion = compactConfig?.packageSource?.modernPackageVersion; const aliasScope = compactConfig?.packageSource?.aliasScope; const aliasPackageNamePrefix = compactConfig?.packageSource?.aliasPackageNamePrefix; - if ( - modernPackageVersion === undefined || - aliasScope === undefined || - aliasPackageNamePrefix === undefined - ) { + if (modernPackageVersion === undefined || aliasScope === undefined || aliasPackageNamePrefix === undefined) { return runtimeManifest; } const aliasPrefix = `@${aliasScope}/${aliasPackageNamePrefix}`; - const dependencies = normalizeDependencySection( - runtimeManifest.dependencies, - aliasPrefix, - modernPackageVersion, - ); + const dependencies = normalizeDependencySection(runtimeManifest.dependencies, aliasPrefix, modernPackageVersion); const optionalDependencies = normalizeDependencySection( runtimeManifest.optionalDependencies, aliasPrefix, @@ -233,13 +223,7 @@ const isCurrentPlatformSupported = (dependencyManifest) => * @param {string} workspaceRoot - Workspace root. * @param {import('effect/Path').Path} pathService - Path service. */ -const readInstalledDependencyPackage = ( - dependencyName, - dependencyVersion, - appRoot, - workspaceRoot, - pathService, -) => +const readInstalledDependencyPackage = (dependencyName, dependencyVersion, appRoot, workspaceRoot, pathService) => Effect.gen(function* readInstalledDependencyPackageEffect() { const fileSystem = yield* FileSystem.FileSystem; const dependencySegments = dependencyName.split('/'); @@ -249,10 +233,7 @@ const readInstalledDependencyPackage = ( ]; for (const candidate of directCandidates) { const dependencyManifest = yield* readOptionalRuntimePackage(candidate); - if ( - dependencyManifest?.name === dependencyName && - dependencyManifest.version === dependencyVersion - ) { + if (dependencyManifest?.name === dependencyName && dependencyManifest.version === dependencyVersion) { return dependencyManifest; } } @@ -272,10 +253,7 @@ const readInstalledDependencyPackage = ( packageJsonFile, ); const dependencyManifest = yield* readOptionalRuntimePackage(candidate); - if ( - dependencyManifest?.name === dependencyName && - dependencyManifest.version === dependencyVersion - ) { + if (dependencyManifest?.name === dependencyName && dependencyManifest.version === dependencyVersion) { return dependencyManifest; } } @@ -303,8 +281,7 @@ const removeIncompatibleDependencySection = (dependencies, appRoot, workspaceRoo workspaceRoot, pathService, ); - const compatible = - dependencyManifest === null || isCurrentPlatformSupported(dependencyManifest); + const compatible = dependencyManifest === null || isCurrentPlatformSupported(dependencyManifest); if (!compatible) { yield* Effect.log( `[ultramodern:zerops] excluded ${dependencyName}@${dependencyVersion} from ${process.platform}/${process.arch} runtime`, @@ -322,26 +299,11 @@ const removeIncompatibleDependencySection = (dependencies, appRoot, workspaceRoo * @param {string} workspaceRoot - Workspace root. * @param {import('effect/Path').Path} pathService - Path service. */ -const removeIncompatiblePlatformDependencies = ( - runtimeManifest, - appRoot, - workspaceRoot, - pathService, -) => +const removeIncompatiblePlatformDependencies = (runtimeManifest, appRoot, workspaceRoot, pathService) => Effect.all( [ - removeIncompatibleDependencySection( - runtimeManifest.dependencies, - appRoot, - workspaceRoot, - pathService, - ), - removeIncompatibleDependencySection( - runtimeManifest.optionalDependencies, - appRoot, - workspaceRoot, - pathService, - ), + removeIncompatibleDependencySection(runtimeManifest.dependencies, appRoot, workspaceRoot, pathService), + removeIncompatibleDependencySection(runtimeManifest.optionalDependencies, appRoot, workspaceRoot, pathService), ], { concurrency: 'unbounded' }, ).pipe( @@ -376,9 +338,7 @@ const collectPackageDirectoryEntries = (absoluteDirectory, pathService) => const packageManifest = yield* readOptionalRuntimePackage( pathService.join(packageDirectory, packageJsonFile), ); - return packageManifest?.name === undefined - ? null - : [packageManifest.name, packageDirectory]; + return packageManifest?.name === undefined ? null : [packageManifest.name, packageDirectory]; }), { concurrency: 'unbounded' }, ); @@ -414,9 +374,7 @@ const removeWorkspaceDependencies = (runtimeManifest, workspacePackages) => { } const entries = Object.entries(dependencies); return { - dependencies: Object.fromEntries( - entries.filter(([dependencyName]) => !workspacePackages.has(dependencyName)), - ), + dependencies: Object.fromEntries(entries.filter(([dependencyName]) => !workspacePackages.has(dependencyName))), localDependencies: entries .filter(([dependencyName]) => workspacePackages.has(dependencyName)) .map(([dependencyName]) => dependencyName), @@ -433,10 +391,7 @@ const removeWorkspaceDependencies = (runtimeManifest, workspacePackages) => { } return { installPackage, - localDependencies: [ - ...runtimeDependencies.localDependencies, - ...runtimeOptionalDependencies.localDependencies, - ], + localDependencies: [...runtimeDependencies.localDependencies, ...runtimeOptionalDependencies.localDependencies], }; }; @@ -495,7 +450,9 @@ const rewriteTsExports = (exportTarget) => { const listTsFiles = (directory) => Effect.gen(function* listTsFilesEffect() { const fileSystem = yield* FileSystem.FileSystem; - const entries = yield* fileSystem.readDirectory(directory, { recursive: true }); + const entries = yield* fileSystem.readDirectory(directory, { + recursive: true, + }); return entries .filter((item) => item.endsWith('.ts') && !item.endsWith('.d.ts')) .map((item) => `${directory}/${item}`); @@ -509,8 +466,7 @@ const stripParameterTypes = (parameters) => const rewriteArrowParameters = (_match, parameters) => `(${stripParameterTypes(parameters)}) =>`; /** @param {string} _match - Full match. @param {string} name - Function name. @param {string} parameters - Parameter source. */ -const rewriteFunctionParameters = (_match, name, parameters) => - `function${name}(${stripParameterTypes(parameters)})`; +const rewriteFunctionParameters = (_match, name, parameters) => `function${name}(${stripParameterTypes(parameters)})`; /** @param {string} source - TypeScript source. */ const transpileGeneratedPackageTs = (source) => @@ -550,10 +506,7 @@ const makeWorkspacePackageRuntimeSafe = (packageDirectory) => (tsFile) => Effect.gen(function* transpileWorkspacePackageFileEffect() { const source = yield* fileSystem.readFileString(tsFile); - yield* fileSystem.writeFileString( - tsFile.replace(/\.ts$/u, '.js'), - transpileGeneratedPackageTs(source), - ); + yield* fileSystem.writeFileString(tsFile.replace(/\.ts$/u, '.js'), transpileGeneratedPackageTs(source)); }), { concurrency: 'unbounded', discard: true }, ); @@ -572,13 +525,11 @@ const copyWorkspacePackage = (workspacePackageName, workspacePackages, runtimeDi } return Effect.gen(function* copyWorkspacePackageEffect() { const fileSystem = yield* FileSystem.FileSystem; - const targetDirectory = pathService.join( - runtimeDir, - 'node_modules', - ...workspacePackageName.split('/'), - ); + const targetDirectory = pathService.join(runtimeDir, 'node_modules', ...workspacePackageName.split('/')); yield* fileSystem.remove(targetDirectory, { force: true, recursive: true }); - yield* fileSystem.makeDirectory(pathService.dirname(targetDirectory), { recursive: true }); + yield* fileSystem.makeDirectory(pathService.dirname(targetDirectory), { + recursive: true, + }); yield* copyDirectoryWithoutNodeModules(sourceDirectory, targetDirectory, pathService); yield* makeWorkspacePackageRuntimeSafe(targetDirectory); }); @@ -591,13 +542,7 @@ const copyWorkspacePackage = (workspacePackageName, workspacePackages, runtimeDi * @param {string} workspaceRoot - Workspace root. * @param {import('effect/Path').Path} pathService - Path service. */ -const installRuntimeDependencies = ( - runtimeManifest, - appId, - runtimeDir, - workspaceRoot, - pathService, -) => +const installRuntimeDependencies = (runtimeManifest, appId, runtimeDir, workspaceRoot, pathService) => Effect.gen(function* installRuntimeDependenciesEffect() { const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -605,15 +550,17 @@ const installRuntimeDependencies = ( prefix: `ultramodern-zerops-${appId}-`, }); const workspacePackages = yield* collectWorkspacePackages(workspaceRoot, pathService); - const { installPackage, localDependencies } = removeWorkspaceDependencies( - runtimeManifest, - workspacePackages, - ); + const { installPackage, localDependencies } = removeWorkspaceDependencies(runtimeManifest, workspacePackages); yield* writeJson(pathService.join(installDir, packageJsonFile), installPackage); const installCommand = ChildProcess.make( process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '--omit=dev', '--no-audit', '--fund=false', '--legacy-peer-deps'], - { cwd: installDir, stderr: 'inherit', stdin: 'inherit', stdout: 'inherit' }, + { + cwd: installDir, + stderr: 'inherit', + stdin: 'inherit', + stdout: 'inherit', + }, ); const installExitCode = yield* childProcessSpawner.exitCode(installCommand); if (installExitCode !== 0) { @@ -640,7 +587,7 @@ const materializeCommand = Command.make( appId: Flag.string('app'), packageDir: Flag.string('package-dir'), packageName: Flag.string('package'), - worker: Flag.boolean('worker'), + worker: Flag.boolean('worker').pipe(Flag.withDefault(false)), }, ({ appId, packageDir, packageName, worker }) => Effect.gen(function* materializeCommandEffect() { @@ -652,11 +599,7 @@ const materializeCommand = Command.make( yield* assertRelativePath('--package-dir', packageDir, pathService); const appRoot = pathService.resolve(workspaceRoot, packageDir); const appOutputDir = pathService.join(appRoot, '.output'); - const runtimeDir = pathService.join( - workspaceRoot, - '.zerops/runtime', - worker ? `${appId}-worker` : appId, - ); + const runtimeDir = pathService.join(workspaceRoot, '.zerops/runtime', worker ? `${appId}-worker` : appId); yield* Effect.all( [ assertInsideWorkspace('package directory', appRoot, workspaceRoot, pathService), @@ -676,16 +619,19 @@ const materializeCommand = Command.make( ); } - yield* fileSystem.remove(runtimeDir, { force: true, recursive: true }); - yield* fileSystem.makeDirectory(pathService.dirname(runtimeDir), { recursive: true }); + yield* fileSystem.remove(runtimeDir, { + force: true, + recursive: true, + }); + yield* fileSystem.makeDirectory(pathService.dirname(runtimeDir), { + recursive: true, + }); yield* worker ? fileSystem.makeDirectory(runtimeDir, { recursive: true }) : fileSystem.copy(appOutputDir, runtimeDir); const entryPath = pathService.join(runtimeDir, 'index.js'); if (!worker && !(yield* fileSystem.exists(entryPath))) { - yield* fail( - `Modern.js Node deploy output is missing ${pathService.relative(workspaceRoot, entryPath)}`, - ); + yield* fail(`Modern.js Node deploy output is missing ${pathService.relative(workspaceRoot, entryPath)}`); } }); yield* prepareRuntimeDirectory; @@ -709,11 +655,7 @@ const materializeCommand = Command.make( }), }).pipe(Effect.flatMap(decodeRuntimePackage)); } - runtimePackage = yield* normalizeRuntimePackageDependencies( - runtimePackage, - workspaceRoot, - pathService, - ); + runtimePackage = yield* normalizeRuntimePackageDependencies(runtimePackage, workspaceRoot, pathService); runtimePackage = yield* removeIncompatiblePlatformDependencies( runtimePackage, appRoot, @@ -730,18 +672,9 @@ const materializeCommand = Command.make( }, }); yield* writeJson(packageJsonPath, runtimePackage); - yield* installRuntimeDependencies( - runtimePackage, - appId, - runtimeDir, - workspaceRoot, - pathService, - ); + yield* installRuntimeDependencies(runtimePackage, appId, runtimeDir, workspaceRoot, pathService); yield* Effect.log( - `[ultramodern:zerops] materialized ${appId} runtime at ${pathService.relative( - workspaceRoot, - runtimeDir, - )}`, + `[ultramodern:zerops] materialized ${appId} runtime at ${pathService.relative(workspaceRoot, runtimeDir)}`, ); }), ); diff --git a/app/scripts/microvertical-api-baseline-boundary.mts b/app/scripts/microvertical-api-baseline-boundary.mts index 6101ce929..aad300996 100644 --- a/app/scripts/microvertical-api-baseline-boundary.mts +++ b/app/scripts/microvertical-api-baseline-boundary.mts @@ -40,10 +40,7 @@ const identifierName = (node: Node | undefined): string | undefined => node !== undefined && isIdentifier(node) ? node.text : undefined; const propertyName = (node: Node | undefined): string | undefined => { - if ( - node !== undefined && - (isIdentifier(node) || isStringLiteralLikeNode(node) || isNumericLiteral(node)) - ) { + if (node !== undefined && (isIdentifier(node) || isStringLiteralLikeNode(node) || isNumericLiteral(node))) { return node.text; } return undefined; @@ -65,11 +62,7 @@ const isAccessPath = (node: Expression, expected: readonly string[]): boolean => const unwrapExpression = (expression: Expression): Expression => { let current = expression; - while ( - isAsExpression(current) || - isParenthesizedExpression(current) || - isSatisfiesExpression(current) - ) { + while (isAsExpression(current) || isParenthesizedExpression(current) || isSatisfiesExpression(current)) { current = current.expression; } return current; @@ -91,26 +84,19 @@ const numericLiteral = (expression: Expression | undefined): number | undefined return isNumericLiteral(unwrapped) ? Number(unwrapped.text) : undefined; }; -const callExpression = ( - expression: Expression | undefined, - callee: readonly string[], -): CallExpression | undefined => { +const callExpression = (expression: Expression | undefined, callee: readonly string[]): CallExpression | undefined => { if (expression === undefined) { return undefined; } const unwrapped = unwrapExpression(expression); - return isCallExpression(unwrapped) && isAccessPath(unwrapped.expression, callee) - ? unwrapped - : undefined; + return isCallExpression(unwrapped) && isAccessPath(unwrapped.expression, callee) ? unwrapped : undefined; }; const exportedConst = (sourceFile: SourceFile, name: string): VariableDeclaration | undefined => { for (const statement of sourceFile.statements) { if ( !isVariableStatement(statement) || - !( - statement.modifiers?.some((modifier) => modifier.kind === SyntaxKind.ExportKeyword) ?? false - ) || + !(statement.modifiers?.some((modifier) => modifier.kind === SyntaxKind.ExportKeyword) ?? false) || statement.declarationList.flags !== NodeFlags.Const ) { continue; @@ -197,9 +183,7 @@ const sharedSchemaObject = ( return undefined; } const spreads = schemaObject.properties.filter(isSpreadAssignment); - const assignments = propertyAssignments( - schemaObject.properties.filter((property) => !isSpreadAssignment(property)), - ); + const assignments = propertyAssignments(schemaObject.properties.filter((property) => !isSpreadAssignment(property))); if ( spreads.length !== 1 || !spreads.every((spread) => isAccessPath(spread.expression, [sharedSchemaName, 'fields'])) || @@ -224,13 +208,19 @@ const directCallChain = (expression: Expression | undefined): DirectCallChain | return undefined; } let current = unwrapExpression(expression); - const methods: { readonly arguments: readonly Expression[]; readonly name: string }[] = []; + const methods: { + readonly arguments: readonly Expression[]; + readonly name: string; + }[] = []; while ( isCallExpression(current) && isPropertyAccessExpression(current.expression) && !isIdentifier(current.expression.expression) ) { - methods.unshift({ arguments: current.arguments, name: current.expression.name.text }); + methods.unshift({ + arguments: current.arguments, + name: current.expression.name.text, + }); current = unwrapExpression(current.expression.expression); } if (!isCallExpression(current)) { @@ -239,10 +229,7 @@ const directCallChain = (expression: Expression | undefined): DirectCallChain | return { base: current, methods }; }; -const brandedStringSchemaIsExact = ( - declaration: VariableDeclaration | undefined, - brand: string, -): boolean => { +const brandedStringSchemaIsExact = (declaration: VariableDeclaration | undefined, brand: string): boolean => { const initializer = declaration?.initializer; if (initializer === undefined) { return false; @@ -262,19 +249,12 @@ const brandedStringSchemaIsExact = ( }; const importedRuntimeNames = (statement: Node, expectedPackage: string): readonly string[] => { - if ( - !isImportDeclaration(statement) || - stringLiteral(statement.moduleSpecifier) !== expectedPackage - ) { + if (!isImportDeclaration(statement) || stringLiteral(statement.moduleSpecifier) !== expectedPackage) { return []; } const clause = statement.importClause; const bindings = clause?.namedBindings; - if ( - clause?.phaseModifier === SyntaxKind.TypeKeyword || - bindings === undefined || - !isNamedImports(bindings) - ) { + if (clause?.phaseModifier === SyntaxKind.TypeKeyword || bindings === undefined || !isNamedImports(bindings)) { return []; } return bindings.elements @@ -287,21 +267,18 @@ const importsExactBindings = ( expectedPackage: string, expectedBindings: readonly string[], ): boolean => { - const names = new Set( - sourceFile.statements.flatMap((statement) => importedRuntimeNames(statement, expectedPackage)), - ); + const names = new Set(sourceFile.statements.flatMap((statement) => importedRuntimeNames(statement, expectedPackage))); return expectedBindings.every((name) => names.has(name)); }; -const importsSharedBaselinePrimitives = ( - sourceFile: SourceFile, - expectedPackage: string, -): boolean => - importsExactBindings(sourceFile, expectedPackage, [ - 'MicroVerticalBuildMarkerSchema', - 'MicroVerticalReadinessSchema', - 'createMicroVerticalOperationContext', - ]); +const importsSharedBaselinePrimitives = (sourceFile: SourceFile, expectedPackage: string): boolean => + [expectedPackage, `${expectedPackage}/microvertical-api-baseline`].some((specifier) => + importsExactBindings(sourceFile, specifier, [ + 'MicroVerticalBuildMarkerSchema', + 'MicroVerticalReadinessSchema', + 'createMicroVerticalOperationContext', + ]), + ); const singleAddedArgument = ( expression: Expression | undefined, @@ -379,11 +356,7 @@ const rootComposesFoundation = ( }; const operationContextFields = (property: PropertyAssignment) => { - const constructorCall = exactCall( - property.initializer, - ['createMicroVerticalOperationContext'], - 1, - ); + const constructorCall = exactCall(property.initializer, ['createMicroVerticalOperationContext'], 1); const input = objectLiteral(constructorCall?.arguments[0]); return input === undefined ? undefined : propertyAssignments(input.properties); }; @@ -391,28 +364,23 @@ const operationContextFields = (property: PropertyAssignment) => { const operationContextIdentity = ( property: PropertyAssignment, ): - | { readonly method: string; readonly operationId: string; readonly routePath: string } + | { + readonly method: string; + readonly operationId: string; + readonly routePath: string; + } | undefined => { const fields = operationContextFields(property); const method = stringLiteral(fields?.get('method')?.initializer); const operationId = stringLiteral(fields?.get('operationId')?.initializer); const routePath = stringLiteral(fields?.get('routePath')?.initializer); - if ( - fields?.size !== 3 || - method === undefined || - operationId === undefined || - routePath === undefined - ) { + if (fields?.size !== 3 || method === undefined || operationId === undefined || routePath === undefined) { return undefined; } return { method, operationId, routePath }; }; -const operationContextIsConstructed = ( - property: PropertyAssignment, - stem: string, - propertyKey: string, -): boolean => { +const operationContextIsConstructed = (property: PropertyAssignment, stem: string, propertyKey: string): boolean => { const identity = operationContextIdentity(property); if (identity === undefined) { return false; @@ -433,8 +401,7 @@ const operationContextIsConstructed = ( ['removeCartItem', ['POST', `/${stem}/cart/remove`]], ]).get(propertyKey); const requestedOperation = [method, routePath]; - const matchesGenerated = - generatedOperation?.every((value, index) => value === requestedOperation[index]) === true; + const matchesGenerated = generatedOperation?.every((value, index) => value === requestedOperation[index]) === true; if (propertyKey === 'readiness' && !matchesGenerated) { return false; } @@ -444,10 +411,7 @@ const operationContextIsConstructed = ( ); }; -const operationMapIsConnected = ( - declaration: VariableDeclaration | undefined, - stem: string, -): boolean => { +const operationMapIsConnected = (declaration: VariableDeclaration | undefined, stem: string): boolean => { const map = objectLiteral(declaration?.initializer); const properties = map === undefined ? undefined : propertyAssignments(map.properties); return ( @@ -457,15 +421,9 @@ const operationMapIsConnected = ( ); }; -const constAssertionObject = ( - declaration: VariableDeclaration | undefined, -): ObjectLiteralExpression | undefined => { +const constAssertionObject = (declaration: VariableDeclaration | undefined): ObjectLiteralExpression | undefined => { const initializer = declaration?.initializer; - if ( - initializer === undefined || - !isAsExpression(initializer) || - initializer.type.getText() !== 'const' - ) { + if (initializer === undefined || !isAsExpression(initializer) || initializer.type.getText() !== 'const') { return undefined; } return objectLiteral(initializer.expression); @@ -487,16 +445,11 @@ const metadataIsExact = ( return ( fields !== undefined && fields.size === new Map(expectedFields).size && - [...expectedFields].every( - ([field, value]) => stringLiteral(fields.get(field)?.initializer) === value, - ) + [...expectedFields].every(([field, value]) => stringLiteral(fields.get(field)?.initializer) === value) ); }; -const markerSchemaIsShared = ( - sourceFile: SourceFile, - declaration: VariableDeclaration | undefined, -): boolean => { +const markerSchemaIsShared = (sourceFile: SourceFile, declaration: VariableDeclaration | undefined): boolean => { const schema = sharedSchemaObject(declaration, 'MicroVerticalBuildMarkerSchema', [ 'build', 'buildMarker', @@ -523,14 +476,10 @@ const markerSchemaIsShared = ( ], [ 'schemaVersion', - (property) => - numericLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === - 1, + (property) => numericLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === 1, ], ]); - return [...schema.assignments].every( - ([field, property]) => validators.get(field)?.(property) === true, - ); + return [...schema.assignments].every(([field, property]) => validators.get(field)?.(property) === true); }; const readinessSchemaIsShared = ( @@ -538,11 +487,7 @@ const readinessSchemaIsShared = ( markerSchemaName: string, ownerMarkerIsIdentity: boolean, ): boolean => { - const schema = sharedSchemaObject(declaration, 'MicroVerticalReadinessSchema', [ - 'checks', - 'status', - 'versionSkew', - ]); + const schema = sharedSchemaObject(declaration, 'MicroVerticalReadinessSchema', ['checks', 'status', 'versionSkew']); return ( schema !== undefined && ((schema.identity && ownerMarkerIsIdentity) || @@ -551,12 +496,8 @@ const readinessSchemaIsShared = ( ); }; -const declarationIsIdentifier = ( - declaration: VariableDeclaration | undefined, - identifier: string, -): boolean => - declaration?.initializer !== undefined && - identifierName(unwrapExpression(declaration.initializer)) === identifier; +const declarationIsIdentifier = (declaration: VariableDeclaration | undefined, identifier: string): boolean => + declaration?.initializer !== undefined && identifierName(unwrapExpression(declaration.initializer)) === identifier; const validateParsedContract = ( sourceFile: SourceFile, @@ -596,14 +537,7 @@ const validateParsedContract = ( if (!foundationIsExact(exportedConst(sourceFile, foundationName), stem, readinessSchemaName)) { return 'MicroVertical readiness foundation API must directly compose its exact readiness endpoint and foundation identity'; } - if ( - !rootComposesFoundation( - sourceFile, - exportedConst(sourceFile, `${exportStem}Api`), - stem, - foundationName, - ) - ) { + if (!rootComposesFoundation(sourceFile, exportedConst(sourceFile, `${exportStem}Api`), stem, foundationName)) { return 'MicroVertical root API must explicitly compose its readiness foundation API'; } if (!operationMapIsConnected(exportedConst(sourceFile, `${exportStem}OperationContexts`), stem)) { @@ -658,9 +592,7 @@ export const configuredMicroVerticalApiStem = ( verticalPath: string, verticals: readonly MicroVerticalTopologyEntry[], ): string | undefined => { - const topologyVertical = verticals.find( - (vertical) => (vertical.path ?? `verticals/${vertical.id}`) === verticalPath, - ); + const topologyVertical = verticals.find((vertical) => (vertical.path ?? `verticals/${vertical.id}`) === verticalPath); const endpoint = topologyVertical?.api?.readiness?.endpoint; return endpoint?.match(/^\/(?[a-z0-9]+(?:-[a-z0-9]+)*)\/readiness$/u)?.groups?.stem; }; diff --git a/app/scripts/migrate-contacts-authorization.mts b/app/scripts/migrate-contacts-authorization.mts index 31ec5c6d8..a3f8275ce 100644 --- a/app/scripts/migrate-contacts-authorization.mts +++ b/app/scripts/migrate-contacts-authorization.mts @@ -1,30 +1,20 @@ #!/usr/bin/env node import { pathToFileURL } from 'node:url'; + import { v1 } from '@authzed/authzed-node'; import { NodeServices } from '@effect/platform-node'; -import { - Console, - Effect, - Exit, - ManagedRuntime, - Number as EffectNumber, - Redacted, - Result, - Schema, -} from 'effect'; +import { Console, Effect, Exit, ManagedRuntime, Number as EffectNumber, Redacted, Result, Schema } from 'effect'; import { Argument, Command } from 'effect/unstable/cli'; import { Pool } from 'pg'; + import { loadDatabaseConnectionPair } from '../packages/core-runtime/src/db/config.ts'; +import { fullyConsistent, spiceDbClientSecurity } from '../packages/core-runtime/src/permissions/client.ts'; +import type { SpiceDbConfigValue } from '../packages/core-runtime/src/permissions/config.ts'; +import { loadSpiceDbConfig } from '../packages/core-runtime/src/permissions/config.ts'; import { toLegalEntityAccessObjectId, toModuleAccessObjectId, } from '../packages/core-runtime/src/permissions/context-access.ts'; -import { - fullyConsistent, - spiceDbClientSecurity, -} from '../packages/core-runtime/src/permissions/client.ts'; -import type { SpiceDbConfigValue } from '../packages/core-runtime/src/permissions/config.ts'; -import { loadSpiceDbConfig } from '../packages/core-runtime/src/permissions/config.ts'; const LEGACY_MODULE_ID = 'crm.core'; const CONTACTS_MODULE_ID = 'contacts.core'; @@ -32,13 +22,11 @@ const MAX_CONTEXTS = 500; const MAX_PRINCIPALS = 5000; const MAX_RELATIONSHIPS_PER_CONTEXT = 100; const DENIED_PROBE_PRINCIPAL_ID = 'contacts-identity-migration-denied-probe'; -const OUTSIDE_AUTHORITATIVE_CONTEXT_MESSAGE = - 'A module-access relationship is outside the authoritative context'; +const OUTSIDE_AUTHORITATIVE_CONTEXT_MESSAGE = 'A module-access relationship is outside the authoritative context'; const ContactsAuthorizationMigrationModeSchema = Schema.Literals(['finalize', 'prepare', 'verify']); -export type ContactsAuthorizationMigrationMode = - typeof ContactsAuthorizationMigrationModeSchema.Type; +export type ContactsAuthorizationMigrationMode = typeof ContactsAuthorizationMigrationModeSchema.Type; export class ContactsAuthorizationMigrationError extends Schema.TaggedError()( 'ContactsAuthorizationMigrationError', @@ -109,7 +97,11 @@ const planContactsAuthorizationContextResult = ( contacts: readonly ContactsAuthorizationRelationship[], ): Result.Result => { if (legacy.length === 0 && contacts.length === 0) { - return Result.succeed({ deleteLegacy: false, state: 'unconfigured', touchContacts: false }); + return Result.succeed({ + deleteLegacy: false, + state: 'unconfigured', + touchContacts: false, + }); } if (legacy.length === 0) { return Result.succeed({ @@ -120,13 +112,13 @@ const planContactsAuthorizationContextResult = ( } if (contacts.length === 0) { if (mode !== 'prepare') { - return Result.fail( - migrationFailure( - 'Contacts authorization is missing while legacy authorization still exists', - ), - ); + return Result.fail(migrationFailure('Contacts authorization is missing while legacy authorization still exists')); } - return Result.succeed({ deleteLegacy: false, state: 'legacy_only', touchContacts: true }); + return Result.succeed({ + deleteLegacy: false, + state: 'legacy_only', + touchContacts: true, + }); } if (!sameRelationshipSet(legacy, contacts)) { return Result.fail(migrationFailure('Legacy and Contacts authorization relationships differ')); @@ -177,14 +169,10 @@ const loadAuthoritativeContexts = ( ), }); if (contextResult.rows.length > MAX_CONTEXTS) { - return yield* migrationFailure( - `Authorization migration exceeds the ${MAX_CONTEXTS}-context safety bound`, - ); + return yield* migrationFailure(`Authorization migration exceeds the ${MAX_CONTEXTS}-context safety bound`); } if (contextResult.rows.some((row) => row.module_key === LEGACY_MODULE_ID)) { - return yield* migrationFailure( - 'Core module identity migration must complete before authorization migration', - ); + return yield* migrationFailure('Core module identity migration must complete before authorization migration'); } const tenantIds = [...new Set(contextResult.rows.map((row) => row.tenant_id))]; if (tenantIds.length === 0) { @@ -203,9 +191,7 @@ const loadAuthoritativeContexts = ( ), }); if (principalResult.rows.length > MAX_PRINCIPALS) { - return yield* migrationFailure( - `Authorization migration exceeds the ${MAX_PRINCIPALS}-principal safety bound`, - ); + return yield* migrationFailure(`Authorization migration exceeds the ${MAX_PRINCIPALS}-principal safety bound`); } const activePrincipalsByTenant = new Map>(); for (const principal of principalResult.rows) { @@ -222,10 +208,7 @@ const loadAuthoritativeContexts = ( })); }).pipe(Effect.scoped); -const hasExpectedRelationshipEnvelope = ( - relationship: v1.Relationship, - resourceId: string, -): boolean => +const hasExpectedRelationshipEnvelope = (relationship: v1.Relationship, resourceId: string): boolean => relationship.subject !== undefined && relationship.subject.object !== undefined && relationship.subject.optionalRelation === '' && @@ -240,8 +223,7 @@ const matchesRelationshipSubject = ( subjectType: string | undefined, expectedRelation: ContactsAuthorizationRelationship['relation'], ): boolean => - relation === expectedRelation && - subjectType === (expectedRelation === 'accessor' ? 'principal' : 'legal_entity'); + relation === expectedRelation && subjectType === (expectedRelation === 'accessor' ? 'principal' : 'legal_entity'); const decodeRelationship = ( relationship: v1.Relationship | undefined, @@ -259,16 +241,21 @@ const decodeRelationship = ( const subjectType = relationship.subject?.object?.objectType; const { relation } = relationship; const isLegalEntity = - matchesRelationshipSubject(relation, subjectType, 'legal_entity') && - subjectId === legalEntityObjectId; - const isAccessor = - matchesRelationshipSubject(relation, subjectType, 'accessor') && - activePrincipalIds.has(subjectId); + matchesRelationshipSubject(relation, subjectType, 'legal_entity') && subjectId === legalEntityObjectId; + const isAccessor = matchesRelationshipSubject(relation, subjectType, 'accessor') && activePrincipalIds.has(subjectId); if (isLegalEntity) { - return Result.succeed({ relation: 'legal_entity', subjectId, subjectType: 'legal_entity' }); + return Result.succeed({ + relation: 'legal_entity', + subjectId, + subjectType: 'legal_entity', + }); } if (isAccessor) { - return Result.succeed({ relation: 'accessor', subjectId, subjectType: 'principal' }); + return Result.succeed({ + relation: 'accessor', + subjectId, + subjectType: 'principal', + }); } return Result.fail(migrationFailure(OUTSIDE_AUTHORITATIVE_CONTEXT_MESSAGE)); }; @@ -294,14 +281,9 @@ const readRelationships = ( ), }); if (responses.length > MAX_RELATIONSHIPS_PER_CONTEXT) { - return yield* migrationFailure( - 'A module-access context exceeds the relationship safety bound', - ); + return yield* migrationFailure('A module-access context exceeds the relationship safety bound'); } - const legalEntityObjectId = toLegalEntityAccessObjectId( - context.tenantId, - context.legalEntityId, - ); + const legalEntityObjectId = toLegalEntityAccessObjectId(context.tenantId, context.legalEntityId); if (legalEntityObjectId === undefined) { return yield* migrationFailure('Invalid authoritative legal-entity context'); } @@ -309,24 +291,19 @@ const readRelationships = ( responses, ({ relationship }) => Effect.fromResult( - decodeRelationship( - relationship, - resourceId, - legalEntityObjectId, - context.activePrincipalIds, - ), + decodeRelationship(relationship, resourceId, legalEntityObjectId, context.activePrincipalIds), ), { concurrency: 'unbounded' }, ); }); -const toRelationship = ( - resourceId: string, - item: ContactsAuthorizationRelationship, -): v1.Relationship => +const toRelationship = (resourceId: string, item: ContactsAuthorizationRelationship): v1.Relationship => v1.Relationship.create({ relation: item.relation, - resource: v1.ObjectReference.create({ objectId: resourceId, objectType: 'module_access' }), + resource: v1.ObjectReference.create({ + objectId: resourceId, + objectType: 'module_access', + }), subject: v1.SubjectReference.create({ object: v1.ObjectReference.create({ objectId: item.subjectId, @@ -377,7 +354,10 @@ const checkContactsPermission = ( objectType: 'module_access', }), subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ objectId: principalId, objectType: 'principal' }), + object: v1.ObjectReference.create({ + objectId: principalId, + objectType: 'principal', + }), }), }), ), @@ -389,33 +369,21 @@ const assertContactsPermissions = ( relationships: readonly ContactsAuthorizationRelationship[], ): Effect.Effect => Effect.gen(function* assertContactsPermissionsEffect() { - const accessorIds = relationships - .filter((item) => item.relation === 'accessor') - .map((item) => item.subjectId); + const accessorIds = relationships.filter((item) => item.relation === 'accessor').map((item) => item.subjectId); yield* Effect.forEach( accessorIds, (principalId) => checkContactsPermission(client, resourceId, principalId).pipe( Effect.filterOrFail( - (permissionship) => - permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, - () => - migrationFailure( - 'Contacts permission verification did not preserve an allowed principal', - ), + (permissionship) => permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, + () => migrationFailure('Contacts permission verification did not preserve an allowed principal'), ), ), { concurrency: 1, discard: true }, ); - const deniedPermissionship = yield* checkContactsPermission( - client, - resourceId, - DENIED_PROBE_PRINCIPAL_ID, - ); + const deniedPermissionship = yield* checkContactsPermission(client, resourceId, DENIED_PROBE_PRINCIPAL_ID); if (deniedPermissionship !== v1.CheckPermissionResponse_Permissionship.NO_PERMISSION) { - yield* migrationFailure( - 'Contacts permission verification did not preserve the denied boundary', - ); + yield* migrationFailure('Contacts permission verification did not preserve the denied boundary'); } }); @@ -426,12 +394,7 @@ const deleteLegacyRelationships = ( context: AuthoritativeContext, ) => Effect.gen(function* deleteLegacyRelationshipsEffect() { - yield* writeRelationships( - client, - v1.RelationshipUpdate_Operation.DELETE, - resourceId, - relationships, - ); + yield* writeRelationships(client, v1.RelationshipUpdate_Operation.DELETE, resourceId, relationships); const remaining = yield* readRelationships(client, resourceId, context); if (remaining.length > 0) { yield* migrationFailure('Legacy relationship cleanup was incomplete'); @@ -444,36 +407,18 @@ const migrateContext = ( context: AuthoritativeContext, ): Effect.Effect => Effect.gen(function* migrateContextEffect() { - const legacyResourceId = toModuleAccessObjectId( - context.tenantId, - context.legalEntityId, - LEGACY_MODULE_ID, - ); - const contactsResourceId = toModuleAccessObjectId( - context.tenantId, - context.legalEntityId, - CONTACTS_MODULE_ID, - ); + const legacyResourceId = toModuleAccessObjectId(context.tenantId, context.legalEntityId, LEGACY_MODULE_ID); + const contactsResourceId = toModuleAccessObjectId(context.tenantId, context.legalEntityId, CONTACTS_MODULE_ID); if (legacyResourceId === undefined || contactsResourceId === undefined) { return yield* migrationFailure('Invalid authoritative module-access context'); } const [legacy, contactsBefore] = yield* Effect.all( - [ - readRelationships(client, legacyResourceId, context), - readRelationships(client, contactsResourceId, context), - ], + [readRelationships(client, legacyResourceId, context), readRelationships(client, contactsResourceId, context)], { concurrency: 'unbounded' }, ); - const plan = yield* Effect.fromResult( - planContactsAuthorizationContextResult(mode, legacy, contactsBefore), - ); + const plan = yield* Effect.fromResult(planContactsAuthorizationContextResult(mode, legacy, contactsBefore)); if (plan.touchContacts) { - yield* writeRelationships( - client, - v1.RelationshipUpdate_Operation.TOUCH, - contactsResourceId, - legacy, - ); + yield* writeRelationships(client, v1.RelationshipUpdate_Operation.TOUCH, contactsResourceId, legacy); } const contactsAfter = yield* readRelationships(client, contactsResourceId, context); if (legacy.length > 0 && !sameRelationshipSet(legacy, contactsAfter)) { @@ -495,12 +440,7 @@ const acquireSpiceDbClient = (configuration: SpiceDbConfigValue) => Effect.acquireRelease( Effect.try({ catch: () => migrationFailure('The authorization migration SpiceDB client could not open'), - try: () => - v1.NewClient( - configuration.preSharedKey, - configuration.endpoint, - spiceDbClientSecurity(configuration), - ), + try: () => v1.NewClient(configuration.preSharedKey, configuration.endpoint, spiceDbClientSecurity(configuration)), }), (client) => Effect.sync(() => client.close()), ); @@ -511,22 +451,16 @@ const migrateContactsAuthorization = ( Effect.gen(function* migrateContactsAuthorizationProgram() { const [database, spiceDb] = yield* Effect.all( [ - loadDatabaseConnectionPair().pipe( - Effect.mapError((error) => migrationFailure(error.reason)), - ), + loadDatabaseConnectionPair().pipe(Effect.mapError((error) => migrationFailure(error.reason))), loadSpiceDbConfig().pipe(Effect.mapError((error) => migrationFailure(error.reason))), ], { concurrency: 'unbounded' }, ); - const contexts = yield* loadAuthoritativeContexts( - Redacted.make(database.admin.connectionString), - ); + const contexts = yield* loadAuthoritativeContexts(Redacted.make(database.admin.connectionString)); const client = yield* acquireSpiceDbClient(spiceDb); - const contextResults = yield* Effect.forEach( - contexts, - (context) => migrateContext(client, mode, context), - { concurrency: 1 }, - ); + const contextResults = yield* Effect.forEach(contexts, (context) => migrateContext(client, mode, context), { + concurrency: 1, + }); return { contexts: contextResults.length, deleted: EffectNumber.sumAll(contextResults.map((result) => result.deleted)), diff --git a/app/scripts/migrate-strict-effect.mts b/app/scripts/migrate-strict-effect.mts index a377d5f89..2155e96d2 100644 --- a/app/scripts/migrate-strict-effect.mts +++ b/app/scripts/migrate-strict-effect.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/scripts/module-federation-bridge-boundary.mts b/app/scripts/module-federation-bridge-boundary.mts new file mode 100644 index 000000000..940e23fac --- /dev/null +++ b/app/scripts/module-federation-bridge-boundary.mts @@ -0,0 +1,104 @@ +import { parseSync } from 'oxc-parser'; +import type { Expression, ObjectExpression, Program } from 'oxc-parser'; + +interface RouterDependencies { + readonly dependencies?: Readonly>; + readonly devDependencies?: Readonly>; +} + +const property = (object: ObjectExpression, name: string): Expression | undefined => { + // Computed keys, spreads and duplicates can overwrite an apparently literal capability. + if (object.properties.some((entry) => entry.type === 'SpreadElement' || entry.computed)) { + return undefined; + } + const entries = object.properties.filter( + (entry) => + entry.type === 'Property' && + ((entry.key.type === 'Identifier' && entry.key.name === name) || + (entry.key.type === 'Literal' && entry.key.value === name)), + ); + const entry = entries.length === 1 ? entries[0] : undefined; + return entry?.type === 'Property' && entry.kind === 'init' && !entry.method ? entry.value : undefined; +}; + +const exportedConfiguration = (program: Program) => { + const exported = program.body.find((statement) => statement.type === 'ExportDefaultDeclaration'); + let config = exported?.type === 'ExportDefaultDeclaration' ? exported.declaration : undefined; + if (config?.type === 'Identifier') { + const { name } = config; + const declarations = program.body.flatMap((statement) => + statement.type === 'VariableDeclaration' && statement.kind === 'const' + ? statement.declarations.filter( + (declaration) => declaration.id.type === 'Identifier' && declaration.id.name === name, + ) + : [], + ); + config = declarations.length === 1 ? (declarations[0]?.init ?? undefined) : undefined; + } + return config; +}; + +const configurationBindings = (program: Program) => + program.body.flatMap((statement) => + statement.type === 'ImportDeclaration' && + statement.importKind !== 'type' && + statement.source.value === '@module-federation/modern-js-v3' + ? statement.specifiers.flatMap((specifier) => + specifier.type === 'ImportSpecifier' && + specifier.importKind !== 'type' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'createModuleFederationConfig' + ? [specifier.local.name] + : [], + ) + : [], + ); + +const configurationObject = (program: Program): ObjectExpression | undefined => { + const bindings = configurationBindings(program); + const config = exportedConfiguration(program); + if ( + config?.type !== 'CallExpression' || + config.callee.type !== 'Identifier' || + !bindings.includes(config.callee.name) || + config.arguments.length !== 1 || + config.arguments[0]?.type !== 'ObjectExpression' + ) { + return undefined; + } + return config.arguments[0]; +}; + +const bridgeRouterEnabled = (config: ObjectExpression): boolean | undefined => { + const bridge = property(config, 'bridge'); + const enabled = bridge?.type === 'ObjectExpression' ? property(bridge, 'enableBridgeRouter') : undefined; + if (enabled?.type !== 'Literal') { + return undefined; + } + return enabled.value === true || enabled.value === false ? enabled.value : undefined; +}; + +const declaresBridgeRouter = (manifest: RouterDependencies): boolean => + ['react-router', 'react-router-dom'].some( + (name) => Object.hasOwn(manifest.dependencies ?? {}, name) || Object.hasOwn(manifest.devDependencies ?? {}, name), + ); + +/** Check the exported configuration, not an unexecuted decoy or obsolete always-on bridge rule. */ +export const moduleFederationBridgeViolation = (source: string, manifest: RouterDependencies): string | undefined => { + const parsed = parseSync('module-federation.config.ts', source); + if (parsed.errors.length !== 0) { + return 'Module Federation configuration must parse.'; + } + const config = configurationObject(parsed.program); + if (config === undefined) { + return 'Module Federation must export a literal createModuleFederationConfig call or its top-level const binding.'; + } + const enabled = bridgeRouterEnabled(config); + if (enabled === undefined) { + return 'Module Federation must declare bridge.enableBridgeRouter as a boolean literal.'; + } + if (enabled && !declaresBridgeRouter(manifest)) { + return 'Module Federation may enable the React bridge router only when the app declares react-router or react-router-dom.'; + } + return undefined; +}; diff --git a/app/scripts/outbox-worker-delivery.mjs b/app/scripts/outbox-worker-delivery.mjs index 7eb5644da..ad4576d0f 100644 --- a/app/scripts/outbox-worker-delivery.mjs +++ b/app/scripts/outbox-worker-delivery.mjs @@ -23,7 +23,7 @@ class OutboxWorkerDeliveryInvalid extends Schema.TaggedError()('OutboxWorkerDeli /** * A generated worker host is the deployment capability; topology owns its identity. * - * @type {(root: string, vertical: OutboxWorkerVertical) => Effect.Effect} + * @type {(root: string, vertical: OutboxWorkerVertical) => Effect.Effect} */ export const outboxWorkerDelivery = Effect.fn('outboxWorkerDelivery')( /** @@ -39,9 +39,9 @@ export const outboxWorkerDelivery = Effect.fn('outboxWorkerDelivery')( return yield* Effect.undefined; } - const ownerPackage = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(OwnerPackageSchema), - )(yield* fileSystem.readFileString(packagePath)); + const ownerPackage = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OwnerPackageSchema))( + yield* fileSystem.readFileString(packagePath), + ); const workerStart = ownerPackage.scripts?.['worker:start']; if (workerStart === undefined || workerStart.length === 0) { return yield* Effect.undefined; diff --git a/app/scripts/plan-deployment-impact.mts b/app/scripts/plan-deployment-impact.mts index c6b270f1c..005e1ef13 100644 --- a/app/scripts/plan-deployment-impact.mts +++ b/app/scripts/plan-deployment-impact.mts @@ -16,7 +16,7 @@ import { } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import { outboxWorkerDelivery } from './outbox-worker-delivery.mjs'; + import type { ProtectedEntrypointInventory } from './authorization/protected-entrypoint-inventory.mts'; import type { AuthorizationRolloutContract } from './authorization/rollout-contract.mts'; import { validateAuthorizationRolloutContract } from './authorization/rollout-contract.mts'; @@ -25,14 +25,9 @@ import type { AuthorizationReadinessEvidence, } from './check-authorization-readiness.mts'; import { hashAuthorizationEvidence } from './check-authorization-readiness.mts'; +import { outboxWorkerDelivery } from './outbox-worker-delivery.mjs'; import type { AuthorizationImpactReport } from './report-fail-closed-authorization-impact.mts'; -declare global { - interface ImportMeta { - readonly main?: boolean; - } -} - export const DeploymentPhaseKindSchema = Schema.Literals(['infrastructure', 'provider', 'shell']); export type DeploymentPhaseKind = typeof DeploymentPhaseKindSchema.Type; @@ -74,9 +69,7 @@ const ReferenceTopologySchema = Schema.Struct({ id: Schema.optional(Schema.String), moduleFederation: Schema.optional( Schema.Struct({ - remotes: Schema.optional( - Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) })), - ), + remotes: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))), verticalRefs: Schema.optional(Schema.Array(Schema.String)), }), ), @@ -98,12 +91,7 @@ type Ownership = typeof OwnershipSchema.Type; const AuthorizationEnvironmentSchema = Schema.Literals(['development', 'production', 'stage']); const AuthorizationModeSchema = Schema.Literals(['enforced', 'report_only']); const AuthorizationCredentialSchema = Schema.Literals(['api_key', 'session']); -const AuthorizationSurfaceSchema = Schema.Literals([ - 'action', - 'capability_issuance', - 'route', - 'worker', -]); +const AuthorizationSurfaceSchema = Schema.Literals(['action', 'capability_issuance', 'route', 'worker']); const EntrypointKeySchema = Schema.String.pipe(Schema.brand('EntrypointKey')); const CanonicalTimestampStringSchema = Schema.String.check( Schema.makeFilter((value) => { @@ -113,9 +101,7 @@ const CanonicalTimestampStringSchema = Schema.String.check( : 'timestamp must use canonical UTC ISO 8601 encoding'; }), ); -const CanonicalTimestampCodec = CanonicalTimestampStringSchema.pipe( - Schema.decodeTo(Schema.DateTimeUtcFromString), -); +const CanonicalTimestampCodec = CanonicalTimestampStringSchema.pipe(Schema.decodeTo(Schema.DateTimeUtcFromString)); const CanonicalTimestampWireSchema = Schema.toEncoded(CanonicalTimestampCodec); export interface DeploymentImpactPlan { @@ -164,7 +150,10 @@ const InventoryAuthorizationSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal('public') }), Schema.Struct({ kind: Schema.Literal('authenticated_principal') }), Schema.Struct({ kind: Schema.Literal('owner_local_background') }), - Schema.Struct({ kind: Schema.Literal('context_permission'), permission: Schema.String }), + Schema.Struct({ + kind: Schema.Literal('context_permission'), + permission: Schema.String, + }), Schema.Struct({ kind: Schema.Literal('action_execution'), provisioning: Schema.Literals(['explicit', 'tenant_membership_default']), @@ -326,16 +315,12 @@ const requireAuthorizationEvidence = ( ): Required> => { const { impact, negativeSmoke, readiness } = input; if (impact === undefined || negativeSmoke === undefined || readiness === undefined) { - return fail( - 'enforced authorization promotion requires impact, readiness, and negative-smoke evidence', - ); + return fail('enforced authorization promotion requires impact, readiness, and negative-smoke evidence'); } return { impact, negativeSmoke, readiness }; }; -type PromotionEvidence = Required< - Pick ->; +type PromotionEvidence = Required>; const evidenceHasInventoryIdentity = ( inventory: ProtectedEntrypointInventory, @@ -349,10 +334,7 @@ const evidenceHasInventoryIdentity = ( evidence.sourceRevision === inventory.sourceRevision && evidence.inventoryHash === inventory.inventoryHash; -const readinessMatchesPromotion = ( - input: AuthorizationPromotionGateInput, - evidence: PromotionEvidence, -): boolean => { +const readinessMatchesPromotion = (input: AuthorizationPromotionGateInput, evidence: PromotionEvidence): boolean => { const { impact, negativeSmoke, readiness } = evidence; return ( readiness.status === 'ready' && @@ -363,10 +345,7 @@ const readinessMatchesPromotion = ( ); }; -const authorizationEvidenceMatches = ( - input: AuthorizationPromotionGateInput, - evidence: PromotionEvidence, -): boolean => +const authorizationEvidenceMatches = (input: AuthorizationPromotionGateInput, evidence: PromotionEvidence): boolean => [evidence.impact, evidence.negativeSmoke, evidence.readiness].every((item) => evidenceHasInventoryIdentity(input.inventory, item), ) && @@ -387,12 +366,20 @@ export const validateAuthorizationPromotionGate = ( if (input.environment === 'production') { fail('production authorization promotion rejects report-only configuration'); } - return { environment: input.environment, mode: rollout.mode, status: 'observing' }; + return { + environment: input.environment, + mode: rollout.mode, + status: 'observing', + }; } if (!authorizationEvidenceMatches(input, requireAuthorizationEvidence(input))) { fail('authorization promotion evidence is missing, stale, mismatched, or unresolved'); } - return { environment: input.environment, mode: rollout.mode, status: 'ready' }; + return { + environment: input.environment, + mode: rollout.mode, + status: 'ready', + }; }; const INFRASTRUCTURE_PHASES = { @@ -412,10 +399,7 @@ const INFRASTRUCTURE_PHASES = { const GIT_EXECUTABLE = '/usr/bin/git'; -const readJson = >( - schema: DocumentSchema, - filePath: string, -) => +const readJson = >(schema: DocumentSchema, filePath: string) => Effect.gen(function* readJsonEffect() { const fileSystem = yield* FileSystem.FileSystem; const source = yield* fileSystem.readFileString(filePath); @@ -445,9 +429,7 @@ const isWithin = (changedPath: string, ownerPath: string): boolean => const parseStageSetups = (zeropsSource: string): ReadonlySet => { const setups = new Set(); - for (const match of zeropsSource.matchAll( - /^\s*-\s+setup:\s*['"]?(?[^'"\s]+)['"]?\s*$/gmu, - )) { + for (const match of zeropsSource.matchAll(/^\s*-\s+setup:\s*['"]?(?[^'"\s]+)['"]?\s*$/gmu)) { const setup = match.groups?.setup; if (setup !== undefined && setup.length > 0) { setups.add(setup); @@ -459,9 +441,7 @@ const parseStageSetups = (zeropsSource: string): ReadonlySet => { type TopologyOwner = typeof TopologyOwnerSchema.Type; type ReferenceVertical = NonNullable[number]; -const indexOwners = ( - ownerEntries: readonly TopologyOwner[], -): ReadonlyMap => { +const indexOwners = (ownerEntries: readonly TopologyOwner[]): ReadonlyMap => { const ownersById = new Map(); for (const owner of ownerEntries) { const ownerId = requireString(owner.id, 'ownership owner.id'); @@ -518,14 +498,8 @@ const validateSharedPackages = ( const sharedPackageIds = new Set(); for (const sharedPackage of sharedPackages) { const id = requireString(sharedPackage.id, 'reference topology shared package.id'); - const packageName = requireString( - sharedPackage.package, - `reference topology shared package ${id}.package`, - ); - const ownerPath = requireString( - sharedPackage.path, - `reference topology shared package ${id}.path`, - ); + const packageName = requireString(sharedPackage.package, `reference topology shared package ${id}.package`); + const ownerPath = requireString(sharedPackage.path, `reference topology shared package ${id}.path`); if (sharedPackageIds.has(id)) { fail(`reference topology contains duplicate shared package identity "${id}"`); } @@ -562,9 +536,7 @@ const verticalDependencies = ( ): readonly string[] => { const dependencies = [ ...(vertical.moduleFederation?.verticalRefs ?? []), - ...(vertical.moduleFederation?.remotes ?? []).flatMap((remote) => - remote.id === undefined ? [] : [remote.id], - ), + ...(vertical.moduleFederation?.remotes ?? []).flatMap((remote) => (remote.id === undefined ? [] : [remote.id])), ]; for (const dependency of dependencies) { if (!verticalIds.has(dependency)) { @@ -582,10 +554,7 @@ const buildVerticalUnits = ( const units: TopologyUnit[] = []; for (const vertical of verticals) { const id = requireString(vertical.id, 'reference topology vertical.id'); - const packageName = requireString( - vertical.package, - `reference topology vertical ${id}.package`, - ); + const packageName = requireString(vertical.package, `reference topology vertical ${id}.package`); const ownerPath = requireString(vertical.path, `reference topology vertical ${id}.path`); const owner = ownersById.get(id); if (owner === undefined) { @@ -643,15 +612,10 @@ const validateOwnershipCoverage = ( } }; -const validateStageSetupCoverage = ( - units: readonly TopologyUnit[], - stageSetups: ReadonlySet, -): void => { +const validateStageSetupCoverage = (units: readonly TopologyUnit[], stageSetups: ReadonlySet): void => { for (const phase of [...Object.values(INFRASTRUCTURE_PHASES), ...units]) { if (!stageSetups.has(phase.stageSetup)) { - fail( - `topology delivery unit "${phase.id}" has unsupported stage setup "${phase.stageSetup}" in zerops.yaml`, - ); + fail(`topology delivery unit "${phase.id}" has unsupported stage setup "${phase.stageSetup}" in zerops.yaml`); } } }; @@ -709,11 +673,7 @@ const orderUnits = (units: readonly TopologyUnit[]): readonly TopologyUnit[] => return ordered; }; -const invalidBaseReason = ( - rootDirectory: string, - baseRevision: string | undefined, - headRevision: string, -) => +const invalidBaseReason = (rootDirectory: string, baseRevision: string | undefined, headRevision: string) => Effect.gen(function* invalidBaseReasonEffect() { if (baseRevision === undefined || baseRevision.length === 0 || /^0+$/u.test(baseRevision)) { return 'comparison base is unavailable or all-zero'; @@ -736,11 +696,11 @@ const invalidBaseReason = ( } const isAncestor = yield* spawner .exitCode( - ChildProcess.make( - GIT_EXECUTABLE, - ['merge-base', '--is-ancestor', baseRevision, headRevision], - { cwd: rootDirectory, stderr: 'ignore', stdout: 'ignore' }, - ), + ChildProcess.make(GIT_EXECUTABLE, ['merge-base', '--is-ancestor', baseRevision, headRevision], { + cwd: rootDirectory, + stderr: 'ignore', + stdout: 'ignore', + }), ) .pipe( Effect.map((exitCode) => exitCode === 0), @@ -756,11 +716,9 @@ const changedPathsFromGit = (rootDirectory: string, baseRevision: string, headRe Effect.gen(function* changedPathsFromGitEffect() { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const output = yield* spawner.string( - ChildProcess.make( - GIT_EXECUTABLE, - ['diff', '--name-only', '--no-renames', '-z', baseRevision, headRevision], - { cwd: rootDirectory }, - ), + ChildProcess.make(GIT_EXECUTABLE, ['diff', '--name-only', '--no-renames', '-z', baseRevision, headRevision], { + cwd: rootDirectory, + }), ); return output.split('\0').filter(Boolean); }); @@ -831,13 +789,16 @@ const makeComparison = ( ): DeploymentImpactPlan['comparison'] => { const mode = fallbackReason === undefined ? 'diff' : 'full'; if (options.baseRevision === undefined) { - return fallbackReason === undefined - ? { headRevision, mode } - : { headRevision, mode, reason: fallbackReason }; + return fallbackReason === undefined ? { headRevision, mode } : { headRevision, mode, reason: fallbackReason }; } return fallbackReason === undefined ? { baseRevision: options.baseRevision, headRevision, mode } - : { baseRevision: options.baseRevision, headRevision, mode, reason: fallbackReason }; + : { + baseRevision: options.baseRevision, + headRevision, + mode, + reason: fallbackReason, + }; }; interface DeploymentImpactState { @@ -852,20 +813,13 @@ const addAllUnits = (impacted: Set, orderedUnits: readonly TopologyUnit[ } }; -const addWithConsumers = ( - unitId: string, - impacted: Set, - orderedUnits: readonly TopologyUnit[], -): void => { +const addWithConsumers = (unitId: string, impacted: Set, orderedUnits: readonly TopologyUnit[]): void => { impacted.add(unitId); let changed = true; while (changed) { changed = false; for (const unit of orderedUnits) { - if ( - !impacted.has(unit.id) && - unit.dependencies.some((dependency) => impacted.has(dependency)) - ) { + if (!impacted.has(unit.id) && unit.dependencies.some((dependency) => impacted.has(dependency))) { impacted.add(unit.id); changed = true; } @@ -972,10 +926,7 @@ const deploymentComparison = (options: PlanDeploymentImpactOptions, rootDirector requireString(options.baseRevision, 'base revision'), headRevision, )); - const changedPaths = EffectArray.sort( - [...new Set(comparedPaths.map(normalizeChangedPath))], - Order.String, - ); + const changedPaths = EffectArray.sort([...new Set(comparedPaths.map(normalizeChangedPath))], Order.String); return { changedPaths, fallbackReason, fullDeploy, headRevision }; }); @@ -998,16 +949,12 @@ export const planDeploymentImpact = (options: PlanDeploymentImpactOptions = {}) : validateAuthorizationPromotionGate(options.authorizationPromotion); const pathService = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; - const rootDirectory = - options.rootDirectory ?? (yield* Config.string('PWD').pipe(Effect.orElseSucceed(() => '.'))); + const rootDirectory = options.rootDirectory ?? (yield* Config.string('PWD').pipe(Effect.orElseSucceed(() => '.'))); const topology = yield* readJson( ReferenceTopologySchema, pathService.join(rootDirectory, 'topology/reference-topology.json'), ); - const ownership = yield* readJson( - OwnershipSchema, - pathService.join(rootDirectory, 'topology/ownership.json'), - ); + const ownership = yield* readJson(OwnershipSchema, pathService.join(rootDirectory, 'topology/ownership.json')); const stageSetups = parseStageSetups( yield* fileSystem.readFileString(pathService.join(rootDirectory, 'zerops.yaml')), ); @@ -1137,18 +1084,14 @@ const writeGitHubOutputs = (plan: DeploymentImpactPlan, outputPath: string) => }); const parseAuthorizationNow = (value: string) => - Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString)(value).pipe( - Effect.map(DateTime.toEpochMillis), - ); + Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString)(value).pipe(Effect.map(DateTime.toEpochMillis)); const deploymentImpactCommand = Command.make( 'plan-deployment-impact', { - authorizationEnvironment: Flag.choice('authorization-environment', [ - 'development', - 'production', - 'stage', - ]).pipe(Flag.optional), + authorizationEnvironment: Flag.choice('authorization-environment', ['development', 'production', 'stage']).pipe( + Flag.optional, + ), authorizationNow: Flag.string('authorization-now').pipe(Flag.optional), baseRevision: Flag.string('base').pipe(Flag.optional), changedPaths: Flag.string('changed-path').pipe(Flag.atLeast(0)), @@ -1162,14 +1105,8 @@ const deploymentImpactCommand = Command.make( if (environment !== undefined) { const configuredNow = Option.getOrUndefined(authorizationNow); const nowEpochMs = - configuredNow === undefined - ? yield* Clock.currentTimeMillis - : yield* parseAuthorizationNow(configuredNow); - authorizationPromotion = yield* loadAuthorizationPromotionGate( - rootDirectory, - environment, - nowEpochMs, - ); + configuredNow === undefined ? yield* Clock.currentTimeMillis : yield* parseAuthorizationNow(configuredNow); + authorizationPromotion = yield* loadAuthorizationPromotionGate(rootDirectory, environment, nowEpochMs); } const options: PlanDeploymentImpactOptions = { baseRevision: Option.getOrUndefined(baseRevision), @@ -1192,10 +1129,8 @@ const deploymentImpactCommand = Command.make( export const main = Command.run({ version: '1.0.0' })(deploymentImpactCommand); -if (import.meta.main === true) { +if (import.meta.main) { NodeRuntime.runMain( - Layer.build(Layer.effectDiscard(main).pipe(Layer.provide(NodeServices.layer))).pipe( - Effect.scoped, - ), + Layer.build(Layer.effectDiscard(main).pipe(Layer.provide(NodeServices.layer))).pipe(Effect.scoped), ); } diff --git a/app/scripts/postgres/bootstrap-runtime-role.mts b/app/scripts/postgres/bootstrap-runtime-role.mts index 2242dfc86..b915676cf 100644 --- a/app/scripts/postgres/bootstrap-runtime-role.mts +++ b/app/scripts/postgres/bootstrap-runtime-role.mts @@ -1,14 +1,12 @@ import { Effect, Exit, Redacted, Schema } from 'effect'; import { Client } from 'pg'; import type { QueryResult, QueryResultRow } from 'pg'; + import { loadDatabaseConnectionPair } from '../../packages/core-runtime/src/db/config.ts'; -class RuntimeRoleBootstrapError extends Schema.TaggedError()( - 'RuntimeRoleBootstrapError', - { - reason: Schema.String, - }, -) {} +class RuntimeRoleBootstrapError extends Schema.TaggedError()('RuntimeRoleBootstrapError', { + reason: Schema.String, +}) {} const quoteLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; const quoteIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; @@ -26,16 +24,16 @@ const query = ( try: async () => await client.query(text, values), }); -const connectAdmin = ( - connectionString: Redacted.Redacted, -): Effect.Effect => +const connectAdmin = (connectionString: Redacted.Redacted): Effect.Effect => Effect.tryPromise({ catch: (cause) => new RuntimeRoleBootstrapError({ reason: `Unable to connect to the administrative PostgreSQL database: ${String(cause)}`, }), try: async () => { - const client = new Client({ connectionString: Redacted.value(connectionString) }); + const client = new Client({ + connectionString: Redacted.value(connectionString), + }); await client.connect(); return client; }, @@ -85,10 +83,7 @@ const bootstrapRuntimeRole = ( client, `grant select, insert, update, delete on all tables in schema ${schema} to ontos_runtime`, ); - yield* query( - client, - `grant usage, select on all sequences in schema ${schema} to ontos_runtime`, - ); + yield* query(client, `grant usage, select on all sequences in schema ${schema} to ontos_runtime`); yield* query( client, `alter default privileges in schema ${schema} grant select, insert, update, delete on tables to ontos_runtime`, diff --git a/app/scripts/postgres/bootstrap-spicedb-database.mts b/app/scripts/postgres/bootstrap-spicedb-database.mts index 2e161a301..c469de5c9 100644 --- a/app/scripts/postgres/bootstrap-spicedb-database.mts +++ b/app/scripts/postgres/bootstrap-spicedb-database.mts @@ -3,6 +3,7 @@ import { Config, ConfigProvider, Console, Effect, Exit, Match, Redacted, Schema import type { FileSystem } from 'effect'; import { Client } from 'pg'; import type { QueryResult, QueryResultRow } from 'pg'; + import { APP_ENV_PATH } from '../../packages/core-runtime/src/environment/workspace-environment.ts'; import { parseSpiceDbDatabaseBootstrapConfig } from '../../packages/core-runtime/src/install/spicedb-database-config.ts'; import type { SpiceDbDatabaseBootstrapConfig } from '../../packages/core-runtime/src/install/spicedb-database-config.ts'; @@ -42,36 +43,23 @@ const loadRootConfiguration = (): Effect.Effect< }).pipe( Effect.catchTag('PlatformError', (failure) => Match.value(failure.reason).pipe( - Match.tag('NotFound', () => - Effect.succeed(ConfigProvider.fromUnknown({}, { preserveEmptyStrings: true })), - ), + Match.tag('NotFound', () => Effect.succeed(ConfigProvider.fromUnknown({}, { preserveEmptyStrings: true }))), Match.orElse(() => Effect.fail(failure)), ), ), - Effect.mapError((cause) => - bootstrapFailure(`Unable to load the root environment from ${APP_ENV_PATH}`, cause), - ), + Effect.mapError((cause) => bootstrapFailure(`Unable to load the root environment from ${APP_ENV_PATH}`, cause)), ); - const provider = ConfigProvider.orElse( - ConfigProvider.fromEnv({ preserveEmptyStrings: true }), - fileProvider, - ); + const provider = ConfigProvider.orElse(ConfigProvider.fromEnv({ preserveEmptyStrings: true }), fileProvider); const [adminUrl, spiceDbUrl] = yield* Effect.all( - [ - Config.redacted('DATABASE_ADMIN_URL').parse(provider), - Config.redacted('SPICEDB_DATABASE_URL').parse(provider), - ], + [Config.redacted('DATABASE_ADMIN_URL').parse(provider), Config.redacted('SPICEDB_DATABASE_URL').parse(provider)], { concurrency: 1 }, ).pipe( - Effect.mapError((cause) => - bootstrapFailure('SpiceDB PostgreSQL bootstrap configuration is invalid', cause), - ), + Effect.mapError((cause) => bootstrapFailure('SpiceDB PostgreSQL bootstrap configuration is invalid', cause)), ); return yield* Effect.try({ - catch: (cause) => - bootstrapFailure('SpiceDB PostgreSQL bootstrap configuration is invalid', cause), + catch: (cause) => bootstrapFailure('SpiceDB PostgreSQL bootstrap configuration is invalid', cause), try: () => parseSpiceDbDatabaseBootstrapConfig({ DATABASE_ADMIN_URL: Redacted.value(adminUrl), @@ -80,14 +68,13 @@ const loadRootConfiguration = (): Effect.Effect< }); }); -const connectAdmin = ( - connectionString: Redacted.Redacted, -): Effect.Effect => +const connectAdmin = (connectionString: Redacted.Redacted): Effect.Effect => Effect.tryPromise({ - catch: (cause) => - bootstrapFailure('Unable to connect to the administrative PostgreSQL database', cause), + catch: (cause) => bootstrapFailure('Unable to connect to the administrative PostgreSQL database', cause), try: async () => { - const client = new Client({ connectionString: Redacted.value(connectionString) }); + const client = new Client({ + connectionString: Redacted.value(connectionString), + }); await client.connect(); return client; }, @@ -95,8 +82,7 @@ const connectAdmin = ( const closeAdmin = (client: Client): Effect.Effect => Effect.tryPromise({ - catch: (cause) => - bootstrapFailure('Unable to close the administrative PostgreSQL connection', cause), + catch: (cause) => bootstrapFailure('Unable to close the administrative PostgreSQL connection', cause), try: async () => await client.end(), }); diff --git a/app/scripts/prepare-dev-module-contract.mts b/app/scripts/prepare-dev-module-contract.mts index 525f347c4..839d256b6 100644 --- a/app/scripts/prepare-dev-module-contract.mts +++ b/app/scripts/prepare-dev-module-contract.mts @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { loadCoreNodeServices } from './shared/core-node-services.mts'; import { Console, Effect, Exit, FileSystem, Path, Schema } from 'effect'; import { Argument, Command } from 'effect/unstable/cli'; import { generateOntosModuleContract } from './generate-ontos-module-contract.mts'; +import { loadCoreNodeServices } from './shared/core-node-services.mts'; class ModuleContractPreparationError extends Schema.TaggedError()( 'ModuleContractPreparationError', @@ -17,9 +17,7 @@ class ModuleContractPreparationError extends Schema.TaggedError Schema.is(ModuleContractPreparationError)(cause) @@ -75,9 +68,7 @@ const NodeServices = loadCoreNodeServices(); const exit = await Effect.runPromiseExit( Command.run(prepareDevModuleContractCommand, { version: '1.0.0' }).pipe( Effect.tapError((failure) => - Schema.is(ModuleContractPreparationError)(failure) - ? Console.error(failure.message) - : Effect.void, + Schema.is(ModuleContractPreparationError)(failure) ? Console.error(failure.message) : Effect.void, ), Effect.provide(NodeServices.layer), ), diff --git a/app/scripts/proof-cloudflare-version.mts b/app/scripts/proof-cloudflare-version.mts index 705190563..d09d05706 100644 --- a/app/scripts/proof-cloudflare-version.mts +++ b/app/scripts/proof-cloudflare-version.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/scripts/proof-workerd-ssr.mts b/app/scripts/proof-workerd-ssr.mts index f268fecbe..15e5dd330 100644 --- a/app/scripts/proof-workerd-ssr.mts +++ b/app/scripts/proof-workerd-ssr.mts @@ -1,30 +1,14 @@ #!/usr/bin/env node import crypto from 'node:crypto'; import http from 'node:http'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import path from 'node:path'; import { NodeFileSystem } from '@effect/platform-node'; -import { - Array as EffectArray, - Config, - Effect, - Exit, - FileSystem, - ManagedRuntime, - Option, - Order, - Schema, -} from 'effect'; -import { - Headers as MiniflareHeaders, - Log, - LogLevel, - Miniflare, - Response as MiniflareResponse, -} from 'miniflare'; -import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { Array as EffectArray, Config, Effect, Exit, FileSystem, ManagedRuntime, Option, Order, Schema } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import type { Scope } from 'effect/Scope'; +import { Headers as MiniflareHeaders, Log, LogLevel, Miniflare, Response as MiniflareResponse } from 'miniflare'; import type { Request as MiniflareRequest, RequestInit as MiniflareRequestInit } from 'miniflare'; const DISTRIBUTED_SSR_FRAGMENT_REQUEST_HEADER = 'x-modern-js-fragment-request'; @@ -62,9 +46,7 @@ const WranglerSchema = Schema.Struct({ compatibility_flags: Schema.optionalKey(Schema.Array(Schema.String)), main: Schema.optionalKey(Schema.String), name: Schema.String, - services: Schema.optionalKey( - Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String })), - ), + services: Schema.optionalKey(Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String }))), vars: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); const ArtifactSchema = Schema.Struct({ @@ -122,11 +104,19 @@ const RawAppSchema = Schema.Struct({ port: Schema.Number, }); const CompactConfigSchema = Schema.Struct({ - topology: Schema.optionalKey( - Schema.Struct({ apps: Schema.optionalKey(Schema.Array(RawAppSchema)) }), - ), + topology: Schema.optionalKey(Schema.Struct({ apps: Schema.optionalKey(Schema.Array(RawAppSchema)) })), }); -const ApiResponseSchema = Schema.Struct({ marker: ApiReleaseMarkerSchema }); +const containsApiReleaseMarker = Schema.is(Schema.Struct({ marker: ApiReleaseMarkerSchema })); +const isJsonScalar = Schema.is(Schema.Union([Schema.Null, Schema.Boolean, Schema.Number, Schema.String])); +const findReleaseMarkers = (value: Schema.Json): readonly ApiReleaseMarker[] => { + if (isJsonScalar(value)) { + return []; + } + return [ + ...(containsApiReleaseMarker(value) ? [value.marker] : []), + ...Object.values(value).flatMap(findReleaseMarkers), + ]; +}; const ServiceBindingFaultCommandSchema = Schema.Struct({ appId: AppIdSchema, failed: Schema.Boolean, @@ -140,10 +130,10 @@ const ServiceBindingFaultResponseSchema = Schema.fromJsonString( ); const TargetUrlsSchema = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); -export class WorkerdProofError extends Schema.TaggedError()( - 'WorkerdProofError', - { cause: Schema.optionalKey(Schema.Defect()), message: Schema.String }, -) {} +export class WorkerdProofError extends Schema.TaggedError()('WorkerdProofError', { + cause: Schema.optionalKey(Schema.Defect()), + message: Schema.String, +}) {} type SmokeCheck = typeof SmokeCheckSchema.Type; type Wrangler = typeof WranglerSchema.Type; @@ -371,10 +361,7 @@ const collectJavaScriptFiles = (absoluteDirectory: string): ProofEffect => +const createWorkerModules = (outputRoot: string, main: string): ProofEffect => Effect.gen(function* createWorkerModulesEffect() { const entryPath = path.resolve(outputRoot, main); const collected = yield* Effect.all( @@ -397,58 +384,39 @@ const readExecutionEnvelope = ( appId: string, outputRoot: string, expectedUnitId: string | undefined, -): ProofEffect<{ readonly envelope: ExecutionEnvelope; readonly envelopePath: string }> => +): ProofEffect<{ + readonly envelope: ExecutionEnvelope; + readonly envelopePath: string; +}> => Effect.gen(function* readExecutionEnvelopeEffect() { const fileSystem = yield* FileSystem.FileSystem; const envelopePath = path.join(outputRoot, 'release/microvertical-release-envelope.json'); - yield* ensure( - yield* fileSystem.exists(envelopePath), - `${appId} executed .output release envelope is missing`, - ); + yield* ensure(yield* fileSystem.exists(envelopePath), `${appId} executed .output release envelope is missing`); const envelope = yield* readJsonDocument(envelopePath, ExecutionEnvelopeSchema); yield* ensure(envelope.schemaVersion === 3, `${appId} executed envelope schema must be 3`); + yield* ensure(envelope.target === 'cloudflare', `${appId} executed envelope must target cloudflare`); yield* ensure( - envelope.target === 'cloudflare', - `${appId} executed envelope must target cloudflare`, - ); - yield* ensure( - expectedUnitId !== undefined && - expectedUnitId.length > 0 && - envelope.identity.unitId === expectedUnitId, + expectedUnitId !== undefined && expectedUnitId.length > 0 && envelope.identity.unitId === expectedUnitId, `${appId} executed envelope unit identity is invalid`, ); - yield* ensure( - /^[a-f\d]{64}$/u.test(envelope.envelopeDigest), - `${appId} executed envelope digest is invalid`, - ); + yield* ensure(/^[a-f\d]{64}$/u.test(envelope.envelopeDigest), `${appId} executed envelope digest is invalid`); yield* ensure(envelope.artifacts.length > 0, `${appId} executed envelope has no artifacts`); return { envelope, envelopePath }; }); -const bindExecutedModule = ( - app: App, - envelope: ExecutionEnvelope, - module: WorkerModule, -): ProofEffect => +const bindExecutedModule = (app: App, envelope: ExecutionEnvelope, module: WorkerModule): ProofEffect => Effect.gen(function* bindExecutedModuleEffect() { const fileSystem = yield* FileSystem.FileSystem; const logicalPath = normalizePath(path.relative(app.outputRoot, module.path)); yield* ensure( - logicalPath.length > 0 && - !logicalPath.startsWith('../') && - !path.posix.isAbsolute(logicalPath), + logicalPath.length > 0 && !logicalPath.startsWith('../') && !path.posix.isAbsolute(logicalPath), `${app.id} selected module escapes .output: ${logicalPath}`, ); const artifact = envelope.artifacts.find((candidate) => candidate.logicalPath === logicalPath); if (artifact === undefined) { - return yield* Effect.fail( - proofError(`${app.id} selected module ${logicalPath} is not envelope-bound`), - ); + return yield* Effect.fail(proofError(`${app.id} selected module ${logicalPath} is not envelope-bound`)); } - yield* ensure( - artifact.kind === 'file', - `${app.id} selected module ${logicalPath} is bound to a non-file artifact`, - ); + yield* ensure(artifact.kind === 'file', `${app.id} selected module ${logicalPath} is bound to a non-file artifact`); const bytes = yield* fileSystem.readFile(module.path); const digest = sha256(bytes); yield* ensure( @@ -470,10 +438,7 @@ const resolveAppPath = (id: string, kind: App['kind'], configuredPath: string | } return kind === 'shell' ? 'apps/shell-super-app' : `verticals/${id}`; }; -const resolveProofRoutes = ( - configuredRoutes: readonly string[], - configuredSsrRoute: string | undefined, -) => { +const resolveProofRoutes = (configuredRoutes: readonly string[], configuredSsrRoute: string | undefined) => { const proofRoutes = [...new Set(configuredRoutes.filter((route) => route.startsWith('/')))]; if (proofRoutes.length > 0) { return proofRoutes; @@ -488,10 +453,7 @@ const deriveAppConfiguration = (rawApp: typeof RawAppSchema.Type) => { id: rawApp.id, jsonSmokeChecks: cloudflare?.jsonSmokeChecks ?? [], port: rawApp.port, - proofRoutes: resolveProofRoutes( - cloudflare?.distributedSsrProofRoutes ?? [], - cloudflare?.routes?.ssr, - ), + proofRoutes: resolveProofRoutes(cloudflare?.distributedSsrProofRoutes ?? [], cloudflare?.routes?.ssr), verticalRefs: rawApp.moduleFederation?.verticalRefs ?? [], }; }; @@ -566,9 +528,7 @@ const createWorkerConfiguration = ( : bindExecutedModule(app, app.envelope, module), { concurrency: 1 }, ); - const mainLogicalPath = normalizePath( - path.relative(app.outputRoot, path.resolve(app.outputRoot, main)), - ); + const mainLogicalPath = normalizePath(path.relative(app.outputRoot, path.resolve(app.outputRoot, main))); yield* ensure( boundModules.some((module) => module.logicalPath === mainLogicalPath), `${app.id} Miniflare main ${mainLogicalPath} is not in the selected module set`, @@ -579,8 +539,7 @@ const createWorkerConfiguration = ( const selectedPaths = new Set(boundModules.map((module) => module.logicalPath)); yield* ensure( app.kind !== 'vertical' || - (apiBackend.length > 0 && - apiBackend.every((logicalPath) => selectedPaths.has(logicalPath))), + (apiBackend.length > 0 && apiBackend.every((logicalPath) => selectedPaths.has(logicalPath))), `${app.id} BFF worker surface is not selected by Miniflare`, ); yield* ensure( @@ -625,9 +584,7 @@ const createWorkerConfiguration = ( appId: app.id, envelopeDigest: app.envelope?.envelopeDigest ?? null, envelopePath: - app.envelopePath === undefined - ? null - : normalizePath(path.relative(workspaceRoot, app.envelopePath)), + app.envelopePath === undefined ? null : normalizePath(path.relative(workspaceRoot, app.envelopePath)), identity: app.envelope?.identity ?? null, main: mainLogicalPath, modules: boundModules, @@ -638,10 +595,7 @@ const createWorkerConfiguration = ( }; }); -const responseEvidence = ( - app: App, - response: MiniflareResponse, -): Effect.Effect => +const responseEvidence = (app: App, response: MiniflareResponse): Effect.Effect => Effect.gen(function* responseEvidenceEffect() { const arrayBuffer = yield* Effect.tryPromise({ catch: (cause) => proofError(`${app.id} API response body could not be read`, cause), @@ -649,18 +603,18 @@ const responseEvidence = ( }); const bytes = Buffer.from(arrayBuffer); const source = bytes.toString('utf-8'); - const body = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ApiResponseSchema))( - source, - ).pipe( + const body = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))(source).pipe( Effect.mapError((cause) => proofError(`${app.id} API response is not valid JSON`, cause)), ); - const { marker } = body; - yield* ensure( - marker.appId === app.id && - marker.build === app.envelope?.identity.buildMarker && - marker.version === app.envelope.identity.releaseVersion, - `${app.id} API response is not tied to its executed release identity: ${source.slice(0, 1000)}`, + const marker = findReleaseMarkers(body).find( + (candidate) => + candidate.appId === app.id && + candidate.build === app.envelope?.identity.buildMarker && + candidate.version === app.envelope.identity.releaseVersion, ); + if (marker === undefined) { + return yield* proofError(`${app.id} API response is not tied to its executed release identity`); + } yield* ensure(response.ok, `${app.id} API response returned HTTP ${response.status}`); return { bodyBase64: bytes.toString('base64'), @@ -671,22 +625,41 @@ const responseEvidence = ( }; }); -const resolveApiSmokeChecks = (app: App, shell: App): readonly SmokeCheck[] => { - const shellChecks = - app.apiPrefix?.startsWith('/') === true - ? shell.jsonSmokeChecks.filter( - (check) => check.route === app.apiPrefix || check.route.startsWith(`${app.apiPrefix}/`), - ) - : []; - const uniqueChecks = new Map(); - for (const check of [...app.jsonSmokeChecks, ...shellChecks]) { - const key = [(check.method ?? 'GET').toUpperCase(), check.route, check.id ?? ''].join('\u0000'); - if (!uniqueChecks.has(key)) { - uniqueChecks.set(key, check); +const encodeSmokeCheckIdentity = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + body: Schema.Json, + expect: Schema.Json, + id: Schema.optional(Schema.String), + method: Schema.String, + route: Schema.String, + }), + ), +); + +const resolveApiSmokeChecks = (app: App, shell: App): Effect.Effect => + Effect.gen(function* resolveApiSmokeChecksEffect() { + const shellChecks = + app.apiPrefix?.startsWith('/') === true + ? shell.jsonSmokeChecks.filter( + (check) => check.route === app.apiPrefix || check.route.startsWith(`${app.apiPrefix}/`), + ) + : []; + const uniqueChecks = new Map(); + for (const check of [...app.jsonSmokeChecks, ...shellChecks]) { + const key = yield* encodeSmokeCheckIdentity({ + body: check.body ?? null, + expect: check.expect ?? null, + id: check.id, + method: (check.method ?? 'GET').toUpperCase(), + route: check.route, + }).pipe(Effect.mapError((cause) => proofError(`${app.id} smoke identity could not be encoded`, cause))); + if (!uniqueChecks.has(key)) { + uniqueChecks.set(key, check); + } } - } - return [...uniqueChecks.values()]; -}; + return [...uniqueChecks.values()]; + }); const runApiCheck = ( app: App, @@ -724,14 +697,10 @@ const runApiCheck = ( const direct = yield* responseEvidence(app, directResponse); const shellResponse = yield* Effect.tryPromise({ catch: (cause) => proofError(`${app.id} Shell API request failed`, cause), - try: async () => - await miniflare.dispatchFetch(`https://${shellWorkerName}.invalid${check.route}`, init), + try: async () => await miniflare.dispatchFetch(`https://${shellWorkerName}.invalid${check.route}`, init), }); const throughShell = yield* responseEvidence(app, shellResponse); - yield* ensure( - direct.sha256 === throughShell.sha256, - `${app.id} direct and service-binding API responses differ`, - ); + yield* ensure(direct.sha256 === throughShell.sha256, `${app.id} direct and service-binding API responses differ`); return { appId: app.id, binding, @@ -755,29 +724,18 @@ const runAppApiProofs = ( executionByAppId: ReadonlyMap, ): Effect.Effect => Effect.gen(function* runAppApiProofsEffect() { - const checks = resolveApiSmokeChecks(app, shell); + const checks = yield* resolveApiSmokeChecks(app, shell); yield* ensure(checks.length > 0, `${app.id} has no real Cloudflare API smoke check`); const appWorkerName = yield* workerName(app); const shellWorkerName = yield* workerName(shell); - const binding = (shell.wrangler.services ?? []).find( - (candidate) => candidate.service === appWorkerName, - ); + const binding = (shell.wrangler.services ?? []).find((candidate) => candidate.service === appWorkerName); const targetEvidence = executionByAppId.get(app.id); if (binding === undefined || targetEvidence === undefined) { return yield* Effect.fail(proofError(`${app.id} service-binding evidence is missing`)); } return yield* Effect.forEach( checks, - (check) => - runApiCheck( - app, - appWorkerName, - binding.binding, - check, - miniflare, - shellWorkerName, - targetEvidence, - ), + (check) => runApiCheck(app, appWorkerName, binding.binding, check, miniflare, shellWorkerName, targetEvidence), { concurrency: 1 }, ); }); @@ -794,18 +752,14 @@ const runApiProofs = ( { concurrency: 1 }, ).pipe(Effect.map((nested) => nested.flat())); -const readRequestBody = ( - request: IncomingMessage, -): Effect.Effect, WorkerdProofError> => +const readRequestBody = (request: IncomingMessage): Effect.Effect, WorkerdProofError> => Effect.callback((resume) => { const chunks: Buffer[] = []; const onData = (chunk: Buffer | string) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }; const onEnd = () => { - resume( - Effect.succeed(chunks.length > 0 ? Option.some(Buffer.concat(chunks)) : Option.none()), - ); + resume(Effect.succeed(chunks.length > 0 ? Option.some(Buffer.concat(chunks)) : Option.none())); }; const onError = (cause: Error) => { resume(Effect.fail(proofError('Could not read incoming proof request', cause))); @@ -862,15 +816,10 @@ const handleTargetRequest = ( ): Effect.Effect => Effect.gen(function* handleTargetRequestEffect() { const body = Option.getOrUndefined(yield* readRequestBody(incoming)); - if ( - incoming.method === 'POST' && - incoming.url === '/_ultramodern-proof/service-binding-fault' - ) { - const command = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(ServiceBindingFaultCommandSchema), - )(body?.toString('utf-8') ?? '{}').pipe( - Effect.mapError((cause) => proofError('Invalid service-binding fault command', cause)), - ); + if (incoming.method === 'POST' && incoming.url === '/_ultramodern-proof/service-binding-fault') { + const command = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceBindingFaultCommandSchema))( + body?.toString('utf-8') ?? '{}', + ).pipe(Effect.mapError((cause) => proofError('Invalid service-binding fault command', cause))); const targetApp = yield* Effect.fromOption( Option.fromNullishOr(apps.find((candidate) => candidate.id === command.appId)), () => proofError(`Unknown service-binding fault target ${command.appId}`), @@ -886,7 +835,9 @@ const handleTargetRequest = ( service, }).pipe(Effect.mapError((cause) => proofError('Could not encode fault response', cause))); yield* Effect.sync(() => { - outgoing.writeHead(200, { [CONTENT_TYPE_HEADER]: APPLICATION_JSON_CONTENT_TYPE }); + outgoing.writeHead(200, { + [CONTENT_TYPE_HEADER]: APPLICATION_JSON_CONTENT_TYPE, + }); outgoing.end(encoded); }); } else { @@ -900,8 +851,7 @@ const handleTargetRequest = ( } const response = yield* Effect.tryPromise({ catch: (cause) => proofError(`${app.id} target dispatch failed`, cause), - try: async () => - await runtime.dispatchFetch(`https://${name}.invalid${incoming.url ?? '/'}`, init), + try: async () => await runtime.dispatchFetch(`https://${name}.invalid${incoming.url ?? '/'}`, init), }); const bytes = yield* Effect.tryPromise({ catch: (cause) => proofError('Could not read Worker response', cause), @@ -917,7 +867,9 @@ const handleTargetRequest = ( Effect.logError(cause).pipe( Effect.andThen( Effect.sync(() => { - outgoing.writeHead(500, { [CONTENT_TYPE_HEADER]: 'text/plain; charset=utf-8' }); + outgoing.writeHead(500, { + [CONTENT_TYPE_HEADER]: 'text/plain; charset=utf-8', + }); outgoing.end('Workerd proof request failed'); }), ), @@ -928,9 +880,7 @@ const handleTargetRequest = ( const createTargetRequestListener = (apps: readonly App[], app: App, runtime: Miniflare, failedServices: Set) => (incoming: IncomingMessage, outgoing: ServerResponse): void => { - adapterRuntime.runCallback( - handleTargetRequest(apps, app, runtime, failedServices, incoming, outgoing), - ); + adapterRuntime.runCallback(handleTargetRequest(apps, app, runtime, failedServices, incoming, outgoing)); }; const startTargetServer = ( @@ -950,11 +900,12 @@ const startTargetServer = ( } const runtime = app.kind === 'vertical' - ? new Miniflare({ log: new Log(LogLevel.ERROR), workers: [configuration] }) + ? new Miniflare({ + log: new Log(LogLevel.ERROR), + workers: [configuration], + }) : miniflare; - const server = http.createServer( - createTargetRequestListener(apps, app, runtime, failedServices), - ); + const server = http.createServer(createTargetRequestListener(apps, app, runtime, failedServices)); yield* listen(server, app.port); return { app, @@ -982,18 +933,14 @@ const startWorkerdTargetServers = ( Effect.gen(function* startWorkerdTargetServersEffect() { const started = yield* Effect.forEach( apps, - (app, index) => - startTargetServer(apps, app, workerConfigurations[index], failedServices, miniflare), + (app, index) => startTargetServer(apps, app, workerConfigurations[index], failedServices, miniflare), { concurrency: 1 }, ); - const targetUrls = Object.fromEntries( - started.map(({ app }) => [app.id, `http://127.0.0.1:${app.port}`]), - ); + const targetUrls = Object.fromEntries(started.map(({ app }) => [app.id, `http://127.0.0.1:${app.port}`])); const runtimeDisposals = started.map(({ runtime }) => disposeTargetRuntime(runtime)); - const stop = Effect.all( - [...started.map(({ server }) => closeServer(server)), ...runtimeDisposals], - { concurrency: 'unbounded' }, - ).pipe(Effect.asVoid); + const stop = Effect.all([...started.map(({ server }) => closeServer(server)), ...runtimeDisposals], { + concurrency: 'unbounded', + }).pipe(Effect.asVoid); return { stop, targetUrls }; }); @@ -1002,9 +949,7 @@ const readAttribute = (tag: string, name: string) => { const match = new RegExp(`\\s${escapedName}=(?:"([^"]*)"|'([^']*)')`, 'u').exec(tag); return match?.[1] ?? match?.[2]; }; -const collectDistributedBoundaries = ( - html: string, -): Effect.Effect => +const collectDistributedBoundaries = (html: string): Effect.Effect => Effect.forEach( html.matchAll(/<[a-z][^>]*data-modern-distributed-ssr-boundary=(?:"[^"]+"|'[^']+')[^>]*>/giu), (match) => @@ -1028,9 +973,7 @@ const collectDistributedBoundaries = ( ); const collectStylesheetHrefs = (html: string): readonly string[] => [...html.matchAll(/]*>/giu)] - .filter( - (match) => readAttribute(match[0], 'rel')?.split(/\s+/u).includes('stylesheet') === true, - ) + .filter((match) => readAttribute(match[0], 'rel')?.split(/\s+/u).includes('stylesheet') === true) .map((match) => readAttribute(match[0], 'href')) .filter((href): href is string => href !== undefined); const isDistributedSsrFragmentRequest = (request: MiniflareRequest) => @@ -1070,9 +1013,7 @@ const decodeDistributedSsrFragmentRequest = ( (header) => readRequiredFragmentHeader(request, header), { concurrency: 5 }, ); - const headers = new Map( - DISTRIBUTED_SSR_REQUIRED_HEADERS.map((header, index) => [header, values[index]]), - ); + const headers = new Map(DISTRIBUTED_SSR_REQUIRED_HEADERS.map((header, index) => [header, values[index]])); const propsSource = headers.get('x-modern-distributed-ssr-props'); const sourceUrl = headers.get('x-modern-distributed-ssr-source-url'); const boundaryId = headers.get('x-modern-distributed-ssr-boundary-id'); @@ -1187,13 +1128,7 @@ const createServiceBindings = ( Object.fromEntries( (caller.wrangler.services ?? []).map((service) => [ service.binding, - createServiceBindingHandler( - caller, - apiBindingRequests, - failedServices, - fragmentBindingRequests, - service, - ), + createServiceBindingHandler(caller, apiBindingRequests, failedServices, fragmentBindingRequests, service), ]), ); @@ -1235,7 +1170,9 @@ const writeReport = ( schemaVersion: 3, }; const encoded = yield* encodeJson(report); - yield* fileSystem.makeDirectory(path.dirname(reportPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(reportPath), { + recursive: true, + }); yield* fileSystem.writeFileString(reportPath, `${encoded}\n`); }); @@ -1243,7 +1180,9 @@ const createOutboundService = (app: App, outboundRequests: OutboundRequest[]): ServiceBindingHandler => (request) => { outboundRequests.push({ callerId: app.id, url: new URL(request.url).href }); - return new MiniflareResponse('External network disabled by SSR proof', { status: 502 }); + return new MiniflareResponse('External network disabled by SSR proof', { + status: 502, + }); }; const proveBoundary = ( @@ -1257,10 +1196,7 @@ const proveBoundary = ( ): Effect.Effect => Effect.gen(function* proveBoundaryEffect() { renderedRemoteIds.add(boundary.remote); - yield* ensure( - boundary.status === 'ready', - `${shell.id} did not mark ${boundary.key} as ready for ${route}`, - ); + yield* ensure(boundary.status === 'ready', `${shell.id} did not mark ${boundary.key} as ready for ${route}`); yield* ensure( boundary.buildMarker !== undefined && boundary.buildMarker.length > 0, `${shell.id} ${boundary.key} is missing immutable build provenance`, @@ -1269,9 +1205,8 @@ const proveBoundary = ( /^[a-f\d]{64}$/u.test(boundary.digest ?? ''), `${shell.id} ${boundary.key} is missing a verified SHA-256 digest`, ); - const remote = yield* Effect.fromOption( - Option.fromNullishOr(apps.find((app) => app.id === boundary.remote)), - () => proofError(`${shell.id} rendered unknown remote ${boundary.remote}`), + const remote = yield* Effect.fromOption(Option.fromNullishOr(apps.find((app) => app.id === boundary.remote)), () => + proofError(`${shell.id} rendered unknown remote ${boundary.remote}`), ); const remoteWorkerName = yield* workerName(remote); const requests = routeFragmentBindingRequests.filter( @@ -1282,8 +1217,7 @@ const proveBoundary = ( ); const renderedCount = boundaries.filter((candidate) => candidate.key === boundary.key).length; yield* ensure( - requests.length === renderedCount && - requests.every((request) => request.pathname.includes('/_mf/fragment/')), + requests.length === renderedCount && requests.every((request) => request.pathname.includes('/_mf/fragment/')), `${shell.id} must compose each ${boundary.key} occurrence through its remote service binding`, ); }); @@ -1345,15 +1279,7 @@ const proveShellRoute = ( yield* Effect.forEach( boundaries, (boundary) => - proveBoundary( - apps, - boundaries, - boundary, - state.renderedRemoteIds, - route, - routeFragmentBindingRequests, - shell, - ), + proveBoundary(apps, boundaries, boundary, state.renderedRemoteIds, route, routeFragmentBindingRequests, shell), { concurrency: 1 }, ); const stylesheetHrefs = collectStylesheetHrefs(html); @@ -1445,10 +1371,7 @@ const runShellProof = ( const remotes = shell.verticalRefs .map((ref) => apps.find((app) => app.id === ref)) .filter((remote): remote is App => remote !== undefined); - yield* ensure( - remotes.length === shell.verticalRefs.length, - `${shell.id} references a missing MicroVertical`, - ); + yield* ensure(remotes.length === shell.verticalRefs.length, `${shell.id} references a missing MicroVertical`); yield* ensure(remotes.length > 0, `${shell.id} has no MicroVerticals to prove`); const failedServices = new Set(); const state: ShellProofState = { @@ -1466,12 +1389,7 @@ const runShellProof = ( app, process.cwd(), createOutboundService(app, state.outboundRequests), - createServiceBindings( - app, - state.apiBindingRequests, - failedServices, - state.fragmentBindingRequests, - ), + createServiceBindings(app, state.apiBindingRequests, failedServices, state.fragmentBindingRequests), ), { concurrency: 1 }, ); @@ -1497,15 +1415,10 @@ const runShellProof = ( const apiProofs = yield* runApiProofs(apps, miniflare, shell, executionByAppId); if (keepWorkerd) { yield* writeReport(reportPath, apiProofs, executions, state.proofs, state.remoteProofs); - const targetServers = yield* startWorkerdTargetServers( - apps, - miniflare, - failedServices, - workers, + const targetServers = yield* startWorkerdTargetServers(apps, miniflare, failedServices, workers); + const encodedTargetUrls = yield* Schema.encodeEffect(TargetUrlsSchema)(targetServers.targetUrls).pipe( + Effect.mapError((cause) => proofError('Could not encode Workerd target URLs', cause)), ); - const encodedTargetUrls = yield* Schema.encodeEffect(TargetUrlsSchema)( - targetServers.targetUrls, - ).pipe(Effect.mapError((cause) => proofError('Could not encode Workerd target URLs', cause))); yield* Effect.log(`WORKERD_TARGET_URLS=${encodedTargetUrls}`); yield* Effect.log(`WORKERD_URL=${targetServers.targetUrls[shell.id] ?? ''}`); yield* waitForTerminationSignal.pipe(Effect.ensuring(targetServers.stop)); @@ -1520,32 +1433,23 @@ const runShellProof = ( const main = Effect.gen(function* mainEffect() { const workspaceRoot = process.cwd(); - const reportPath = path.join( - workspaceRoot, - '.codex/reports/cloudflare-workerd-ssr/composition-proof.json', - ); - const keepWorkerd = yield* Config.boolean('ULTRAMODERN_KEEP_WORKERD').pipe( - Config.withDefault(false), - ); + const reportPath = path.join(workspaceRoot, '.codex/reports/cloudflare-workerd-ssr/composition-proof.json'); + const keepWorkerd = yield* Config.boolean('ULTRAMODERN_KEEP_WORKERD').pipe(Config.withDefault(false)); const apps = yield* loadApps(workspaceRoot); const shells = apps.filter((app) => app.kind === 'shell'); yield* ensure(shells.length > 0, 'Workerd SSR proof requires at least one shell'); if (keepWorkerd) { yield* ensure(shells.length === 1, 'Browser workerd proof requires exactly one shell'); } - const results = yield* Effect.forEach( - shells, - (shell) => runShellProof(apps, shell, keepWorkerd, reportPath), - { concurrency: 1 }, - ); + const results = yield* Effect.forEach(shells, (shell) => runShellProof(apps, shell, keepWorkerd, reportPath), { + concurrency: 1, + }); const apiProofs = results.flatMap((result) => result.apiProofs); const executions = results.flatMap((result) => result.executions); const proofs = results.flatMap((result) => result.proofs); const remoteProofs = results.flatMap((result) => result.remoteProofs); yield* writeReport(reportPath, apiProofs, executions, proofs, remoteProofs); - yield* Effect.log( - `Workerd SSR composition proof passed for ${shells.length} shell(s): ${reportPath}`, - ); + yield* Effect.log(`Workerd SSR composition proof passed for ${shells.length} shell(s): ${reportPath}`); }).pipe(Effect.scoped); const loggedMain = main.pipe(Effect.tapCause((cause) => Effect.logError(cause))); diff --git a/app/scripts/provision-current-action-authorization.mts b/app/scripts/provision-current-action-authorization.mts index dda9a6a07..d988a3e74 100644 --- a/app/scripts/provision-current-action-authorization.mts +++ b/app/scripts/provision-current-action-authorization.mts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { pathToFileURL } from 'node:url'; + import { v1 } from '@authzed/authzed-node'; import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { @@ -16,6 +17,8 @@ import { Schema, } from 'effect'; import { Command } from 'effect/unstable/cli'; + +import { coreActionCatalog } from '../packages/core-runtime/src/index.ts'; import { ActionAuthorizationProvisioningError, provisionActionAuthorization, @@ -26,13 +29,12 @@ import type { ActionAuthorizationProvisioningClient, ActionAuthorizationProvisioningResult, } from '../packages/core-runtime/src/install/action-authorization-provisioning.ts'; -import { coreActionCatalog } from '../packages/core-runtime/src/index.ts'; +import { STAGE_CONTEXTS } from '../packages/core-runtime/src/install/stage-context-bootstrap.ts'; import { spiceDbClientSecurity } from '../packages/core-runtime/src/permissions/client.ts'; import { loadSpiceDbConfig } from '../packages/core-runtime/src/permissions/config.ts'; import type { SpiceDbConfigValue } from '../packages/core-runtime/src/permissions/config.ts'; -import { STAGE_CONTEXTS } from '../packages/core-runtime/src/install/stage-context-bootstrap.ts'; -import { LOCAL_DEVELOPMENT_CONTEXT } from './initialize-local-development.mts'; import { deriveOntosModuleDeploymentContract } from './generate-ontos-module-contract.mts'; +import { LOCAL_DEVELOPMENT_CONTEXT } from './initialize-local-development.mts'; const TopologySchema = Schema.Struct({ verticals: Schema.Array( @@ -88,8 +90,7 @@ export const selectActionAuthorizationProvisioningTarget = ( configuration: SpiceDbConfigValue, ): Effect.Effect => { if ( - (configuration.deploymentEnvironment === undefined || - configuration.deploymentEnvironment === 'development') && + (configuration.deploymentEnvironment === undefined || configuration.deploymentEnvironment === 'development') && isLoopbackSpiceDb(configuration) ) { return Effect.succeed({ @@ -127,10 +128,7 @@ export const selectActionAuthorizationProvisioningTarget = ( }; const discoveryFailure = (): ActionAuthorizationProvisioningError => - failure( - 'action_authorization_discovery_failed', - 'The complete current Action set could not be derived safely', - ); + failure('action_authorization_discovery_failed', 'The complete current Action set could not be derived safely'); const decodeRepositoryInventory = (workspaceRoot: string) => Effect.gen(function* decodeRepositoryInventoryEffect() { @@ -138,10 +136,7 @@ const decodeRepositoryInventory = (workspaceRoot: string) => const path = yield* Path.Path; const [topologySource, ownershipSource] = yield* Effect.all( [ - fileSystem.readFileString( - path.join(workspaceRoot, 'topology/reference-topology.json'), - 'utf-8', - ), + fileSystem.readFileString(path.join(workspaceRoot, 'topology/reference-topology.json'), 'utf-8'), fileSystem.readFileString(path.join(workspaceRoot, 'topology/ownership.json'), 'utf-8'), ], { concurrency: 'unbounded' }, @@ -171,8 +166,7 @@ export const discoverCurrentActions = ( } const ownerKeys = new Set( ownership.owners.map( - ({ id, package: packageName, path: ownerPath }) => - `${id}\u0000${packageName}\u0000${ownerPath}`, + ({ id, package: packageName, path: ownerPath }) => `${id}\u0000${packageName}\u0000${ownerPath}`, ), ); const verticals = EffectArray.sortWith(topology.verticals, ({ id }) => id, Order.String); @@ -199,17 +193,17 @@ export const discoverCurrentActions = ( const collectVerticalActions = Effect.gen(function* collectVerticalActionsEffect() { const verticalActions: ActionAuthorizationProvisioningAction[] = []; for (const { contract, id } of contracts) { - if ( - contract.deployment.appId !== id || - contract.manifest.publicSurface.actions.length === 0 - ) { + if (contract.deployment.appId !== id || contract.manifest.publicSurface.actions.length === 0) { return yield* discoveryFailure(); } for (const { actionKey, entrypoint } of contract.manifest.publicSurface.actions) { if (entrypoint?.authorization.kind !== 'action_execution') { return yield* discoveryFailure(); } - verticalActions.push({ actionKey, provisioning: entrypoint.authorization.provisioning }); + verticalActions.push({ + actionKey, + provisioning: entrypoint.authorization.provisioning, + }); } } @@ -221,7 +215,10 @@ export const discoverCurrentActions = ( if (entrypoint.authorization.kind !== 'action_execution') { return yield* discoveryFailure(); } - coreActions.push({ actionKey, provisioning: entrypoint.authorization.provisioning }); + coreActions.push({ + actionKey, + provisioning: entrypoint.authorization.provisioning, + }); } const actions = EffectArray.sortWith( [...coreActions, ...verticalActions], @@ -238,11 +235,7 @@ export const discoverCurrentActions = ( export const discoverCurrentActionKeys = ( workspaceRoot: string, deriveContract: DeriveContract = deriveOntosModuleDeploymentContract, -): Effect.Effect< - readonly string[], - ActionAuthorizationProvisioningError, - NodeServices.NodeServices -> => +): Effect.Effect => discoverCurrentActions(workspaceRoot, deriveContract).pipe( Effect.map((actions) => actions.map(({ actionKey }) => actionKey)), ); @@ -264,21 +257,13 @@ const callProvisioningClient = (operation: () => PromiseLike) => duration: Duration.seconds(30), orElse: () => Effect.fail( - provisioningServiceFailure( - new Cause.TimeoutError('SpiceDB authorization provisioning request timed out'), - ), + provisioningServiceFailure(new Cause.TimeoutError('SpiceDB authorization provisioning request timed out')), ), }), ); -const createProvisioningClient = ( - configuration: SpiceDbConfigValue, -): CloseableProvisioningClient => { - const client = v1.NewClient( - configuration.preSharedKey, - configuration.endpoint, - spiceDbClientSecurity(configuration), - ); +const createProvisioningClient = (configuration: SpiceDbConfigValue): CloseableProvisioningClient => { + const client = v1.NewClient(configuration.preSharedKey, configuration.endpoint, spiceDbClientSecurity(configuration)); return { checkPermission: (request) => callProvisioningClient(client.promises.checkPermission.bind(client.promises, request)).pipe( @@ -287,8 +272,7 @@ const createProvisioningClient = ( close: () => client.close(), writeRelationships: (request) => callProvisioningClient(client.promises.writeRelationships.bind(client.promises, request)), - writeSchema: (request) => - callProvisioningClient(client.promises.writeSchema.bind(client.promises, request)), + writeSchema: (request) => callProvisioningClient(client.promises.writeSchema.bind(client.promises, request)), }; }; @@ -309,7 +293,9 @@ const runCurrentActionAuthorizationProvisioningWithServices = ( workspaceRoot: string, commandArguments: readonly string[] = [], ): Effect.Effect< - ActionAuthorizationProvisioningResult & { readonly environment: 'development' | 'stage' }, + ActionAuthorizationProvisioningResult & { + readonly environment: 'development' | 'stage'; + }, ActionAuthorizationProvisioningError, NodeServices.NodeServices > => @@ -322,10 +308,7 @@ const runCurrentActionAuthorizationProvisioningWithServices = ( } const configuration = yield* loadSpiceDbConfig().pipe( Effect.mapError(() => - failure( - 'action_authorization_configuration_invalid', - 'The SpiceDB provisioning configuration is invalid', - ), + failure('action_authorization_configuration_invalid', 'The SpiceDB provisioning configuration is invalid'), ), ); const target = yield* selectActionAuthorizationProvisioningTarget(configuration); @@ -342,13 +325,15 @@ export function runCurrentActionAuthorizationProvisioning( workspaceRoot: string, commandArguments: readonly [string, ...string[]], ): Effect.Effect< - ActionAuthorizationProvisioningResult & { readonly environment: 'development' | 'stage' }, + ActionAuthorizationProvisioningResult & { + readonly environment: 'development' | 'stage'; + }, ActionAuthorizationProvisioningError >; -export function runCurrentActionAuthorizationProvisioning( - workspaceRoot: string, -): Effect.Effect< - ActionAuthorizationProvisioningResult & { readonly environment: 'development' | 'stage' }, +export function runCurrentActionAuthorizationProvisioning(workspaceRoot: string): Effect.Effect< + ActionAuthorizationProvisioningResult & { + readonly environment: 'development' | 'stage'; + }, ActionAuthorizationProvisioningError, NodeServices.NodeServices >; @@ -356,7 +341,9 @@ export function runCurrentActionAuthorizationProvisioning( workspaceRoot: string, commandArguments: readonly string[] = [], ): Effect.Effect< - ActionAuthorizationProvisioningResult & { readonly environment: 'development' | 'stage' }, + ActionAuthorizationProvisioningResult & { + readonly environment: 'development' | 'stage'; + }, ActionAuthorizationProvisioningError, NodeServices.NodeServices > { @@ -373,9 +360,7 @@ const command = Command.make('authorization-provision-current-actions', {}, () = const path = yield* Path.Path; const workspaceRoot = path.resolve(import.meta.dirname, '..'); const result = yield* runCurrentActionAuthorizationProvisioningWithServices(workspaceRoot).pipe( - Effect.tapError((cause) => - Console.error(formatActionAuthorizationProvisioningFailure(cause)), - ), + Effect.tapError((cause) => Console.error(formatActionAuthorizationProvisioningFailure(cause))), ); yield* Console.log( `Provisioned ${result.grantCount} explicit Action grants for ${result.actionCount} Actions across ${result.tenantCount} ${result.environment} Tenant(s).`, diff --git a/app/scripts/published-outbox-contracts.mts b/app/scripts/published-outbox-contracts.mts index 9303cddd7..34a87653d 100644 --- a/app/scripts/published-outbox-contracts.mts +++ b/app/scripts/published-outbox-contracts.mts @@ -53,12 +53,12 @@ export const resolvePublishedContractModuleId = (input: { readonly expectedAppId: string; readonly manifestSource: string; }): string => { - const moduleIds = [ - ...input.manifestSource.matchAll(/^\/\/ @ontos-module-id (?[^\s]+)$/gmu), - ].map((match) => match.groups?.moduleId); - const appIds = [ - ...input.manifestSource.matchAll(/^\/\/ @ontos-deployment-app-id (?[^\s]+)$/gmu), - ].map((match) => match.groups?.appId); + const moduleIds = [...input.manifestSource.matchAll(/^\/\/ @ontos-module-id (?[^\s]+)$/gmu)].map( + (match) => match.groups?.moduleId, + ); + const appIds = [...input.manifestSource.matchAll(/^\/\/ @ontos-deployment-app-id (?[^\s]+)$/gmu)].map( + (match) => match.groups?.appId, + ); const [moduleId] = moduleIds; assertCondition( input.manifestSource.startsWith('// @generated by OntOS Codesmith Module Contract v1\n') && @@ -66,12 +66,7 @@ export const resolvePublishedContractModuleId = (input: { moduleId !== undefined && appIds.length === 1 && appIds[0] === input.expectedAppId && - packageMatchesManifest( - input.dependencyPackageJson, - input.dependencyPackageName, - input.expectedAppId, - moduleId, - ), + packageMatchesManifest(input.dependencyPackageJson, input.dependencyPackageName, input.expectedAppId, moduleId), `${input.dependencyPackageName} package and generated manifest ownership disagree`, ); return moduleId; @@ -87,19 +82,13 @@ export const assertPublishedOutboxContractSource = (input: { input.source.includes(`// @ontos-outbox-producer ${input.moduleId}\n`) && input.source.includes('// @ontos-outbox-topic ') && input.source.includes('export const OutboxPayloadSchema =') && - input.source.includes( - `export const outboxProducerModuleKey = '${input.moduleId}' as const;`, - ) && - !/(?:src\/actions|create[A-Za-z0-9]+Message|handler|repository|transport)/u.test( - input.source, - ), + input.source.includes(`export const outboxProducerModuleKey = '${input.moduleId}' as const;`) && + !/(?:src\/actions|create[A-Za-z0-9]+Message|handler|repository|transport)/u.test(input.source), `${input.specifier} must remain a generated schema-only Outbox contract`, ); }; -export const publishedOutboxContractExports = ( - packageJson: PublishedOutboxPackage, -): readonly string[] => +export const publishedOutboxContractExports = (packageJson: PublishedOutboxPackage): readonly string[] => sortLexically( Object.entries(packageJson.exports ?? {}) .filter(([exportKey, target]) => { @@ -109,9 +98,7 @@ export const publishedOutboxContractExports = ( .map(([exportKey]) => exportKey), ); -export const publishedResourceRefContractExports = ( - packageJson: PublishedOutboxPackage, -): readonly string[] => +export const publishedResourceRefContractExports = (packageJson: PublishedOutboxPackage): readonly string[] => sortLexically( Object.entries(packageJson.exports ?? {}) .filter(([exportKey, target]) => { @@ -121,9 +108,7 @@ export const publishedResourceRefContractExports = ( .map(([exportKey]) => exportKey), ); -const publishedEffectClientContractExports = ( - packageJson: PublishedOutboxPackage, -): readonly string[] => { +const publishedEffectClientContractExports = (packageJson: PublishedOutboxPackage): readonly string[] => { const appId = packageJson.modernjs?.appId; return appId !== undefined && packageJson.modernjs?.apiRuntime === 'effect' && @@ -135,9 +120,7 @@ const publishedEffectClientContractExports = ( const importedModuleSpecifiers = (source: string): readonly string[] => [ - ...source.matchAll( - /\b(?:import|export)\s+(?:type\s+)?[^;'"`]+?\s+from\s*['"](?[^'"]+)['"]/gu, - ), + ...source.matchAll(/\b(?:import|export)\s+(?:type\s+)?[^;'"`]+?\s+from\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\bimport\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\bimport\s*\(\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\brequire\s*\(\s*['"](?[^'"]+)['"]/gu), @@ -153,9 +136,7 @@ const hasResourceRefDeclarations = (input: { const resourceType = `${input.moduleId}.${input.slug}`; return ( /export const [A-Z][A-Za-z0-9]*RefSchema = Schema\.Struct\(/u.test(input.source) && - /export type [A-Z][A-Za-z0-9]*Ref = typeof [A-Z][A-Za-z0-9]*RefSchema\.Type;/u.test( - input.source, - ) && + /export type [A-Z][A-Za-z0-9]*Ref = typeof [A-Z][A-Za-z0-9]*RefSchema\.Type;/u.test(input.source) && input.source.includes(`moduleId: Schema.Literal('${input.moduleId}')`) && input.source.includes(`resourceType: Schema.Literal('${resourceType}')`) && /export const [a-z][A-Za-z0-9]*ResourceDescriptor = \{/u.test(input.source) && @@ -178,9 +159,7 @@ const isGeneratedSchemaOnlyResourceRef = (input: { /^import \{ Schema \} from 'effect';$/mu.test(input.source) && imports.length === 2 && imports.every((specifier) => specifier === '@app/core-runtime' || specifier === 'effect') && - !/\b(?:import\s*\(|require\s*\(|async\b|function\b|class\b|fetch\s*\(|new\s+|process\.)/u.test( - input.source, - ) && + !/\b(?:import\s*\(|require\s*\(|async\b|function\b|class\b|fetch\s*\(|new\s+|process\.)/u.test(input.source) && !input.source.includes('=>') && hasResourceRefDeclarations(input) ); @@ -206,7 +185,10 @@ const isGeneratedEffectClientLeaf = (source: string, appId: string): boolean => const hasValidActionGateway = ( imports: readonly string[], - input: { readonly appId: string; readonly readOwnerSource: (path: string) => string }, + input: { + readonly appId: string; + readonly readOwnerSource: (path: string) => string; + }, ): boolean => { if (imports.includes('./action-gateway.ts')) { const gateway = input.readOwnerSource('./src/api/action-gateway.ts'); @@ -247,9 +229,7 @@ const isGeneratedPublicEffectClient = (input: { return false; } - const localClients = [ - ...new Set(imports.filter((specifier) => /^\.\/.+-client\.ts$/u.test(specifier))), - ]; + const localClients = [...new Set(imports.filter((specifier) => /^\.\/.+-client\.ts$/u.test(specifier)))]; if (localClients.length === 0) { return false; } @@ -326,13 +306,9 @@ const assertEffectClientUsage = ( } }; -export const assertPublishedCrossMicroVerticalContractUsage = ( - input: PublishedContractUsageInput, -): void => { +export const assertPublishedCrossMicroVerticalContractUsage = (input: PublishedContractUsageInput): void => { const dependencySpecifiers = input.moduleSpecifiers.filter( - (specifier) => - specifier === input.dependencyPackageName || - specifier.startsWith(`${input.dependencyPackageName}/`), + (specifier) => specifier === input.dependencyPackageName || specifier.startsWith(`${input.dependencyPackageName}/`), ); assertCondition( dependencySpecifiers.length > 0, @@ -342,10 +318,7 @@ export const assertPublishedCrossMicroVerticalContractUsage = ( input.dependencyDeclared, `consumer must declare ${input.dependencyPackageName} as a workspace dependency`, ); - assertCondition( - input.projectReferenceDeclared, - `consumer must project-reference ${input.dependencyPackageName}`, - ); + assertCondition(input.projectReferenceDeclared, `consumer must project-reference ${input.dependencyPackageName}`); const outboxExports = publishedOutboxContractExports(input.dependencyPackageJson); const resourceExports = publishedResourceRefContractExports(input.dependencyPackageJson); @@ -355,9 +328,7 @@ export const assertPublishedCrossMicroVerticalContractUsage = ( (exportKey) => `${input.dependencyPackageName}${exportKey.slice(1)}`, ), ); - const forbiddenSpecifier = dependencySpecifiers.find( - (specifier) => !allowedSpecifiers.has(specifier), - ); + const forbiddenSpecifier = dependencySpecifiers.find((specifier) => !allowedSpecifiers.has(specifier)); assertCondition( forbiddenSpecifier === undefined, `${forbiddenSpecifier} is not a published schema-only contract subpath`, @@ -378,9 +349,7 @@ export const assertPublishedOutboxDependencyUsage = (input: { `${input.dependencyPackageName} is not a published schema-only Outbox contract dependency`, ); const dependencySpecifiers = input.moduleSpecifiers.filter( - (specifier) => - specifier === input.dependencyPackageName || - specifier.startsWith(`${input.dependencyPackageName}/`), + (specifier) => specifier === input.dependencyPackageName || specifier.startsWith(`${input.dependencyPackageName}/`), ); const allowedSpecifiers = new Set( contractExports.map((exportKey) => `${input.dependencyPackageName}${exportKey.slice(1)}`), @@ -389,9 +358,7 @@ export const assertPublishedOutboxDependencyUsage = (input: { dependencySpecifiers.length > 0, `${input.dependencyPackageName} is an unused cross-MicroVertical dependency`, ); - const forbiddenSpecifier = dependencySpecifiers.find( - (specifier) => !allowedSpecifiers.has(specifier), - ); + const forbiddenSpecifier = dependencySpecifiers.find((specifier) => !allowedSpecifiers.has(specifier)); assertCondition( forbiddenSpecifier === undefined, `${forbiddenSpecifier} is not a published schema-only Outbox contract subpath`, diff --git a/app/scripts/quality-audit-gate.mts b/app/scripts/quality-audit-gate.mts index 66ae600a6..31bd5086f 100644 --- a/app/scripts/quality-audit-gate.mts +++ b/app/scripts/quality-audit-gate.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Console, Data, Effect, FileSystem, Match, Path, Schema } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; + import { runQualityCli } from './quality-cli-lifecycle.mts'; const FALLOW_FILES = 'fallow-files'; @@ -29,13 +30,21 @@ const ResultSchema = Schema.Union([ }), name: Schema.Literal('knip'), }), - Schema.Struct({ ...primary, coverage: tokenCoverage, name: Schema.Literal('jscpd') }), + Schema.Struct({ + ...primary, + coverage: tokenCoverage, + name: Schema.Literal('jscpd'), + }), Schema.Struct({ ...primary, coverage: Schema.Struct({ discoveredFiles: PositiveCount }), name: Schema.Literal(FALLOW_FILES), }), - Schema.Struct({ ...primary, coverage: tokenCoverage, name: Schema.Literal('fallow-clones') }), + Schema.Struct({ + ...primary, + coverage: tokenCoverage, + name: Schema.Literal('fallow-clones'), + }), Schema.Struct({ ...base, advisory: Schema.Literal(true), @@ -55,7 +64,10 @@ const ResultSchema = Schema.Union([ }), ]); const Summary = Schema.fromJsonString( - Schema.Struct({ results: Schema.Array(ResultSchema), status: Schema.Literal('reported') }), + Schema.Struct({ + results: Schema.Array(ResultSchema), + status: Schema.Literal('reported'), + }), ); class QualityAuditGateError extends Data.TaggedError('QualityAuditGateError')<{ @@ -107,17 +119,14 @@ export const validateQualityAuditSummary = Effect.fn('qualityAuditGate.validate' const summary = yield* Schema.decodeEffect(Summary)(source).pipe( Effect.mapError( (cause) => - new QualityAuditGateError({ message: `Malformed audit summary: ${String(cause)}` }), + new QualityAuditGateError({ + message: `Malformed audit summary: ${String(cause)}`, + }), ), ); // The schema admits exactly six names; cardinality plus uniqueness requires all of them. - if ( - summary.results.length !== 6 || - new Set(summary.results.map(({ name }) => name)).size !== 6 - ) { - return yield* reject( - 'Audit gate requires all six unique analyzer results; run the full audit', - ); + if (summary.results.length !== 6 || new Set(summary.results.map(({ name }) => name)).size !== 6) { + return yield* reject('Audit gate requires all six unique analyzer results; run the full audit'); } for (const result of summary.results) { if (!consistent(result)) { @@ -141,9 +150,7 @@ export const validateQualityAuditSummary = Effect.fn('qualityAuditGate.validate' const cli = Command.make( 'quality-audit-gate', { - summary: Flag.string('summary').pipe( - Flag.withDefault('.codex/reports/quality-audit/summary.json'), - ), + summary: Flag.string('summary').pipe(Flag.withDefault('.codex/reports/quality-audit/summary.json')), }, ({ summary }) => Effect.gen(function* qualityAuditGateCommand() { diff --git a/app/scripts/quality-audit.mts b/app/scripts/quality-audit.mts index 162227c3f..a30db79e2 100644 --- a/app/scripts/quality-audit.mts +++ b/app/scripts/quality-audit.mts @@ -1,32 +1,28 @@ #!/usr/bin/env node import nodePath from 'node:path'; -import { - Array as EffectArray, - Clock, - Console, - Effect, - FileSystem, - Order, - Path, - Result, - Schema, - Stream, -} from 'effect'; + +import { Array as EffectArray, Clock, Console, Effect, FileSystem, Order, Path, Result, Schema, Stream } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; -import { runQualityCli } from './quality-cli-lifecycle.mts'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import { - buildKnipModel, - KnipConfigSchema, - KnipModelEvidenceSchema, -} from '../quality-audit/knip-model.mts'; + +import { importCloneEvidence } from '../quality-audit/import-clone-evidence.mts'; +import { buildKnipModel, KnipConfigSchema, KnipModelEvidenceSchema } from '../quality-audit/knip-model.mts'; import type { KnipModelEvidence } from '../quality-audit/knip-model.mts'; +import { runQualityCli } from './quality-cli-lifecycle.mts'; const FALLOW_FILES = 'fallow-files'; const FALLOW_HEALTH = 'fallow-health'; const FALLOW_SIMILARITY = 'fallow-similarity'; -const TOOL_VERSIONS = { fallow: '3.22.0', jscpd: '5.1.2', knip: '6.34.0' } as const; -const TOOL_BINS = { fallow: 'bin/fallow', jscpd: 'run-jscpd.js', knip: 'bin/knip.js' } as const; +const TOOL_VERSIONS = { + fallow: '3.22.0', + jscpd: '5.1.2', + knip: '6.34.0', +} as const; +const TOOL_BINS = { + fallow: 'bin/fallow', + jscpd: 'run-jscpd.js', + knip: 'bin/knip.js', +} as const; const COMPLEXITY_LIMITS = { cognitive: 15, cyclomatic: 10 } as const; const SOURCE_GROUPS = { fixtures: 'fixtures', @@ -91,7 +87,11 @@ const FallowClonesSchema = Schema.Struct({ Schema.Struct({ fingerprint: Schema.String, instances: Schema.Array( - Schema.Struct({ end_line: CountSchema, file: Schema.String, start_line: CountSchema }), + Schema.Struct({ + end_line: CountSchema, + file: Schema.String, + start_line: CountSchema, + }), ).check(Schema.isMinLength(2)), line_count: CountSchema, token_count: CountSchema, @@ -153,6 +153,7 @@ interface AuditResult { readonly analyzedFiles?: number; readonly analyzedFunctions?: number; readonly controlFlowFindings?: number; + readonly declarationOnlyClones?: number; readonly discoveredFiles?: number; readonly findingCounts?: Readonly>; readonly modeledUsages?: number; @@ -185,20 +186,13 @@ const errorResult = (name: string, directory: string, diagnostic: string): Audit const failure = (reason: string): QualityAuditError => new QualityAuditError({ reason }); const jsonCodec = Schema.fromJsonString(Schema.Unknown, { space: 2 }); -const writeJson = Effect.fn('qualityAudit.writeJson')(function* writeJsonEffect( - file: string, - value: A, -) { +const writeJson = Effect.fn('qualityAudit.writeJson')(function* writeJsonEffect(file: string, value: A) { const fs = yield* FileSystem.FileSystem; const source = yield* Schema.encodeEffect(jsonCodec)(value); yield* fs.writeFileString(file, `${source}\n`); }); -const decodeReport = ( - schema: S, - source: string, - subject = 'analyzer report', -) => +const decodeReport = (schema: S, source: string, subject = 'analyzer report') => Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(source).pipe( Effect.mapError((issue) => failure(`Malformed ${subject}: ${String(issue)}`)), ); @@ -219,19 +213,13 @@ const sourceGroup = (file: string) => { return SOURCE_GROUPS.runtime; }; -const validateKnip = Effect.fn('qualityAudit.validateKnip')(function* validateKnipEffect( - name: string, - source: string, -) { +const validateKnip = Effect.fn('qualityAudit.validateKnip')(function* validateKnipEffect(name: string, source: string) { const records = source.trim().split('\n'); if (records.length !== 2) { return yield* failure('Knip must emit findings and coverage records'); } const [, coverage] = yield* Effect.all( - [ - decodeReport(KnipSchema, records[0] ?? ''), - decodeReport(KnipCoverageSchema, records[1] ?? ''), - ], + [decodeReport(KnipSchema, records[0] ?? ''), decodeReport(KnipCoverageSchema, records[1] ?? '')], { concurrency: 'unbounded' }, ); yield* nonempty(coverage.coverage.processed, name); @@ -264,11 +252,7 @@ const modeledUsage = ( return false; } if (entry.kind === 'resolver') { - return ( - entry.line === issue.line && - entry.column === issue.col && - entry.owningManifest !== undefined - ); + return entry.line === issue.line && entry.column === issue.col && entry.owningManifest !== undefined; } return entry.kind === 'compiler-option' && issue.line === undefined && issue.col === undefined; }); @@ -287,9 +271,7 @@ const calibrateKnip = Effect.fn('qualityAudit.calibrateKnip')(function* calibrat const modeled = report.issues.flatMap((record) => record.unlisted.flatMap((issue) => { const consumer = modeledUsage(record.file, issue, evidence); - return consumer === undefined - ? [] - : [{ category: 'unlisted', consumer, file: record.file, issue }]; + return consumer === undefined ? [] : [{ category: 'unlisted', consumer, file: record.file, issue }]; }), ); yield* writeJson(path.join(directory, 'modeled-usages.json'), modeled); @@ -336,20 +318,21 @@ const validateJscpd = Effect.fn('qualityAudit.validateJscpd')(function* validate }; }); -const validateDiscovery = Effect.fn('qualityAudit.validateDiscovery')( - function* validateDiscoveryEffect(name: string, source: string) { - const report = yield* decodeReport(FallowFilesSchema, source); - yield* nonempty(report.file_count, name); - if (report.file_count !== report.files.length) { - return yield* failure('Fallow discovery count disagrees with file list'); - } - return { - coverage: { discoveredFiles: report.file_count }, - files: report.file_count, - findings: 0, - }; - }, -); +const validateDiscovery = Effect.fn('qualityAudit.validateDiscovery')(function* validateDiscoveryEffect( + name: string, + source: string, +) { + const report = yield* decodeReport(FallowFilesSchema, source); + yield* nonempty(report.file_count, name); + if (report.file_count !== report.files.length) { + return yield* failure('Fallow discovery count disagrees with file list'); + } + return { + coverage: { discoveredFiles: report.file_count }, + files: report.file_count, + findings: 0, + }; +}); const validateClones = Effect.fn('qualityAudit.validateClones')(function* validateClonesEffect( name: string, @@ -403,8 +386,7 @@ const validateHealth = Effect.fn('qualityAudit.validateHealth')(function* valida controlFlowCognitive, cyclomatic: finding.cyclomatic, exceedsControlFlowLimits: - finding.cyclomatic > COMPLEXITY_LIMITS.cyclomatic || - controlFlowCognitive > COMPLEXITY_LIMITS.cognitive, + finding.cyclomatic > COMPLEXITY_LIMITS.cyclomatic || controlFlowCognitive > COMPLEXITY_LIMITS.cognitive, hookDensityWeight, line: finding.line, name: finding.name, @@ -420,15 +402,12 @@ const validateHealth = Effect.fn('qualityAudit.validateHealth')(function* valida if ( report.findings.some( (finding) => - finding.cyclomatic <= COMPLEXITY_LIMITS.cyclomatic && - finding.cognitive <= COMPLEXITY_LIMITS.cognitive, + finding.cyclomatic <= COMPLEXITY_LIMITS.cyclomatic && finding.cognitive <= COMPLEXITY_LIMITS.cognitive, ) ) { return yield* failure('Fallow reported a function below both configured thresholds'); } - const controlFlowFindings = complexity.filter( - (finding) => finding.exceedsControlFlowLimits, - ).length; + const controlFlowFindings = complexity.filter((finding) => finding.exceedsControlFlowLimits).length; return { complexity, coverage: { @@ -443,164 +422,137 @@ const validateHealth = Effect.fn('qualityAudit.validateHealth')(function* valida }; }); -export const validateReport = Effect.fn('qualityAudit.validateReport')( - function* validateReportEffect(name: string, source: string) { - switch (name) { - case 'knip': { - return yield* validateKnip(name, source); - } - case 'jscpd': { - return yield* validateJscpd(name, source); - } - case FALLOW_FILES: { - return yield* validateDiscovery(name, source); - } - case 'fallow-clones': - case FALLOW_SIMILARITY: { - return yield* validateClones(name, source); - } - case FALLOW_HEALTH: { - return yield* validateHealth(name, source); - } - default: { - return yield* failure(`Unknown analyzer report: ${name}`); - } +export const validateReport = Effect.fn('qualityAudit.validateReport')(function* validateReportEffect( + name: string, + source: string, +) { + switch (name) { + case 'knip': { + return yield* validateKnip(name, source); } - }, -); - -const collectSourceFiles = Effect.fn('qualityAudit.collectSourceFiles')( - function* collectSourceFilesEffect(root: string, output: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const source = yield* fs.readFileString(path.join(root, 'quality-audit/scope.json')); - const scope = yield* decodeReport(ScopeSchema, source, 'quality-audit/scope.json'); - const [canonicalRoot, canonicalOutput] = yield* Effect.all([ - fs.realPath(root), - fs.realPath(output), - ]); - const outputPaths = [ - path.relative(root, output), - path.relative(canonicalRoot, canonicalOutput), - ]; - if ( - outputPaths.some((directory) => - scope.patterns.some((pattern) => - nodePath.matchesGlob(path.join(directory, 'knip-consumers.mts'), pattern), - ), - ) - ) { - return yield* failure( - 'Choose an output directory outside configured source roots, such as .codex/reports/quality-audit', - ); + case 'jscpd': { + return yield* validateJscpd(name, source); } - const groups = yield* Effect.forEach( - scope.patterns, - (pattern) => - fs.glob(pattern, { - exclude: scope.exclude, - root, - }), - { concurrency: 'unbounded' }, - ); - const files = EffectArray.sort([...new Set(groups.flat())], Order.String); - yield* nonempty(files.length, 'Source inventory'); - return files; - }, -); + case FALLOW_FILES: { + return yield* validateDiscovery(name, source); + } + case 'fallow-clones': + case FALLOW_SIMILARITY: { + return yield* validateClones(name, source); + } + case FALLOW_HEALTH: { + return yield* validateHealth(name, source); + } + default: { + return yield* failure(`Unknown analyzer report: ${name}`); + } + } +}); -const readSourceProvenance = Effect.fn('qualityAudit.readSourceProvenance')( - function* readProvenance(root: string) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const git = (args: readonly string[]) => - spawner - .string(ChildProcess.make('git', args, { cwd: root })) - .pipe(Effect.timeout('10 seconds'), Effect.result); - const [revision, status] = yield* Effect.all( - [git(['rev-parse', 'HEAD']), git(['status', '--porcelain=v1', '--untracked-files=all'])], - { concurrency: 'unbounded' }, +const collectSourceFiles = Effect.fn('qualityAudit.collectSourceFiles')(function* collectSourceFilesEffect( + root: string, + output: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const source = yield* fs.readFileString(path.join(root, 'quality-audit/scope.json')); + const scope = yield* decodeReport(ScopeSchema, source, 'quality-audit/scope.json'); + const [canonicalRoot, canonicalOutput] = yield* Effect.all([fs.realPath(root), fs.realPath(output)]); + const outputPaths = [path.relative(root, output), path.relative(canonicalRoot, canonicalOutput)]; + if ( + outputPaths.some((directory) => + scope.patterns.some((pattern) => nodePath.matchesGlob(path.join(directory, 'knip-consumers.mts'), pattern)), + ) + ) { + return yield* failure( + 'Choose an output directory outside configured source roots, such as .codex/reports/quality-audit', ); - return { - sourceRevision: Result.isSuccess(revision) - ? revision.success.trim() - : 'unavailable (no Git HEAD)', - sourceState: Result.match(status, { - onFailure: () => 'unavailable', - onSuccess: (output) => (output.trim().length > 0 ? 'modified' : 'clean'), + } + const groups = yield* Effect.forEach( + scope.patterns, + (pattern) => + fs.glob(pattern, { + exclude: scope.exclude, + root, }), - workingTreeChanges: Result.isSuccess(status) - ? status.success.trimEnd().split('\n').filter(Boolean) - : [], - }; - }, -); + { concurrency: 'unbounded' }, + ); + const files = EffectArray.sort([...new Set(groups.flat())], Order.String); + yield* nonempty(files.length, 'Source inventory'); + return files; +}); -const snapshotConfiguration = Effect.fn('qualityAudit.snapshotConfiguration')( - function* snapshotConfigurationEffect( - root: string, - directory: string, - tool: AuditTool, - consumerPath: string | undefined, - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const selected = ['knip', 'jscpd', 'fallow'].filter((name) => tool === 'all' || tool === name); - const configs = [ - 'scope.json', - ...selected.map((name) => `${name}.json`), - ...(selected.includes('knip') ? ['knip-reporter.mts'] : []), - ]; - yield* fs.makeDirectory(path.join(directory, 'configs')); - yield* Effect.forEach( - configs, - (name) => - fs.copyFile(path.join(root, 'quality-audit', name), path.join(directory, 'configs', name)), - { concurrency: 'unbounded' }, - ); - if (selected.includes('knip')) { - const target = path.join(directory, 'configs/knip.json'); - yield* fs.copyFile(target, path.join(directory, 'configs/knip-base.json')); - const source = yield* fs.readFileString(target); - const config = yield* decodeReport(KnipConfigSchema, source, target); - const model = yield* buildKnipModel(root, config, consumerPath); - if (model.consumerSource !== undefined && consumerPath !== undefined) { - yield* fs.writeFileString(consumerPath, model.consumerSource); - yield* fs.copyFile(consumerPath, path.join(directory, 'knip-consumers.mts')); - } - yield* writeJson(target, model.config); - yield* writeJson(path.join(directory, 'knip-model.json'), model.evidence); - } - if (selected.includes('fallow')) { - const target = path.join(directory, 'configs/fallow.json'); - const source = yield* fs.readFileString(target); - const config = yield* decodeReport(Schema.Record(Schema.String, Schema.Json), source, target); - const ignores = yield* decodeReport( - Schema.Struct({ ignorePatterns: Schema.Array(Schema.String) }), - source, - target, - ); - const relativeOutput = path.relative(root, path.dirname(directory)); - const outputIsInsideRoot = - relativeOutput !== '..' && - !relativeOutput.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativeOutput); - yield* writeJson(target, { - ...config, - ignorePatterns: outputIsInsideRoot - ? [...ignores.ignorePatterns, `${relativeOutput}/**`] - : ignores.ignorePatterns, - }); +const readSourceProvenance = Effect.fn('qualityAudit.readSourceProvenance')(function* readProvenance(root: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const git = (args: readonly string[]) => + spawner.string(ChildProcess.make('git', args, { cwd: root })).pipe(Effect.timeout('10 seconds'), Effect.result); + const [revision, status] = yield* Effect.all( + [git(['rev-parse', 'HEAD']), git(['status', '--porcelain=v1', '--untracked-files=all'])], + { concurrency: 'unbounded' }, + ); + return { + sourceRevision: Result.isSuccess(revision) ? revision.success.trim() : 'unavailable (no Git HEAD)', + sourceState: Result.match(status, { + onFailure: () => 'unavailable', + onSuccess: (output) => (output.trim().length > 0 ? 'modified' : 'clean'), + }), + workingTreeChanges: Result.isSuccess(status) ? status.success.trimEnd().split('\n').filter(Boolean) : [], + }; +}); + +const snapshotConfiguration = Effect.fn('qualityAudit.snapshotConfiguration')(function* snapshotConfigurationEffect( + root: string, + directory: string, + tool: AuditTool, + consumerPath: string | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const selected = ['knip', 'jscpd', 'fallow'].filter((name) => tool === 'all' || tool === name); + const configs = [ + 'scope.json', + ...selected.map((name) => `${name}.json`), + ...(selected.includes('knip') ? ['knip-reporter.mts'] : []), + ]; + yield* fs.makeDirectory(path.join(directory, 'configs')); + yield* Effect.forEach( + configs, + (name) => fs.copyFile(path.join(root, 'quality-audit', name), path.join(directory, 'configs', name)), + { concurrency: 'unbounded' }, + ); + if (selected.includes('knip')) { + const target = path.join(directory, 'configs/knip.json'); + yield* fs.copyFile(target, path.join(directory, 'configs/knip-base.json')); + const source = yield* fs.readFileString(target); + const config = yield* decodeReport(KnipConfigSchema, source, target); + const model = yield* buildKnipModel(root, config, consumerPath); + if (model.consumerSource !== undefined && consumerPath !== undefined) { + yield* fs.writeFileString(consumerPath, model.consumerSource); + yield* fs.copyFile(consumerPath, path.join(directory, 'knip-consumers.mts')); } - return configs; - }, -); + yield* writeJson(target, model.config); + yield* writeJson(path.join(directory, 'knip-model.json'), model.evidence); + } + if (selected.includes('fallow')) { + const target = path.join(directory, 'configs/fallow.json'); + const source = yield* fs.readFileString(target); + const config = yield* decodeReport(Schema.Record(Schema.String, Schema.Json), source, target); + const ignores = yield* decodeReport(Schema.Struct({ ignorePatterns: Schema.Array(Schema.String) }), source, target); + const relativeOutput = path.relative(root, path.dirname(directory)); + const outputIsInsideRoot = + relativeOutput !== '..' && !relativeOutput.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeOutput); + yield* writeJson(target, { + ...config, + ignorePatterns: outputIsInsideRoot ? [...ignores.ignorePatterns, `${relativeOutput}/**`] : ignores.ignorePatterns, + }); + } + return configs; +}); const verifyFallowCounts = (results: readonly AuditResult[]) => { const discovery = results.find((result) => result.name === FALLOW_FILES); const health = results.find((result) => result.name === FALLOW_HEALTH); - return discovery?.status === 'reported' && - health?.status === 'reported' && - discovery.files !== health.files + return discovery?.status === 'reported' && health?.status === 'reported' && discovery.files !== health.files ? Effect.fail(failure('Fallow discovery and complexity file counts disagree')) : Effect.void; }; @@ -614,9 +566,7 @@ const reconcileFallowCoverage = Effect.fn('qualityAudit.reconcileFallowCoverage' ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const fallow = results.find( - (result) => result.name === FALLOW_FILES && result.status === 'reported', - ); + const fallow = results.find((result) => result.name === FALLOW_FILES && result.status === 'reported'); if (fallow !== undefined) { const report = yield* decodeReport( FallowFilesSchema, @@ -651,38 +601,36 @@ const reconcileFallowCoverage = Effect.fn('qualityAudit.reconcileFallowCoverage' }, ); -const reconcileCoverage = Effect.fn('qualityAudit.reconcileCoverage')( - function* reconcileCoverageEffect( - root: string, - directory: string, - files: readonly string[], - results: readonly AuditResult[], - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const canonicalRoot = yield* fs.realPath(root); - const expectedManifests = yield* fs.glob('{apps,verticals,packages}/*/package.json', { - exclude: ['**/node_modules/**'], - root, - }); - const expectedWorkspaces = ['.', ...expectedManifests.map((file) => path.dirname(file))]; - const knip = results.find((result) => result.name === 'knip' && result.status === 'reported'); - if (knip !== undefined) { - const observed = (knip.coverage.workspaces ?? []).map( - (workspace) => path.relative(canonicalRoot, workspace) || '.', +const reconcileCoverage = Effect.fn('qualityAudit.reconcileCoverage')(function* reconcileCoverageEffect( + root: string, + directory: string, + files: readonly string[], + results: readonly AuditResult[], +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const canonicalRoot = yield* fs.realPath(root); + const expectedManifests = yield* fs.glob('{apps,verticals,packages}/*/package.json', { + exclude: ['**/node_modules/**'], + root, + }); + const expectedWorkspaces = ['.', ...expectedManifests.map((file) => path.dirname(file))]; + const knip = results.find((result) => result.name === 'knip' && result.status === 'reported'); + if (knip !== undefined) { + const observed = (knip.coverage.workspaces ?? []).map( + (workspace) => path.relative(canonicalRoot, workspace) || '.', + ); + if ( + observed.length !== expectedWorkspaces.length || + expectedWorkspaces.some((workspace) => !observed.includes(workspace)) + ) { + return yield* failure( + `Knip workspace coverage mismatch: expected ${expectedWorkspaces.join(', ')}, observed ${observed.join(', ')}`, ); - if ( - observed.length !== expectedWorkspaces.length || - expectedWorkspaces.some((workspace) => !observed.includes(workspace)) - ) { - return yield* failure( - `Knip workspace coverage mismatch: expected ${expectedWorkspaces.join(', ')}, observed ${observed.join(', ')}`, - ); - } } - return yield* reconcileFallowCoverage(directory, files, results, expectedWorkspaces); - }, -); + } + return yield* reconcileFallowCoverage(directory, files, results, expectedWorkspaces); +}); const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepEffect( root: string, @@ -710,9 +658,7 @@ const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepE yield* fs.readFileString(path.join(root, 'node_modules', step.tool, 'package.json')), ); if (installed.version !== TOOL_VERSIONS[step.tool]) { - return yield* failure( - `Expected ${step.tool} ${TOOL_VERSIONS[step.tool]}, found ${installed.version}`, - ); + return yield* failure(`Expected ${step.tool} ${TOOL_VERSIONS[step.tool]}, found ${installed.version}`); } const processHandle = yield* spawner.spawn( ChildProcess.make(process.execPath, [binary, ...step.args], { @@ -757,23 +703,26 @@ const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepE return yield* failure(`Analyzer exited ${exitCode}; inspect stdout.txt and stderr.txt`); } const stderr = yield* fs.readFileString(stderrPath); - if ( - stderr.trim().length > 0 && - !(step.name === 'jscpd' && stderr.trim() === `Using config from ${step.args[1]}`) - ) { - return yield* failure( - 'Analyzer emitted diagnostics; inspect stderr.txt before trusting coverage', - ); + if (stderr.trim().length > 0 && !(step.name === 'jscpd' && stderr.trim() === `Using config from ${step.args[1]}`)) { + return yield* failure('Analyzer emitted diagnostics; inspect stderr.txt before trusting coverage'); } const reportPath = step.report ?? stdoutPath; const report = yield* fs.readFileString(reportPath); - yield* fs.writeFileString( - path.join(directory, step.name === 'knip' ? 'report.ndjson' : 'report.json'), - report, - ); - return step.name === 'knip' - ? yield* evaluateKnip(report, directory) - : yield* validateReport(step.name, report); + yield* fs.writeFileString(path.join(directory, step.name === 'knip' ? 'report.ndjson' : 'report.json'), report); + if (step.name === 'jscpd') { + const validated = yield* validateReport(step.name, report); + const imports = yield* importCloneEvidence(root, report); + yield* writeJson(path.join(directory, 'import-clone-evidence.json'), imports); + return { + ...validated, + coverage: { + ...validated.coverage, + declarationOnlyClones: imports.length, + }, + findings: validated.findings - imports.length, + }; + } + return step.name === 'knip' ? yield* evaluateKnip(report, directory) : yield* validateReport(step.name, report); }).pipe(Effect.result); if (Result.isFailure(evaluated)) { const diagnostic = String(evaluated.failure); @@ -783,16 +732,24 @@ const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepE if ('complexity' in evaluated.success) { const { complexity, ...summary } = evaluated.success; yield* writeJson(path.join(directory, 'complexity.json'), complexity); - return { name: step.name, status: 'reported', ...summary, diagnostic: '', directory }; + return { + name: step.name, + status: 'reported', + ...summary, + diagnostic: '', + directory, + }; } - return { name: step.name, status: 'reported', ...evaluated.success, diagnostic: '', directory }; + return { + name: step.name, + status: 'reported', + ...evaluated.success, + diagnostic: '', + directory, + }; }); -export const auditSteps = ( - root: string, - runDirectory: string, - tool: AuditTool, -): readonly AuditStep[] => { +export const auditSteps = (root: string, runDirectory: string, tool: AuditTool): readonly AuditStep[] => { const config = `${runDirectory}/configs`; const common = [ '--root', @@ -828,7 +785,11 @@ export const auditSteps = ( report: `${runDirectory}/jscpd/jscpd-report.json`, tool: 'jscpd', }, - { args: ['list', '--files', ...common], name: FALLOW_FILES, tool: 'fallow' }, + { + args: ['list', '--files', ...common], + name: FALLOW_FILES, + tool: 'fallow', + }, { args: ['dupes', '--mode', 'strict', '--min-tokens', '100', '--min-lines', '10', ...common], name: 'fallow-clones', @@ -861,16 +822,21 @@ export const auditSteps = ( }; const REPORT_MEANINGS = { - [FALLOW_HEALTH]: { advisory: false, unit: 'functions above control-flow limits' }, + [FALLOW_HEALTH]: { + advisory: false, + unit: 'functions above control-flow limits', + }, 'fallow-clones': { advisory: false, unit: 'strict clone groups' }, 'fallow-files': { advisory: false, unit: 'discovery only' }, - 'fallow-similarity': { advisory: true, unit: 'semantic similarity groups (advisory)' }, + 'fallow-similarity': { + advisory: true, + unit: 'semantic similarity groups (advisory)', + }, jscpd: { advisory: false, unit: 'token clone pairs' }, knip: { advisory: false, unit: 'unused/dependency records' }, }; -const reportMeaning = (name: string) => - Object.entries(REPORT_MEANINGS).find(([analysis]) => analysis === name)?.[1]; +const reportMeaning = (name: string) => Object.entries(REPORT_MEANINGS).find(([analysis]) => analysis === name)?.[1]; const writeSummary = Effect.fn('qualityAudit.writeSummary')(function* writeSummaryEffect( output: string, @@ -889,7 +855,10 @@ const writeSummary = Effect.fn('qualityAudit.writeSummary')(function* writeSumma mode: 'report-only', parserCompleteness: 'unavailable; use existing lint and compiler checks', provenance: `${runDirectory}/provenance.json`, - results: results.map((result) => ({ ...result, ...reportMeaning(result.name) })), + results: results.map((result) => ({ + ...result, + ...reportMeaning(result.name), + })), runDirectory, status: errors.length > 0 ? 'error' : 'reported', }); @@ -918,6 +887,7 @@ const writeSummary = Effect.fn('qualityAudit.writeSummary')(function* writeSumma `Proven modeled usages retained separately: ${result.coverage.modeledUsages ?? 0}.`, ]), 'Unused exports describe an unused public binding; they do not establish that the implementation body is unused.', + 'JSCPD implementation counts exclude only complete static import-binding spans proven by parsing both files; raw clones and per-pair evidence remain in jscpd/report.json and jscpd/import-clone-evidence.json.', 'Fallow strict clones preserve literal differences. Semantic similarity normalizes them and remains advisory; inspect both together with JSCPD before choosing a shared implementation.', `Health counts cyclomatic > ${COMPLEXITY_LIMITS.cyclomatic} or control-flow cognitive > ${COMPLEXITY_LIMITS.cognitive}. The latter subtracts hook-density and prop-count penalties from the native weighted metric, with contribution arithmetic verified for every finding.`, ...results @@ -937,101 +907,99 @@ const writeSummary = Effect.fn('qualityAudit.writeSummary')(function* writeSumma ); }); -const createConsumerPath = Effect.fn('qualityAudit.createConsumerPath')( - function* createConsumerPathEffect(root: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parent = path.join(root, '.codex'); - yield* fs.makeDirectory(parent, { recursive: true }); - const directory = yield* fs.makeTempDirectoryScoped({ - directory: parent, - prefix: 'quality-audit-model-', - }); - return path.join(directory, 'consumers.mts'); - }, -); +const createConsumerPath = Effect.fn('qualityAudit.createConsumerPath')(function* createConsumerPathEffect( + root: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = path.join(root, '.codex'); + yield* fs.makeDirectory(parent, { recursive: true }); + const directory = yield* fs.makeTempDirectoryScoped({ + directory: parent, + prefix: 'quality-audit-model-', + }); + return path.join(directory, 'consumers.mts'); +}); -export const runQualityAudit = Effect.fn('qualityAudit.runQualityAudit')( - function* runQualityAuditEffect(root: string, output: string, tool: AuditTool) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const provenance = yield* readSourceProvenance(root); - yield* fs.makeDirectory(output, { recursive: true }); - const runDirectory = yield* fs.makeTempDirectory({ directory: output, prefix: 'run-' }); - yield* writeSummary(output, runDirectory, [ - errorResult('setup', runDirectory, 'Audit has not completed'), - ]); - const results = yield* Effect.gen(function* collectAudit() { - const files = yield* collectSourceFiles(root, output); - yield* writeJson(path.join(runDirectory, 'source-inventory.json'), { - count: files.length, - entries: files.map((file) => ({ group: sourceGroup(file), path: file })), - files, - groupCounts: Object.fromEntries( - Object.values(SOURCE_GROUPS).map((group) => [ - group, - files.filter((file) => sourceGroup(file) === group).length, - ]), - ), - root, - }); - const consumerPath = - tool === 'all' || tool === 'knip' ? yield* createConsumerPath(root) : undefined; - const configs = yield* snapshotConfiguration(root, runDirectory, tool, consumerPath); - yield* writeJson(path.join(runDirectory, 'provenance.json'), { - ...provenance, - configs: configs.map((name) => `configs/${name}`), - expectedToolVersions: TOOL_VERSIONS, - parserCompleteness: 'unavailable', - }); - if (tool === 'all' || tool === 'jscpd') { - const jscpdConfig = yield* decodeReport( - Schema.Record(Schema.String, Schema.Json), - yield* fs.readFileString(path.join(runDirectory, 'configs/jscpd.json')), - 'configs/jscpd.json', - ); - yield* writeJson(path.join(runDirectory, 'jscpd.config.json'), { - ...jscpdConfig, - path: files.map((file) => path.resolve(root, file)), - }); - } - const stepResults = yield* Effect.forEach( - auditSteps(root, runDirectory, tool), - (step) => executeStep(root, path.join(runDirectory, step.name), step), - { concurrency: 1 }, - ); - return yield* reconcileCoverage(root, runDirectory, files, stepResults).pipe( - Effect.match({ - onFailure: (issue) => [ - ...stepResults, - errorResult('coverage', runDirectory, String(issue)), - ], - onSuccess: () => stepResults, - }), +export const runQualityAudit = Effect.fn('qualityAudit.runQualityAudit')(function* runQualityAuditEffect( + root: string, + output: string, + tool: AuditTool, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const provenance = yield* readSourceProvenance(root); + yield* fs.makeDirectory(output, { recursive: true }); + const runDirectory = yield* fs.makeTempDirectory({ + directory: output, + prefix: 'run-', + }); + yield* writeSummary(output, runDirectory, [errorResult('setup', runDirectory, 'Audit has not completed')]); + const results = yield* Effect.gen(function* collectAudit() { + const files = yield* collectSourceFiles(root, output); + yield* writeJson(path.join(runDirectory, 'source-inventory.json'), { + count: files.length, + entries: files.map((file) => ({ + group: sourceGroup(file), + path: file, + })), + files, + groupCounts: Object.fromEntries( + Object.values(SOURCE_GROUPS).map((group) => [ + group, + files.filter((file) => sourceGroup(file) === group).length, + ]), + ), + root, + }); + const consumerPath = tool === 'all' || tool === 'knip' ? yield* createConsumerPath(root) : undefined; + const configs = yield* snapshotConfiguration(root, runDirectory, tool, consumerPath); + yield* writeJson(path.join(runDirectory, 'provenance.json'), { + ...provenance, + configs: configs.map((name) => `configs/${name}`), + expectedToolVersions: TOOL_VERSIONS, + parserCompleteness: 'unavailable', + }); + if (tool === 'all' || tool === 'jscpd') { + const jscpdConfig = yield* decodeReport( + Schema.Record(Schema.String, Schema.Json), + yield* fs.readFileString(path.join(runDirectory, 'configs/jscpd.json')), + 'configs/jscpd.json', ); - }).pipe( - Effect.scoped, + yield* writeJson(path.join(runDirectory, 'jscpd.config.json'), { + ...jscpdConfig, + path: files.map((file) => path.resolve(root, file)), + }); + } + const stepResults = yield* Effect.forEach( + auditSteps(root, runDirectory, tool), + (step) => executeStep(root, path.join(runDirectory, step.name), step), + { concurrency: 1 }, + ); + return yield* reconcileCoverage(root, runDirectory, files, stepResults).pipe( Effect.match({ - onFailure: (issue) => [errorResult('setup', runDirectory, String(issue))], - onSuccess: (collected) => collected, + onFailure: (issue) => [...stepResults, errorResult('coverage', runDirectory, String(issue))], + onSuccess: () => stepResults, }), ); - yield* writeSummary(output, runDirectory, results); - yield* Console.log(`Quality audit: ${path.join(output, 'summary.md')}`); - const errors = results.filter((result) => result.status === 'error'); - if (errors.length > 0) { - yield* Effect.forEach( - errors, - (result) => Console.error(`${result.name}: ${result.diagnostic}`), - { concurrency: 1 }, - ); - return yield* failure( - 'Quality audit analysis failed; diagnostics preserved in summary and raw artifacts', - ); - } - return yield* Effect.void; - }, -); + }).pipe( + Effect.scoped, + Effect.match({ + onFailure: (issue) => [errorResult('setup', runDirectory, String(issue))], + onSuccess: (collected) => collected, + }), + ); + yield* writeSummary(output, runDirectory, results); + yield* Console.log(`Quality audit: ${path.join(output, 'summary.md')}`); + const errors = results.filter((result) => result.status === 'error'); + if (errors.length > 0) { + yield* Effect.forEach(errors, (result) => Console.error(`${result.name}: ${result.diagnostic}`), { + concurrency: 1, + }); + return yield* failure('Quality audit analysis failed; diagnostics preserved in summary and raw artifacts'); + } + return yield* Effect.void; +}); const cli = Command.make( 'quality-audit', diff --git a/app/scripts/quality-cli-lifecycle.mts b/app/scripts/quality-cli-lifecycle.mts index 54aa73409..79eec123d 100644 --- a/app/scripts/quality-cli-lifecycle.mts +++ b/app/scripts/quality-cli-lifecycle.mts @@ -6,9 +6,9 @@ import type { Scope } from 'effect'; export const runQualityCli = ( command: Effect.Effect, ) => { - const mainLayer = Layer.effectDiscard( - command.pipe(Effect.tapError((issue) => Console.error(String(issue)))), - ).pipe(Layer.provide(NodeServices.layer)); + const mainLayer = Layer.effectDiscard(command.pipe(Effect.tapError((issue) => Console.error(String(issue))))).pipe( + Layer.provide(NodeServices.layer), + ); NodeRuntime.runMain(Effect.scoped(Layer.build(mainLayer)).pipe(Effect.asVoid), { disableErrorReporting: true, }); diff --git a/app/scripts/report-fail-closed-authorization-impact.mts b/app/scripts/report-fail-closed-authorization-impact.mts index e5277f02a..723a660e1 100644 --- a/app/scripts/report-fail-closed-authorization-impact.mts +++ b/app/scripts/report-fail-closed-authorization-impact.mts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { pathToFileURL } from 'node:url'; + import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { Array as EffectArray, @@ -15,7 +17,6 @@ import { Schema, } from 'effect'; import { Argument, Command } from 'effect/unstable/cli'; -import { pathToFileURL } from 'node:url'; const InventoryHashSchema = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); const SourceRevisionSchema = Schema.String.check( @@ -23,9 +24,9 @@ const SourceRevisionSchema = Schema.String.check( Schema.isMaxLength(100), Schema.isPattern(/^[a-zA-Z0-9._-]+$/u), ); -const EntrypointKeySchema = Schema.String.check( - Schema.isPattern(/^[a-z][a-z0-9]*(?:[./_-][a-z0-9]+)*$/u), -).pipe(Schema.brand('EntrypointKey')); +const EntrypointKeySchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9]*(?:[./_-][a-z0-9]+)*$/u)).pipe( + Schema.brand('EntrypointKey'), +); const CanonicalTimestampStringSchema = Schema.String.check( Schema.makeFilter((value) => { const parsed = DateTime.make(value); @@ -34,9 +35,7 @@ const CanonicalTimestampStringSchema = Schema.String.check( : 'timestamp must use canonical UTC ISO 8601 encoding'; }), ); -const CanonicalTimestampSchema = CanonicalTimestampStringSchema.pipe( - Schema.decodeTo(Schema.DateTimeUtcFromString), -); +const CanonicalTimestampSchema = CanonicalTimestampStringSchema.pipe(Schema.decodeTo(Schema.DateTimeUtcFromString)); const WouldDenyEvidenceSchema = Schema.Struct({ denialReason: Schema.Literals([ @@ -120,41 +119,24 @@ const localeStringOrder = Order.make((left, right) => { }); const aggregateOrder = Order.combineAll([ Order.mapInput(localeStringOrder, (aggregate: AuthorizationImpactAggregate) => aggregate.surface), - Order.mapInput( - localeStringOrder, - (aggregate: AuthorizationImpactAggregate) => aggregate.entrypointKey, - ), - Order.mapInput( - localeStringOrder, - (aggregate: AuthorizationImpactAggregate) => aggregate.policyClass, - ), - Order.mapInput( - localeStringOrder, - (aggregate: AuthorizationImpactAggregate) => aggregate.denialReason, - ), + Order.mapInput(localeStringOrder, (aggregate: AuthorizationImpactAggregate) => aggregate.entrypointKey), + Order.mapInput(localeStringOrder, (aggregate: AuthorizationImpactAggregate) => aggregate.policyClass), + Order.mapInput(localeStringOrder, (aggregate: AuthorizationImpactAggregate) => aggregate.denialReason), ]); const reduceDecodedEvidence = (events: NonEmptyEvidence): AuthorizationImpactReport => { const [first] = events; if ( - events.some( - (event) => - event.sourceRevision !== first.sourceRevision || - event.inventoryHash !== first.inventoryHash, - ) + events.some((event) => event.sourceRevision !== first.sourceRevision || event.inventoryHash !== first.inventoryHash) ) { return Result.getOrThrow( - Result.fail( - validationError('authorization evidence mixes source revisions or inventory hashes'), - ), + Result.fail(validationError('authorization evidence mixes source revisions or inventory hashes')), ); } const counts = new Map(); for (const event of events) { - const key = [event.surface, event.entrypointKey, event.policyClass, event.denialReason].join( - '\0', - ); + const key = [event.surface, event.entrypointKey, event.policyClass, event.denialReason].join('\0'); const current = counts.get(key); counts.set(key, { count: (current?.count ?? 0) + 1, @@ -188,7 +170,9 @@ export const reduceAuthorizationImpact = ( ): AuthorizationImpactReport => { if (rawEvents.length > 0) { const events = Result.getOrThrowWith( - Schema.decodeUnknownResult(NonEmptyEvidenceSchema, { onExcessProperty: 'error' })(rawEvents), + Schema.decodeUnknownResult(NonEmptyEvidenceSchema, { + onExcessProperty: 'error', + })(rawEvents), () => validationError('authorization evidence is malformed or contains prohibited fields'), ); return reduceDecodedEvidence(events); @@ -202,9 +186,7 @@ export const reduceAuthorizationImpact = ( ); if (DateTime.toEpochMillis(observation.startedAt) > DateTime.toEpochMillis(observation.endedAt)) { return Result.getOrThrow( - Result.fail( - validationError('empty authorization impact requires explicit observation bounds'), - ), + Result.fail(validationError('empty authorization impact requires explicit observation bounds')), ); } return { @@ -227,54 +209,46 @@ const BoundedEvidenceBatchSchema = Schema.Struct({ sourceRevision: SourceRevisionSchema, startedAt: CanonicalTimestampSchema, }); -const EvidenceDocumentSchema = Schema.Union([ - Schema.Array(WouldDenyEvidenceSchema), - BoundedEvidenceBatchSchema, -]); +const EvidenceDocumentSchema = Schema.Union([Schema.Array(WouldDenyEvidenceSchema), BoundedEvidenceBatchSchema]); -const writeAuthorizationImpactReport = Effect.fn('writeAuthorizationImpactReport')( - function* writeReport(inputPath: Option.Option) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const configuredRoot = yield* Config.option(Config.string('ULTRAMODERN_WORKSPACE_ROOT')); - const root = Option.getOrElse(configuredRoot, () => path.resolve(import.meta.dirname, '..')); - const input = Option.getOrElse(inputPath, () => - path.join(root, '.codex/reports/authorization/would-deny.json'), - ); - const output = path.join(root, '.codex/reports/authorization/fail-closed-impact.json'); - const source = yield* fileSystem.readFileString(input); - const document = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(EvidenceDocumentSchema), - { onExcessProperty: 'error' }, - )(source); +const writeAuthorizationImpactReport = Effect.fn('writeAuthorizationImpactReport')(function* writeReport( + inputPath: Option.Option, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configuredRoot = yield* Config.option(Config.string('ULTRAMODERN_WORKSPACE_ROOT')); + const root = Option.getOrElse(configuredRoot, () => path.resolve(import.meta.dirname, '..')); + const input = Option.getOrElse(inputPath, () => path.join(root, '.codex/reports/authorization/would-deny.json')); + const output = path.join(root, '.codex/reports/authorization/fail-closed-impact.json'); + const source = yield* fileSystem.readFileString(input); + const document = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(EvidenceDocumentSchema), { + onExcessProperty: 'error', + })(source); - let report: AuthorizationImpactReport; - if (Schema.is(Schema.Array(WouldDenyEvidenceSchema))(document)) { - report = Schema.is(NonEmptyEvidenceSchema)(document) - ? reduceDecodedEvidence(document) - : reduceAuthorizationImpact([]); - } else if (Schema.is(NonEmptyEvidenceSchema)(document.events)) { - report = reduceDecodedEvidence(document.events); - } else { - report = reduceAuthorizationImpact([], { - endedAt: DateTime.formatIso(document.endedAt), - inventoryHash: document.inventoryHash, - sourceRevision: document.sourceRevision, - startedAt: DateTime.formatIso(document.startedAt), - }); - } + let report: AuthorizationImpactReport; + if (Schema.is(Schema.Array(WouldDenyEvidenceSchema))(document)) { + report = Schema.is(NonEmptyEvidenceSchema)(document) + ? reduceDecodedEvidence(document) + : reduceAuthorizationImpact([]); + } else if (Schema.is(NonEmptyEvidenceSchema)(document.events)) { + report = reduceDecodedEvidence(document.events); + } else { + report = reduceAuthorizationImpact([], { + endedAt: DateTime.formatIso(document.endedAt), + inventoryHash: document.inventoryHash, + sourceRevision: document.sourceRevision, + startedAt: DateTime.formatIso(document.startedAt), + }); + } - const decodedReport = yield* Schema.decodeUnknownEffect(AuthorizationImpactReportSchema)( - report, - ); - const outputJson = yield* Schema.encodeEffect( - Schema.fromJsonString(AuthorizationImpactReportSchema, { space: 2 }), - )(decodedReport); - yield* fileSystem.makeDirectory(path.dirname(output), { recursive: true }); - yield* fileSystem.writeFileString(output, `${outputJson}\n`); - yield* Console.log(output); - }, -); + const decodedReport = yield* Schema.decodeUnknownEffect(AuthorizationImpactReportSchema)(report); + const outputJson = yield* Schema.encodeEffect(Schema.fromJsonString(AuthorizationImpactReportSchema, { space: 2 }))( + decodedReport, + ); + yield* fileSystem.makeDirectory(path.dirname(output), { recursive: true }); + yield* fileSystem.writeFileString(output, `${outputJson}\n`); + yield* Console.log(output); +}); const command = Command.make( 'report-fail-closed-authorization-impact', diff --git a/app/scripts/run-zerops-migrator.mjs b/app/scripts/run-zerops-migrator.mjs index dd21291a4..0d6f05eb3 100644 --- a/app/scripts/run-zerops-migrator.mjs +++ b/app/scripts/run-zerops-migrator.mjs @@ -3,6 +3,7 @@ import { createServer } from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import { Cause, Config, Effect, Exit } from 'effect'; const appDirectory = fileURLToPath(new URL('../', import.meta.url)); @@ -72,8 +73,7 @@ const run = Effect.fn('run')( ); /** @param {string} relativePath - Application-relative script path. */ -const runAppScript = (relativePath) => - run(process.execPath, [path.join(appDirectory, relativePath)]); +const runAppScript = (relativePath) => run(process.execPath, [path.join(appDirectory, relativePath)]); /** * @param {string} relativeDirectory - Application-relative package directory. * @param {string} config - Drizzle configuration filename. @@ -107,7 +107,9 @@ const serveReadiness = Effect.fn('serveReadiness')( Effect.sync(() => createServer((request, response) => { if (request.url === '/ready') { - response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); + response.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + }); response.end('ready\n'); return; } @@ -118,8 +120,7 @@ const serveReadiness = Effect.fn('serveReadiness')( ); yield* Effect.callback((resume) => { - const onError = (cause) => - resume(Effect.fail(new MigratorError('The migration readiness server failed', cause))); + const onError = (cause) => resume(Effect.fail(new MigratorError('The migration readiness server failed', cause))); const onListening = () => { console.log(`Migration verification complete; readiness listening on port ${String(port)}`); }; diff --git a/app/scripts/scaffolding/action-service/scaffold.mts b/app/scripts/scaffolding/action-service/scaffold.mts index ba9118768..0ec82fae8 100644 --- a/app/scripts/scaffolding/action-service/scaffold.mts +++ b/app/scripts/scaffolding/action-service/scaffold.mts @@ -1,3 +1,6 @@ +import { Effect } from 'effect'; + +import { createCodesmithGenerator } from '../generator-adapter.mts'; import { ACTION_SERVICE_GENERATOR_HEADER, createMutationEffect, @@ -7,13 +10,7 @@ import { toCamelCase, tryScaffold, } from '../shared.mts'; -import { createCodesmithGenerator } from '../generator-adapter.mts'; -import { Effect } from 'effect'; -import type { - ActionServiceScaffoldConfig, - ActionServiceScaffoldResult, - ScaffoldPlan, -} from '../shared.mts'; +import type { ActionServiceScaffoldConfig, ActionServiceScaffoldResult, ScaffoldPlan } from '../shared.mts'; const renderActionService = (service: string): string => `${ACTION_SERVICE_GENERATOR_HEADER} import { Effect } from 'effect'; @@ -21,28 +18,20 @@ import { Effect } from 'effect'; export const ${toCamelCase(service)}Service = () => Effect.succeed({}); `; -const planActionServiceScaffold = Effect.fn('ActionServiceScaffold.plan')( - function* planActionServiceScaffold(workspaceRoot: string, config: ActionServiceScaffoldConfig) { - const service = yield* tryScaffold('service name is invalid', () => - requireCanonicalSlug(config.service, 'service'), - ); - const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); - const servicePath = yield* tryScaffold('failed to resolve Action service path', () => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'src', - 'services', - `${service}.service.ts`, - ), - ); - const mutation = yield* createMutationEffect(servicePath, renderActionService(service)); - return { - mutations: [mutation], - result: { servicePath }, - } satisfies ScaffoldPlan; - }, -); +const planActionServiceScaffold = Effect.fn('ActionServiceScaffold.plan')(function* planActionServiceScaffold( + workspaceRoot: string, + config: ActionServiceScaffoldConfig, +) { + const service = yield* tryScaffold('service name is invalid', () => requireCanonicalSlug(config.service, 'service')); + const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); + const servicePath = yield* tryScaffold('failed to resolve Action service path', () => + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'src', 'services', `${service}.service.ts`), + ); + const mutation = yield* createMutationEffect(servicePath, renderActionService(service)); + return { + mutations: [mutation], + result: { servicePath }, + } satisfies ScaffoldPlan; +}); export default createCodesmithGenerator(planActionServiceScaffold); diff --git a/app/scripts/scaffolding/action/scaffold.mts b/app/scripts/scaffolding/action/scaffold.mts index 8ad0a921f..23d41ae08 100644 --- a/app/scripts/scaffolding/action/scaffold.mts +++ b/app/scripts/scaffolding/action/scaffold.mts @@ -1,4 +1,6 @@ import { Effect, FileSystem } from 'effect'; + +import { createCodesmithGenerator } from '../generator-adapter.mts'; import { ACTION_GENERATOR_HEADER, CORE_ACTION_CATALOG_IMPORT_SLOT_END, @@ -33,7 +35,6 @@ import { updateMutation, withCoreDependency, } from '../shared.mts'; -import { createCodesmithGenerator } from '../generator-adapter.mts'; import type { ActionScaffoldConfig, OntosVerticalMetadata } from '../shared.mts'; const CORE_RUNTIME_DIRECTORY = 'core-runtime'; @@ -188,112 +189,93 @@ const coreCatalogImportEntry = (action: string): string => `import { ${toCamelCase(action)}Action } from './${action}.action.ts';`; const isCoreActionCatalogImport = (candidate: string): boolean => - /^import \{ [a-z][A-Za-z0-9]*Action \} from '\.\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.action\.ts';$/u.test( - candidate, - ); + /^import \{ [a-z][A-Za-z0-9]*Action \} from '\.\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.action\.ts';$/u.test(candidate); -const coreCatalogValueEntry = (action: string): string => - `${toCamelCase(action)}Action.descriptor,`; +const coreCatalogValueEntry = (action: string): string => `${toCamelCase(action)}Action.descriptor,`; const isCoreActionCatalogValue = (candidate: string): boolean => /^[a-z][A-Za-z0-9]*Action\.descriptor,$/u.test(candidate); -const planCoreActionScaffold = Effect.fn('ActionScaffold.planCore')( - function* planCoreActionScaffold( - workspaceRoot: string, - moduleKeyInput: string, - action: string, - legalEntityScope: ActionScaffoldConfig['legalEntityScope'], - provisioning: ActionScaffoldConfig['provisioning'], - ) { - const moduleKey = yield* tryScaffold('Core module key is invalid', () => - requireCoreModuleKey(moduleKeyInput), - ); - const [actionPath, indexPath, catalogPath] = yield* tryScaffold( - 'failed to resolve Core Action paths', - () => - [ - resolveContainedPath( - workspaceRoot, - 'packages', - CORE_RUNTIME_DIRECTORY, - 'src', - 'modules', - 'actions', - `${action}.action.ts`, - ), - resolveContainedPath( - workspaceRoot, - 'packages', - CORE_RUNTIME_DIRECTORY, - 'src', - 'index.ts', - ), - resolveContainedPath( - workspaceRoot, - 'packages', - CORE_RUNTIME_DIRECTORY, - 'src', - 'modules', - 'actions', - 'catalog.ts', - ), - ] as const, - ); - const actionMutation = yield* createMutationEffect( - actionPath, - renderCoreAction(moduleKey, action, legalEntityScope, provisioning), - ); - const fileSystem = yield* FileSystem.FileSystem; - const indexContent = yield* fileSystem - .readFileString(indexPath) - .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read ${indexPath}`, cause))); - const nextIndex = yield* tryScaffold('failed to patch the Core Action export slot', () => - insertSortedSlot( - indexContent, - CORE_ACTION_SLOT_START, - CORE_ACTION_SLOT_END, - [coreExportEntry(action)], - isCoreActionExport, - ), - ); - const indexMutation = updateMutation(indexPath, indexContent, nextIndex); - const catalogContent = yield* fileSystem - .readFileString(catalogPath) - .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read ${catalogPath}`, cause))); - const nextCatalog = yield* tryScaffold('failed to patch the Core Action catalog', () => - insertSortedSlot( - insertSortedSlot( - catalogContent, - CORE_ACTION_CATALOG_IMPORT_SLOT_START, - CORE_ACTION_CATALOG_IMPORT_SLOT_END, - [coreCatalogImportEntry(action)], - isCoreActionCatalogImport, +const planCoreActionScaffold = Effect.fn('ActionScaffold.planCore')(function* planCoreActionScaffold( + workspaceRoot: string, + moduleKeyInput: string, + action: string, + legalEntityScope: ActionScaffoldConfig['legalEntityScope'], + provisioning: ActionScaffoldConfig['provisioning'], +) { + const moduleKey = yield* tryScaffold('Core module key is invalid', () => requireCoreModuleKey(moduleKeyInput)); + const [actionPath, indexPath, catalogPath] = yield* tryScaffold( + 'failed to resolve Core Action paths', + () => + [ + resolveContainedPath( + workspaceRoot, + 'packages', + CORE_RUNTIME_DIRECTORY, + 'src', + 'modules', + 'actions', + `${action}.action.ts`, ), - CORE_ACTION_CATALOG_VALUE_SLOT_START, - CORE_ACTION_CATALOG_VALUE_SLOT_END, - [coreCatalogValueEntry(action)], - isCoreActionCatalogValue, + resolveContainedPath(workspaceRoot, 'packages', CORE_RUNTIME_DIRECTORY, 'src', 'index.ts'), + resolveContainedPath( + workspaceRoot, + 'packages', + CORE_RUNTIME_DIRECTORY, + 'src', + 'modules', + 'actions', + 'catalog.ts', + ), + ] as const, + ); + const actionMutation = yield* createMutationEffect( + actionPath, + renderCoreAction(moduleKey, action, legalEntityScope, provisioning), + ); + const fileSystem = yield* FileSystem.FileSystem; + const indexContent = yield* fileSystem + .readFileString(indexPath) + .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read ${indexPath}`, cause))); + const nextIndex = yield* tryScaffold('failed to patch the Core Action export slot', () => + insertSortedSlot( + indexContent, + CORE_ACTION_SLOT_START, + CORE_ACTION_SLOT_END, + [coreExportEntry(action)], + isCoreActionExport, + ), + ); + const indexMutation = updateMutation(indexPath, indexContent, nextIndex); + const catalogContent = yield* fileSystem + .readFileString(catalogPath) + .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read ${catalogPath}`, cause))); + const nextCatalog = yield* tryScaffold('failed to patch the Core Action catalog', () => + insertSortedSlot( + insertSortedSlot( + catalogContent, + CORE_ACTION_CATALOG_IMPORT_SLOT_START, + CORE_ACTION_CATALOG_IMPORT_SLOT_END, + [coreCatalogImportEntry(action)], + isCoreActionCatalogImport, ), - ); - const catalogMutation = updateMutation(catalogPath, catalogContent, nextCatalog); - const mutations = [actionMutation, indexMutation, catalogMutation].filter( - (mutation) => mutation !== undefined, - ); - yield* tryScaffold('Core Action mutation paths are invalid', () => - ensureUniqueMutationPaths(mutations), - ); - return { mutations, result: { actionPath } }; - }, -); + CORE_ACTION_CATALOG_VALUE_SLOT_START, + CORE_ACTION_CATALOG_VALUE_SLOT_END, + [coreCatalogValueEntry(action)], + isCoreActionCatalogValue, + ), + ); + const catalogMutation = updateMutation(catalogPath, catalogContent, nextCatalog); + const mutations = [actionMutation, indexMutation, catalogMutation].filter((mutation) => mutation !== undefined); + yield* tryScaffold('Core Action mutation paths are invalid', () => ensureUniqueMutationPaths(mutations)); + return { mutations, result: { actionPath } }; +}); const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planActionScaffold( workspaceRoot: string, config: ActionScaffoldConfig, ) { - const action = yield* tryScaffold('Action name is invalid', () => - requireCanonicalSlug(config.action, 'action'), - ); + const action = yield* tryScaffold('Action name is invalid', () => requireCanonicalSlug(config.action, 'action')); if (config.scope === 'core') { return yield* planCoreActionScaffold( workspaceRoot, @@ -305,14 +287,7 @@ const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planAction } const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); const actionPath = yield* tryScaffold('failed to resolve Action path', () => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'src', - 'actions', - `${action}.action.ts`, - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'src', 'actions', `${action}.action.ts`), ); const actionMutation = yield* createMutationEffect( actionPath, @@ -358,11 +333,7 @@ const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planAction ), ] as const, ); - const manifestMutation = updateMutation( - vertical.manifestPath, - vertical.manifestContent, - nextManifest, - ); + const manifestMutation = updateMutation(vertical.manifestPath, vertical.manifestContent, nextManifest); const registrationMutation = updateMutation( vertical.registrationPath, vertical.registrationContent, @@ -371,15 +342,10 @@ const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planAction const dependencyMutation = yield* tryScaffold('failed to patch the Core dependency', () => withCoreDependency(vertical), ); - const mutations = [ - actionMutation, - manifestMutation, - registrationMutation, - dependencyMutation, - ].filter((mutation) => mutation !== undefined); - yield* tryScaffold('Action mutation paths are invalid', () => - ensureUniqueMutationPaths(mutations), + const mutations = [actionMutation, manifestMutation, registrationMutation, dependencyMutation].filter( + (mutation) => mutation !== undefined, ); + yield* tryScaffold('Action mutation paths are invalid', () => ensureUniqueMutationPaths(mutations)); return { mutations, result: { actionPath } }; }); diff --git a/app/scripts/scaffolding/cli.mts b/app/scripts/scaffolding/cli.mts index 0a46b111c..831dac898 100644 --- a/app/scripts/scaffolding/cli.mts +++ b/app/scripts/scaffolding/cli.mts @@ -1,28 +1,30 @@ #!/usr/bin/env node -import { NodeServices } from '@effect/platform-node'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; + +import { NodeServices } from '@effect/platform-node'; import { CodeSmith, FsMaterial, GeneratorCore } from '@modern-js/codesmith'; import type { GeneratorContext } from '@modern-js/codesmith'; import { Console, Effect, Option, Predicate, Schema } from 'effect'; import { Argument, CliConfig, Command, Flag, GlobalFlag } from 'effect/unstable/cli'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import actionGenerator from './action/scaffold.mts'; + import actionServiceGenerator from './action-service/scaffold.mts'; +import actionGenerator from './action/scaffold.mts'; import externalHttpAdapterGenerator from './external-http-adapter/scaffold.mts'; import actionBoundaryGenerator from './microvertical-action-boundary/scaffold.mts'; import microverticalPageGenerator from './microvertical-page/scaffold.mts'; +import moduleApiGenerator from './module-api/scaffold.mts'; import moduleContractGenerator from './module-contract/scaffold.mts'; import outboxMessageGenerator from './outbox-message/scaffold.mts'; import outboxWorkerGenerator from './outbox-worker/scaffold.mts'; import policyGenerator from './policy/scaffold.mts'; import publicComponentGenerator from './public-component/scaffold.mts'; -import moduleApiGenerator from './module-api/scaffold.mts'; import reportGenerator from './report/scaffold.mts'; import resourceGenerator from './resource/scaffold.mts'; import retireContributionGenerator from './retire-contribution/scaffold.mts'; -import searchProviderGenerator from './search-provider/scaffold.mts'; import searchProviderAccessGenerator from './search-provider-access/generator.mts'; +import searchProviderGenerator from './search-provider/scaffold.mts'; import type { ActionScaffoldConfig, ActionScaffoldResult, @@ -128,9 +130,7 @@ export interface RouteRefreshInput { readonly workspaceRoot: string; } -export type RouteRefreshExecutor = ( - input: RouteRefreshInput, -) => Effect.Effect; +export type RouteRefreshExecutor = (input: RouteRefreshInput) => Effect.Effect; export interface RunScaffoldOptions { readonly routeRefresh?: RouteRefreshExecutor; @@ -197,62 +197,60 @@ export type RunScaffoldResult = | { readonly help: string; readonly kind: 'help' } | { readonly kind: 'generated'; readonly result: GeneratorResult }; -const failScaffolding = ( - message: string, - cause?: unknown, -): Effect.Effect => +const failScaffolding = (message: string, cause?: unknown): Effect.Effect => Effect.fail(new ScaffoldingError(cause === undefined ? { message } : { cause, message })); -const runCodesmithGenerator = Effect.fn('runCodesmithGenerator')( - function* runCodesmithGeneratorEffect< - Config extends GeneratorConfig, - Result extends GeneratorResult, - >( - generator: LocalGenerator, - workspaceRoot: string, - config: Config, - ): Effect.fn.Return { - const prepared = yield* Effect.try({ - catch: (cause) => - new ScaffoldingError({ cause, message: 'failed to prepare the Codesmith generator' }), - try: () => { - const smith = new CodeSmith({ namespace: 'ontos-scaffolding' }); - const core = new GeneratorCore({ - logger: smith.logger, - materialsManager: smith.materialsManager, - outputPath: workspaceRoot, - }); - const workspaceMaterial = new FsMaterial(workspaceRoot); - const generatorMaterial = new FsMaterial(path.resolve(import.meta.dirname)); - core.addMaterial('default', workspaceMaterial); - core.addMaterial('ontos-local-generator', generatorMaterial); - core._context.config = config; - core._context.current = { material: generatorMaterial }; - const generatorContext = { ...core._context, config }; - return { core, generatorContext }; - }, - }); - const result = yield* generator(prepared.generatorContext, prepared.core).pipe( - Effect.catchDefect((cause) => - Effect.fail( - new ScaffoldingError({ - cause, - message: Predicate.isError(cause) ? cause.message : 'Codesmith generation failed', - }), - ), - ), - Effect.mapError( - (cause) => - new ScaffoldingError({ - cause, - message: Predicate.isError(cause) ? cause.message : 'Codesmith generation failed', - }), +const runCodesmithGenerator = Effect.fn('runCodesmithGenerator')(function* runCodesmithGeneratorEffect< + Config extends GeneratorConfig, + Result extends GeneratorResult, +>( + generator: LocalGenerator, + workspaceRoot: string, + config: Config, +): Effect.fn.Return { + const prepared = yield* Effect.try({ + catch: (cause) => + new ScaffoldingError({ + cause, + message: 'failed to prepare the Codesmith generator', + }), + try: () => { + const smith = new CodeSmith({ namespace: 'ontos-scaffolding' }); + const core = new GeneratorCore({ + logger: smith.logger, + materialsManager: smith.materialsManager, + outputPath: workspaceRoot, + }); + const workspaceMaterial = new FsMaterial(workspaceRoot); + const generatorMaterial = new FsMaterial(path.resolve(import.meta.dirname)); + core.addMaterial('default', workspaceMaterial); + core.addMaterial('ontos-local-generator', generatorMaterial); + core._context.config = config; + core._context.current = { material: generatorMaterial }; + const generatorContext = { ...core._context, config }; + return { core, generatorContext }; + }, + }); + const result = yield* generator(prepared.generatorContext, prepared.core).pipe( + Effect.catchDefect((cause) => + Effect.fail( + new ScaffoldingError({ + cause, + message: Predicate.isError(cause) ? cause.message : 'Codesmith generation failed', + }), ), - Effect.ensuring(Effect.sync(() => (prepared.core._context.current = null))), - ); - return result; - }, -); + ), + Effect.mapError( + (cause) => + new ScaffoldingError({ + cause, + message: Predicate.isError(cause) ? cause.message : 'Codesmith generation failed', + }), + ), + Effect.ensuring(Effect.sync(() => (prepared.core._context.current = null))), + ); + return result; +}); const defineCommand = ( definition: CommandDefinitionInput, @@ -282,7 +280,11 @@ const defaultRouteRefresh = ({ appId, workspaceRoot }: RouteRefreshInput) => ) .pipe( Effect.mapError( - (cause) => new ScaffoldingError({ cause, message: `route refresh failed for ${appId}` }), + (cause) => + new ScaffoldingError({ + cause, + message: `route refresh failed for ${appId}`, + }), ), ); if (exitCode !== ChildProcessSpawner.ExitCode(0)) { @@ -294,39 +296,29 @@ const defaultRouteRefresh = ({ appId, workspaceRoot }: RouteRefreshInput) => const LegalEntityScope = Schema.Literals(['required', 'optional', 'forbidden']); const isLegalEntityScope = Schema.is(LegalEntityScope); -const ReadAuthorization = Schema.Literals([ - 'authenticated_principal', - 'context_permission', - 'public', -]); +const ReadAuthorization = Schema.Literals(['authenticated_principal', 'context_permission', 'public']); const isReadAuthorization = Schema.is(ReadAuthorization); const RequestFilter = Schema.Literals(['includeArchived', 'role']); const isRequestFilter = Schema.is(RequestFilter); const requireReadAuthorization = ( flags: ParsedScaffoldFlags, -): Effect.Effect< - Pick, - ScaffoldingError -> => +): Effect.Effect, ScaffoldingError> => Effect.gen(function* requireReadAuthorizationEffect() { if (!isReadAuthorization(flags.authorizationMode)) { - return yield* failScaffolding( - '--authorization must be public, authenticated_principal, or context_permission', - ); + return yield* failScaffolding('--authorization must be public, authenticated_principal, or context_permission'); } if (flags.authorizationMode === 'context_permission') { if (flags.permission === undefined) { - return yield* failScaffolding( - '--permission is required for context_permission authorization', - ); + return yield* failScaffolding('--permission is required for context_permission authorization'); } - return { authorization: flags.authorizationMode, permission: flags.permission }; + return { + authorization: flags.authorizationMode, + permission: flags.permission, + }; } if (flags.permission !== undefined) { - return yield* failScaffolding( - '--permission is valid only for context_permission authorization', - ); + return yield* failScaffolding('--permission is valid only for context_permission authorization'); } return { authorization: flags.authorizationMode }; }); @@ -337,15 +329,7 @@ const isSearchLegalEntityScope = Schema.is(Schema.Literals(['required', 'optiona const commandDefinitions = { action: defineCommand({ - flags: [ - 'action', - 'authorization', - LEGAL_ENTITY_SCOPE_FLAG, - 'module', - 'provisioning', - 'scope', - 'vertical', - ], + flags: ['action', 'authorization', LEGAL_ENTITY_SCOPE_FLAG, 'module', 'provisioning', 'scope', 'vertical'], generator: actionGenerator, help: `Usage: pnpm scaffold:action -- --vertical --action --legal-entity-scope --authorization action_execution --provisioning @@ -372,24 +356,18 @@ Options: const action = flags.action ?? ''; const { legalEntityScope } = flags; if (!isLegalEntityScope(legalEntityScope)) { - return yield* failScaffolding( - '--legal-entity-scope must be required, optional, or forbidden', - ); + return yield* failScaffolding('--legal-entity-scope must be required, optional, or forbidden'); } if (flags.authorizationMode !== 'action_execution') { return yield* failScaffolding('--authorization must be action_execution for Actions'); } if (!isActionProvisioning(flags.provisioning)) { - return yield* failScaffolding( - '--provisioning must be tenant_membership_default or explicit', - ); + return yield* failScaffolding('--provisioning must be tenant_membership_default or explicit'); } const { module, scope, vertical } = flags; if (vertical !== undefined) { if (scope !== undefined || module !== undefined) { - return yield* failScaffolding( - '--vertical is mutually exclusive with --scope and --module', - ); + return yield* failScaffolding('--vertical is mutually exclusive with --scope and --module'); } return { action, @@ -520,9 +498,7 @@ Example: const vertical = flags.vertical ?? ''; const { url } = flags; const authorization = yield* requireReadAuthorization(flags); - return url === undefined - ? { ...authorization, page, vertical } - : { ...authorization, page, url, vertical }; + return url === undefined ? { ...authorization, page, vertical } : { ...authorization, page, url, vertical }; }), }), 'module-api': defineCommand({ @@ -617,9 +593,7 @@ Options: toConfig: (flags) => Effect.gen(function* outboxWorkerConfigEffect() { if (flags.authorizationMode !== 'owner_local_background') { - return yield* failScaffolding( - '--authorization must be owner_local_background for Outbox Workers', - ); + return yield* failScaffolding('--authorization must be owner_local_background for Outbox Workers'); } return { authorization: 'owner_local_background', @@ -814,29 +788,19 @@ Options: --tenant-permission read_party_identity Required exactly for tenant_scope --help Show this help without writing `, - requiredFlags: [ - ACCESS_FILTERING_FLAG, - LEGAL_ENTITY_SCOPE_FLAG, - 'name', - REQUEST_FILTERS_FLAG, - 'vertical', - ], + requiredFlags: [ACCESS_FILTERING_FLAG, LEGAL_ENTITY_SCOPE_FLAG, 'name', REQUEST_FILTERS_FLAG, 'vertical'], toConfig: (flags) => Effect.gen(function* searchProviderAccessConfigEffect() { const { accessFiltering, legalEntityScope, tenantPermission } = flags; const filters = (flags.requestFilters ?? '').split(',').filter((value) => value !== ''); if (!isAccessFiltering(accessFiltering)) { - return yield* failScaffolding( - '--access-filtering must be resource_permission or tenant_scope', - ); + return yield* failScaffolding('--access-filtering must be resource_permission or tenant_scope'); } if (!isSearchLegalEntityScope(legalEntityScope)) { return yield* failScaffolding('--legal-entity-scope must be required or optional'); } if (!filters.every(isRequestFilter)) { - return yield* failScaffolding( - '--request-filters may contain only includeArchived and role', - ); + return yield* failScaffolding('--request-filters may contain only includeArchived and role'); } if (tenantPermission !== undefined && tenantPermission !== 'read_party_identity') { return yield* failScaffolding('--tenant-permission must be read_party_identity'); @@ -861,8 +825,7 @@ export const isScaffoldCommand = Schema.is(ScaffoldCommandSchema); export const getHelpText = (command: ScaffoldCommand): string => commandDefinitions[command].help; -const isFlagArgument = (flag: string): boolean => - flag.startsWith('--') && flag !== '--' && !flag.includes('='); +const isFlagArgument = (flag: string): boolean => flag.startsWith('--') && flag !== '--' && !flag.includes('='); const parseFlagPair = ( command: ScaffoldCommand, @@ -873,9 +836,7 @@ const parseFlagPair = ( ) => Effect.gen(function* parseFlagPairEffect() { if (flag === undefined || !isFlagArgument(flag)) { - return yield* failScaffolding( - `invalid argument ${flag ?? ''}; use separate --flag value pairs`, - ); + return yield* failScaffolding(`invalid argument ${flag ?? ''}; use separate --flag value pairs`); } const name = flag.slice(2); if (!allowed.has(name)) { @@ -990,8 +951,7 @@ const cliFlags = { worker: optionalTextFlag('worker'), } as const; -const cliFlagName = (key: string): string => - key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); +const cliFlagName = (key: string): string => key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); const toCliArguments = ( values: Readonly>>>, @@ -1020,9 +980,7 @@ const cliSubcommands = scaffoldCommandValues.map((command) => command, { ...Object.fromEntries( - Object.entries(cliFlags).filter(([key]) => - commandDefinitions[command].flags.includes(cliFlagName(key)), - ), + Object.entries(cliFlags).filter(([key]) => commandDefinitions[command].flags.includes(cliFlagName(key))), ), forwarded: forwardedArguments, }, diff --git a/app/scripts/scaffolding/external-http-adapter/scaffold.mts b/app/scripts/scaffolding/external-http-adapter/scaffold.mts index fc7357d10..8f6a24931 100644 --- a/app/scripts/scaffolding/external-http-adapter/scaffold.mts +++ b/app/scripts/scaffolding/external-http-adapter/scaffold.mts @@ -1,5 +1,6 @@ -import { createCodesmithGenerator } from '../generator-adapter.mts'; import { Effect, Predicate } from 'effect'; + +import { createCodesmithGenerator } from '../generator-adapter.mts'; import { createMutationEffect, discoverOntosModuleEffect, @@ -21,16 +22,10 @@ import type { const preserveFileSystemCause = (failure: ScaffoldFailure): ScaffoldFailure => { const { cause } = failure; const underlying = Predicate.isError(cause) ? cause.cause : undefined; - return Predicate.isError(underlying) - ? scaffoldFailure(`${failure.message}: ${underlying.message}`, cause) - : failure; + return Predicate.isError(underlying) ? scaffoldFailure(`${failure.message}: ${underlying.message}`, cause) : failure; }; -const renderExternalHttpAdapter = ( - packageName: string, - provider: string, - operation: string, -): string => { +const renderExternalHttpAdapter = (packageName: string, provider: string, operation: string): string => { const providerType = toPascalCase(provider); const operationType = toPascalCase(operation); const adapterType = `${providerType}${operationType}`; @@ -80,10 +75,7 @@ export const ${adapterType}ServiceLive = Layer.effect( }; const planExternalHttpAdapterScaffold = Effect.fn('ExternalHttpAdapterScaffold.plan')( - function* planExternalHttpAdapterScaffold( - workspaceRoot: string, - config: ExternalHttpAdapterScaffoldConfig, - ) { + function* planExternalHttpAdapterScaffold(workspaceRoot: string, config: ExternalHttpAdapterScaffoldConfig) { const provider = yield* tryScaffold('provider name is invalid', () => requireCanonicalSlug(config.provider, 'provider'), ); diff --git a/app/scripts/scaffolding/generator-adapter.mts b/app/scripts/scaffolding/generator-adapter.mts index 5927d461c..f7d3dc537 100644 --- a/app/scripts/scaffolding/generator-adapter.mts +++ b/app/scripts/scaffolding/generator-adapter.mts @@ -1,6 +1,7 @@ import type { NodeServices } from '@effect/platform-node'; import type { GeneratorContext, GeneratorCore } from '@modern-js/codesmith'; import { Effect } from 'effect'; + import { applyMutationPlanEffect } from './shared.mts'; import type { ScaffoldPlan } from './shared.mts'; @@ -13,12 +14,7 @@ type EffectScaffoldPlanner = ( config: Config, ) => Effect.Effect, PlannerError, Services>; -export const createCodesmithGenerator = < - Config, - Result, - PlannerError, - Services extends NodeServices.NodeServices, ->( +export const createCodesmithGenerator = ( planner: EffectScaffoldPlanner, ) => Effect.fn('planAndApplyScaffold')(function* planAndApplyScaffold( diff --git a/app/scripts/scaffolding/governed-contribution/scaffold.mts b/app/scripts/scaffolding/governed-contribution/scaffold.mts index d970a677c..fbca5bbb9 100644 --- a/app/scripts/scaffolding/governed-contribution/scaffold.mts +++ b/app/scripts/scaffolding/governed-contribution/scaffold.mts @@ -1,5 +1,20 @@ -import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; import { SyntaxKind } from '@typescript/native/unstable/ast'; +import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; + +import { governedApiBinding, hasValidGovernedHttpCompositionRoot } from '../../generated-governed-http-boundary.mts'; +import { + hasGeneratedOperationGatewayContract, + hasGeneratedGovernedClientContract, + hasGeneratedModuleApiReadContract, + hasGeneratedModuleApiContract, + hasNamedImportBinding, + hasGeneratedProviderApiContract, + hasGeneratedProviderReadContract, + hasGeneratedOperationPrincipalContract, + hasUniqueExactNamedImport, + tokenizeGovernedClient, +} from '../../generated-module-api-boundary.mts'; +import { planActionBoundaryScaffold } from '../microvertical-action-boundary/scaffold.mts'; import { GOVERNED_HTTP_API_ADDITION_SLOT_END, GOVERNED_HTTP_API_ADDITION_SLOT_START, @@ -57,23 +72,6 @@ import { updateMutation, withExactDependencies, } from '../shared.mts'; -import { - governedApiBinding, - hasValidGovernedHttpCompositionRoot, -} from '../../generated-governed-http-boundary.mts'; -import { planActionBoundaryScaffold } from '../microvertical-action-boundary/scaffold.mts'; -import { - hasGeneratedOperationGatewayContract, - hasGeneratedGovernedClientContract, - hasGeneratedModuleApiReadContract, - hasGeneratedModuleApiContract, - hasNamedImportBinding, - hasGeneratedProviderApiContract, - hasGeneratedProviderReadContract, - hasGeneratedOperationPrincipalContract, - hasUniqueExactNamedImport, - tokenizeGovernedClient, -} from '../../generated-module-api-boundary.mts'; import type { GovernedContributionScaffoldConfig, Mutation, @@ -100,14 +98,9 @@ const isProviderContribution = Schema.is(ProviderContributionKindSchema); type GovernedToken = ReturnType[number]; -const propertyValueKind = ( - tokens: readonly GovernedToken[], - property: string, -): SyntaxKind | undefined => { +const propertyValueKind = (tokens: readonly GovernedToken[], property: string): SyntaxKind | undefined => { const [key, colon, value] = tokens; - return key?.kind === SyntaxKind.Identifier && - key.value === property && - colon?.kind === SyntaxKind.ColonToken + return key?.kind === SyntaxKind.Identifier && key.value === property && colon?.kind === SyntaxKind.ColonToken ? value?.kind : undefined; }; @@ -132,10 +125,7 @@ const directPropertyIndexes = ( return indexes; }; -const directTokenStringProperty = ( - tokens: readonly GovernedToken[], - property: string, -): string | undefined => { +const directTokenStringProperty = (tokens: readonly GovernedToken[], property: string): string | undefined => { const indexes = directPropertyIndexes(tokens, property, SyntaxKind.StringLiteral); const [index] = indexes; return indexes.length === 1 && index !== undefined ? tokens[index + 2]?.value : undefined; @@ -189,9 +179,7 @@ const insertSortedSlotIdempotently = ( ): string => { const entries = readGeneratedSlotEntries(content, start, end); if (entries.some((candidate) => !validateEntry(candidate))) { - return raiseScaffoldFailure( - `generated owner slot contains unsupported developer content: ${start}`, - ); + return raiseScaffoldFailure(`generated owner slot contains unsupported developer content: ${start}`); } if (generatedSlotContainsExactEntry(content, start, end, entry)) { return content; @@ -224,8 +212,7 @@ const manifestImport = (kind: GovernedContributionKind, name: string): string | const manifestImportIdentity = (kind: GovernedContributionKind, name: string) => ({ binding: `${toPascalCase(name)}${kind === PUBLIC_COMPONENT_KIND ? '' : 'Api'}`, - specifier: - kind === PUBLIC_COMPONENT_KIND ? `./src/components/${name}.tsx` : `./shared/apis/${name}.ts`, + specifier: kind === PUBLIC_COMPONENT_KIND ? `./src/components/${name}.tsx` : `./shared/apis/${name}.ts`, }); const renderPublicComponent = (name: string): string => { @@ -294,13 +281,8 @@ const renderReadAuthorization = ( config: Pick, ): string => { if (config.authorization === 'context_permission') { - if ( - config.permission === undefined || - !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(config.permission) - ) { - return raiseScaffoldFailure( - 'context_permission authorization requires a stable --permission value', - ); + if (config.permission === undefined || !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(config.permission)) { + return raiseScaffoldFailure('context_permission authorization requires a stable --permission value'); } return `{ kind: 'context_permission', permission: '${config.permission}' }`; } @@ -711,26 +693,21 @@ const patchGovernedHttpComposition = Effect.fn('GovernedContributionScaffold.pat const type = toPascalCase(name); const contractSuffix = kind === REPORT_KIND ? REPORT_KIND : 'search'; const contract = isModuleApi ? name : `${name}-${contractSuffix}`; - const apiValue = isModuleApi - ? `${type}Api` - : `${type}${kind === REPORT_KIND ? 'Report' : 'Search'}Api`; + const apiValue = isModuleApi ? `${type}Api` : `${type}${kind === REPORT_KIND ? 'Report' : 'Search'}Api`; const serverSuffix = isModuleApi ? 'read' : contractSuffix; const layerValue = `${toCamelCase(name)}ReadApiLive`; const sharedApiPath = yield* tryScaffold('failed to resolve governed HTTP API path', () => resolveContainedPath(vertical.directory, 'shared', 'api.ts'), ); - const handlerRootPath = yield* tryScaffold( - 'failed to resolve governed HTTP handler root path', - () => resolveContainedPath(vertical.directory, 'api', 'index.ts'), + const handlerRootPath = yield* tryScaffold('failed to resolve governed HTTP handler root path', () => + resolveContainedPath(vertical.directory, 'api', 'index.ts'), ); const fileSystem = yield* FileSystem.FileSystem; const [sharedApi, handlerRoot] = yield* Effect.all([ fileSystem .readFileString(sharedApiPath) .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to read governed HTTP API root ${sharedApiPath}`, cause), - ), + Effect.mapError((cause) => scaffoldFailure(`failed to read governed HTTP API root ${sharedApiPath}`, cause)), ), fileSystem .readFileString(handlerRootPath) @@ -741,18 +718,13 @@ const patchGovernedHttpComposition = Effect.fn('GovernedContributionScaffold.pat ), ]); if (!hasValidGovernedHttpCompositionRoot(sharedApi, handlerRoot)) { - return raiseScaffoldFailure( - 'governed HTTP composition slots are not bound to the exported runtime root', - ); + return raiseScaffoldFailure('governed HTTP composition slots are not bound to the exported runtime root'); } const nextSharedApi = yield* tryScaffold('failed to patch governed HTTP API root', () => insertSortedSlotIdempotently( insertSortedSlotIdempotently( sharedApi - .replace( - '// ;', - '// \n;', - ) + .replace('// ;', '// \n;') .replace( /(?:\/\*\* Canonical composition-root binding consumed by generated governed HTTP adapters\. \*\/\n)?export const governedHttpApi = [A-Za-z][A-Za-z0-9]*;\n?/u, '', @@ -805,8 +777,7 @@ const patchGovernedHttpComposition = Effect.fn('GovernedContributionScaffold.pat GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_START, GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_END, 'GovernedReadLayer.provide(GovernedReadLayer.mergeAll(GovernedActionPrincipalVerifierLive, GovernedGatewayAssertionRedemptionLive)),', - (candidate) => - candidate.startsWith('GovernedReadLayer.provide(') && candidate.endsWith('),'), + (candidate) => candidate.startsWith('GovernedReadLayer.provide(') && candidate.endsWith('),'), ); } return next; @@ -815,9 +786,8 @@ const patchGovernedHttpComposition = Effect.fn('GovernedContributionScaffold.pat const sharedApiMutation = yield* tryScaffold('failed to update governed HTTP API root', () => updateMutation(sharedApiPath, sharedApi, nextSharedApi), ); - const handlerRootMutation = yield* tryScaffold( - 'failed to update governed HTTP handler root', - () => updateMutation(handlerRootPath, handlerRoot, nextHandlerRoot), + const handlerRootMutation = yield* tryScaffold('failed to update governed HTTP handler root', () => + updateMutation(handlerRootPath, handlerRoot, nextHandlerRoot), ); if (sharedApiMutation !== undefined) { mutations.push(sharedApiMutation); @@ -862,11 +832,7 @@ const slotLine = ( if (kind === PUBLIC_COMPONENT_KIND) { return { manifest: [ - [ - MODULE_MANIFEST_COMPONENT_SLOT_START, - MODULE_MANIFEST_COMPONENT_SLOT_END, - `'${name}': ${toPascalCase(name)},`, - ], + [MODULE_MANIFEST_COMPONENT_SLOT_START, MODULE_MANIFEST_COMPONENT_SLOT_END, `'${name}': ${toPascalCase(name)},`], [ MODULE_MANIFEST_SHELL_COMPONENT_SLOT_START, MODULE_MANIFEST_SHELL_COMPONENT_SLOT_END, @@ -885,11 +851,7 @@ const slotLine = ( if (kind === MODULE_API_KIND) { return { manifest: [ - [ - MODULE_MANIFEST_API_SLOT_START, - MODULE_MANIFEST_API_SLOT_END, - `'${name}': ${toPascalCase(name)}Api,`, - ], + [MODULE_MANIFEST_API_SLOT_START, MODULE_MANIFEST_API_SLOT_END, `'${name}': ${toPascalCase(name)}Api,`], ], registration: [ [ @@ -974,10 +936,7 @@ const readStringArray = ( const directStringProperty = (source: string, property: string): string | undefined => directTokenStringProperty(tokenizeGovernedClient(source), property); -const directStringArrayProperty = ( - source: string, - property: string, -): readonly string[] | undefined => { +const directStringArrayProperty = (source: string, property: string): readonly string[] | undefined => { const tokens = tokenizeGovernedClient(source); const [index] = directPropertyIndexes(tokens, property, SyntaxKind.OpenBracketToken); return index === undefined ? undefined : readStringArray(tokens, index + 3); @@ -985,19 +944,14 @@ const directStringArrayProperty = ( // Owners may adapt accessFiltering/tenantPermission and report label/dimensions. These describe // presentation and report shape; the generated provider identity and resource ownership stay fixed. -const acceptsAdaptedProviderDescriptor = ( - start: string, - current: string, - expected: string, -): boolean => { +const acceptsAdaptedProviderDescriptor = (start: string, current: string, expected: string): boolean => { if (start !== MODULE_MANIFEST_SEARCH_SLOT_START && start !== MODULE_MANIFEST_REPORT_SLOT_START) { return false; } const requiredStringProperties = ['key', 'owningModuleId', 'resourceType']; if ( requiredStringProperties.some( - (property) => - directStringProperty(current, property) !== directStringProperty(expected, property), + (property) => directStringProperty(current, property) !== directStringProperty(expected, property), ) ) { return false; @@ -1015,8 +969,7 @@ const acceptsAdaptedProviderDescriptor = ( const accessFiltering = directStringProperty(current, 'accessFiltering'); return ( accessFiltering === 'resource_permission' || - (accessFiltering === 'tenant_scope' && - directStringProperty(current, 'tenantPermission') !== undefined) + (accessFiltering === 'tenant_scope' && directStringProperty(current, 'tenantPermission') !== undefined) ); }; @@ -1032,8 +985,7 @@ const structurallyMatchesGeneratedEntry = (current: string, expected: string): b return false; } const carriesIdentity = identityTokenKinds.has(expectedToken.kind); - const isPropertyKey = - index === 0 && carriesIdentity && identityTokenKinds.has(currentToken.kind); + const isPropertyKey = index === 0 && carriesIdentity && identityTokenKinds.has(currentToken.kind); return ( (isPropertyKey || currentToken.kind === expectedToken.kind) && (!carriesIdentity || currentToken.value === expectedToken.value) @@ -1049,15 +1001,11 @@ const patchSlots = ( slots.reduce((current, [start, end, line]) => { const entries = readGeneratedSlotEntries(current, start, end); if (entries.some((candidate) => !candidate.endsWith(','))) { - return raiseScaffoldFailure( - `generated owner slot contains unsupported developer content: ${start}`, - ); + return raiseScaffoldFailure(`generated owner slot contains unsupported developer content: ${start}`); } const identity = slotEntryIdentity(line); const allOwnerEntries = ownerSlots - .filter( - ([ownerStart, ownerEnd]) => current.includes(ownerStart) && current.includes(ownerEnd), - ) + .filter(([ownerStart, ownerEnd]) => current.includes(ownerStart) && current.includes(ownerEnd)) .flatMap(([ownerStart, ownerEnd]) => readGeneratedSlotEntries(current, ownerStart, ownerEnd).map((entry) => ({ entry, @@ -1065,9 +1013,7 @@ const patchSlots = ( })), ); if (allOwnerEntries.some(({ entry }) => slotEntryIdentity(entry) === undefined)) { - return raiseScaffoldFailure( - `generated owner slot contains unsupported developer content: ${start}`, - ); + return raiseScaffoldFailure(`generated owner slot contains unsupported developer content: ${start}`); } const identityMatches = allOwnerEntries.filter( ({ entry }) => identity !== undefined && slotEntryIdentity(entry) === identity, @@ -1107,16 +1053,10 @@ const patchFederationExposure = Effect.fn('GovernedContributionScaffold.patchFed const content = yield* fileSystem .readFileString(configPath) .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to read Module Federation config ${configPath}`, cause), - ), + Effect.mapError((cause) => scaffoldFailure(`failed to read Module Federation config ${configPath}`, cause)), ); const next = yield* tryScaffold('failed to patch Module Federation exposure', () => - insertModuleFederationExposure( - content, - `./${toPascalCase(name)}`, - `./src/components/${name}.tsx`, - ), + insertModuleFederationExposure(content, `./${toPascalCase(name)}`, `./src/components/${name}.tsx`), ); return { content: next, kind: 'update' as const, path: configPath }; }, @@ -1167,10 +1107,7 @@ const hasExistingOperationBoundary = ( Effect.gen(function* hasExistingOperationBoundaryEffect() { const fileSystem = yield* FileSystem.FileSystem; const { gatewayPath, principalPath } = operationBoundaryPaths(vertical); - const exists = yield* Effect.all([ - fileSystem.exists(principalPath), - fileSystem.exists(gatewayPath), - ]).pipe( + const exists = yield* Effect.all([fileSystem.exists(principalPath), fileSystem.exists(gatewayPath)]).pipe( Effect.mapError((cause) => scaffoldFailure('failed to inspect operation boundary', cause)), ); if (!exists.every(Boolean)) { @@ -1179,9 +1116,7 @@ const hasExistingOperationBoundary = ( const [principal, gateway] = yield* Effect.all([ fileSystem.readFileString(principalPath), fileSystem.readFileString(gatewayPath), - ]).pipe( - Effect.mapError((cause) => scaffoldFailure('failed to read operation boundary', cause)), - ); + ]).pipe(Effect.mapError((cause) => scaffoldFailure('failed to read operation boundary', cause))); const header = `// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n// @ontos-action-boundary-owner ${vertical.appId}\n`; return ( principal.startsWith(header) && @@ -1198,27 +1133,19 @@ const planOperationBoundary = Effect.fn('GovernedContributionScaffold.planOperat const existingBoundaryFiles = yield* Effect.all([ fileSystem.exists(principalPath), fileSystem.exists(gatewayPath), - ]).pipe( - Effect.mapError((cause) => - scaffoldFailure('failed to inspect operation boundary files', cause), - ), - ); + ]).pipe(Effect.mapError((cause) => scaffoldFailure('failed to inspect operation boundary files', cause))); if (existingBoundaryFiles.every(Boolean)) { - return yield* Effect.fail( - scaffoldFailure('refusing to overwrite existing business file: operation boundary'), - ); + return yield* Effect.fail(scaffoldFailure('refusing to overwrite existing business file: operation boundary')); } const boundary = yield* planActionBoundaryScaffold(workspaceRoot, { vertical: vertical.slug, }); return boundary.mutations; } - const dependencyMutation = yield* tryScaffold( - 'failed to ensure governed client dependency', - () => - withExactDependencies(vertical, { - '@app/shared-contracts': 'workspace:*', - }), + const dependencyMutation = yield* tryScaffold('failed to ensure governed client dependency', () => + withExactDependencies(vertical, { + '@app/shared-contracts': 'workspace:*', + }), ); return EffectArray.getSomes([Option.fromNullishOr(dependencyMutation)]); }, @@ -1249,93 +1176,85 @@ const acceptsGovernedArtifact = ( ); }; -const planGovernedTransport = Effect.fn('GovernedContributionScaffold.transport')( - function* planGovernedTransport( - workspaceRoot: string, - kind: GovernedContributionKind, - vertical: OntosVerticalMetadata, - name: string, - ) { - const isApi = kind === MODULE_API_KIND; - const mutations: Mutation[] = []; - let clientPath: string | undefined; - let serverPath: string | undefined; - if (kind !== PUBLIC_COMPONENT_KIND) { - const suffix = { - [MODULE_API_KIND]: 'client', - [REPORT_KIND]: 'report-client', - [SEARCH_PROVIDER_KIND]: 'search-client', - }[kind]; - clientPath = yield* tryScaffold('failed to resolve governed client path', () => - resolveContainedPath(vertical.directory, 'src', 'api', `${name}-${suffix}.ts`), - ); - const clientMutation = yield* createOrAcceptGeneratedMutationEffect( - clientPath, - isApi ? renderApiClient(vertical, name) : renderProviderClient(kind, vertical, name), - acceptsGeneratedClient(kind, vertical, name), - ); - mutations.push(...EffectArray.getSomes([clientMutation])); - if (isProviderContribution(kind)) { - const providerContractPath = yield* tryScaffold( - 'failed to resolve provider contract path', - () => - resolveContainedPath( - vertical.directory, - 'shared', - 'apis', - `${name}-${kind === REPORT_KIND ? REPORT_KIND : 'search'}.ts`, - ), - ); - const providerContractMutation = yield* createOrAcceptGeneratedMutationEffect( - providerContractPath, - renderProviderApiContract(kind, vertical, name), - (current) => - current.startsWith(`${generatedHeader(kind)}\n`) && - hasGeneratedProviderApiContract( - current, - `${toPascalCase(name)}${kind === REPORT_KIND ? 'Report' : 'Search'}Api`, - vertical.moduleId, - name, - kind === REPORT_KIND ? 'report' : 'search', - ), - ); - mutations.push(...EffectArray.getSomes([providerContractMutation])); - } - serverPath = yield* tryScaffold('failed to resolve governed server path', () => +const planGovernedTransport = Effect.fn('GovernedContributionScaffold.transport')(function* planGovernedTransport( + workspaceRoot: string, + kind: GovernedContributionKind, + vertical: OntosVerticalMetadata, + name: string, +) { + const isApi = kind === MODULE_API_KIND; + const mutations: Mutation[] = []; + let clientPath: string | undefined; + let serverPath: string | undefined; + if (kind !== PUBLIC_COMPONENT_KIND) { + const suffix = { + [MODULE_API_KIND]: 'client', + [REPORT_KIND]: 'report-client', + [SEARCH_PROVIDER_KIND]: 'search-client', + }[kind]; + clientPath = yield* tryScaffold('failed to resolve governed client path', () => + resolveContainedPath(vertical.directory, 'src', 'api', `${name}-${suffix}.ts`), + ); + const clientMutation = yield* createOrAcceptGeneratedMutationEffect( + clientPath, + isApi ? renderApiClient(vertical, name) : renderProviderClient(kind, vertical, name), + acceptsGeneratedClient(kind, vertical, name), + ); + mutations.push(...EffectArray.getSomes([clientMutation])); + if (isProviderContribution(kind)) { + const providerContractPath = yield* tryScaffold('failed to resolve provider contract path', () => resolveContainedPath( vertical.directory, - 'api', - `${name}-${{ [MODULE_API_KIND]: 'read', [REPORT_KIND]: REPORT_KIND, [SEARCH_PROVIDER_KIND]: 'search' }[kind]}-server.ts`, + 'shared', + 'apis', + `${name}-${kind === REPORT_KIND ? REPORT_KIND : 'search'}.ts`, ), ); - const sharedApiPath = yield* tryScaffold('failed to resolve governed API binding', () => - resolveContainedPath(vertical.directory, 'shared', 'api.ts'), - ); - const fileSystem = yield* FileSystem.FileSystem; - const sharedApi = yield* fileSystem - .readFileString(sharedApiPath) - .pipe( - Effect.mapError((cause) => scaffoldFailure('failed to read governed API binding', cause)), - ); - const apiBinding = governedApiBinding(sharedApi); - if (apiBinding === undefined) { - return raiseScaffoldFailure( - 'governed HTTP composition slots are not bound to the exported runtime root', - ); - } - const serverMutation = yield* createOrAcceptGeneratedMutationEffect( - serverPath, - renderGovernedServer(apiBinding, kind, name), - ); - mutations.push( - ...EffectArray.getSomes([serverMutation]), - ...(yield* patchGovernedHttpComposition(vertical, kind, name)), - ...(yield* planOperationBoundary(workspaceRoot, vertical)), + const providerContractMutation = yield* createOrAcceptGeneratedMutationEffect( + providerContractPath, + renderProviderApiContract(kind, vertical, name), + (current) => + current.startsWith(`${generatedHeader(kind)}\n`) && + hasGeneratedProviderApiContract( + current, + `${toPascalCase(name)}${kind === REPORT_KIND ? 'Report' : 'Search'}Api`, + vertical.moduleId, + name, + kind === REPORT_KIND ? 'report' : 'search', + ), ); + mutations.push(...EffectArray.getSomes([providerContractMutation])); } - return { clientPath, mutations, serverPath }; - }, -); + serverPath = yield* tryScaffold('failed to resolve governed server path', () => + resolveContainedPath( + vertical.directory, + 'api', + `${name}-${{ [MODULE_API_KIND]: 'read', [REPORT_KIND]: REPORT_KIND, [SEARCH_PROVIDER_KIND]: 'search' }[kind]}-server.ts`, + ), + ); + const sharedApiPath = yield* tryScaffold('failed to resolve governed API binding', () => + resolveContainedPath(vertical.directory, 'shared', 'api.ts'), + ); + const fileSystem = yield* FileSystem.FileSystem; + const sharedApi = yield* fileSystem + .readFileString(sharedApiPath) + .pipe(Effect.mapError((cause) => scaffoldFailure('failed to read governed API binding', cause))); + const apiBinding = governedApiBinding(sharedApi); + if (apiBinding === undefined) { + return raiseScaffoldFailure('governed HTTP composition slots are not bound to the exported runtime root'); + } + const serverMutation = yield* createOrAcceptGeneratedMutationEffect( + serverPath, + renderGovernedServer(apiBinding, kind, name), + ); + mutations.push( + ...EffectArray.getSomes([serverMutation]), + ...(yield* patchGovernedHttpComposition(vertical, kind, name)), + ...(yield* planOperationBoundary(workspaceRoot, vertical)), + ); + } + return { clientPath, mutations, serverPath }; +}); export const planGovernedContributionScaffold = Effect.fn('GovernedContributionScaffold.plan')( function* planGovernedContributionScaffold( @@ -1393,12 +1312,7 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS readSource, (current) => current.startsWith(`${generatedHeader(MODULE_API_KIND)}\n`) && - hasGeneratedModuleApiReadContract( - current, - vertical.moduleId, - name, - readAuthorizationExpectation(config), - ), + hasGeneratedModuleApiReadContract(current, vertical.moduleId, name, readAuthorizationExpectation(config)), ); mutations.push(...EffectArray.getSomes([readMutation])); } @@ -1410,16 +1324,10 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS let manifest = vertical.manifestContent; if ( ownerImport !== undefined && - !hasUniqueExactNamedImport( - manifest, - ownerImportIdentity.binding, - ownerImportIdentity.specifier, - ) + !hasUniqueExactNamedImport(manifest, ownerImportIdentity.binding, ownerImportIdentity.specifier) ) { if (hasNamedImportBinding(manifest, ownerImportIdentity.binding)) { - return yield* scaffoldFailure( - `generated owner import binding conflicts with ${ownerImportIdentity.binding}`, - ); + return yield* scaffoldFailure(`generated owner import binding conflicts with ${ownerImportIdentity.binding}`); } manifest = yield* tryScaffold('failed to patch module manifest imports', () => insertSortedSlotIdempotently( @@ -1444,15 +1352,11 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS const registrationMutation = yield* tryScaffold('failed to update module registration', () => updateMutation(vertical.registrationPath, vertical.registrationContent, registration), ); - mutations.push( - ...[manifestMutation, registrationMutation].filter((mutation) => mutation !== undefined), - ); + mutations.push(...[manifestMutation, registrationMutation].filter((mutation) => mutation !== undefined)); if (isComponent) { mutations.push(yield* patchFederationExposure(vertical, name)); } - yield* tryScaffold('governed contribution mutation paths are invalid', () => - ensureUniqueMutationPaths(mutations), - ); + yield* tryScaffold('governed contribution mutation paths are invalid', () => ensureUniqueMutationPaths(mutations)); const result = clientPath === undefined || serverPath === undefined ? { artifactPath } diff --git a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts index 9f7be6a15..838fefb83 100644 --- a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts @@ -1,4 +1,6 @@ import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; + +import { createCodesmithGenerator } from '../generator-adapter.mts'; import { createMutationEffect, discoverOntosModuleEffect, @@ -7,7 +9,6 @@ import { withExactDependencies, createScaffoldErrorTools, } from '../shared.mts'; -import { createCodesmithGenerator } from '../generator-adapter.mts'; import type { ActionBoundaryScaffoldConfig, ActionBoundaryScaffoldResult, @@ -17,8 +18,7 @@ import type { VerticalMetadata, } from '../shared.mts'; -const ACTION_BOUNDARY_GENERATOR_HEADER = - '// @generated by OntOS Codesmith MicroVertical Action Boundary v1'; +const ACTION_BOUNDARY_GENERATOR_HEADER = '// @generated by OntOS Codesmith MicroVertical Action Boundary v1'; const WORKSPACE_DEPENDENCY_VERSION = 'workspace:*'; class ActionBoundaryScaffoldError extends Schema.TaggedError()( @@ -40,11 +40,7 @@ const createOrAcceptOwnedMutation = ( content: string, requiredMarkers: readonly string[], requiredContract?: { readonly marker: string; readonly migration: string }, -): Effect.Effect< - Option.Option, - ActionBoundaryScaffoldError | ScaffoldFailure, - FileSystem.FileSystem -> => +): Effect.Effect, ActionBoundaryScaffoldError | ScaffoldFailure, FileSystem.FileSystem> => Effect.gen(function* createOrAcceptOwnedMutationEffect() { const fileSystem = yield* FileSystem.FileSystem; const exists = yield* fileSystem @@ -214,51 +210,21 @@ export const planActionBoundaryScaffold = ( Effect.gen(function* planActionBoundaryScaffoldEffect() { const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); const serverPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'api', - 'auth', - 'action-principal.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'api', 'auth', 'action-principal.ts'), ); const clientPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'src', - 'api', - 'action-gateway.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'src', 'api', 'action-gateway.ts'), ); const redemptionPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'api', - 'auth', - 'gateway-assertion-redemption.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'api', 'auth', 'gateway-assertion-redemption.ts'), ); const runnerPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'api', - 'action-http-runner.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'api', 'action-http-runner.ts'), ); const serverMutation = yield* createOrAcceptOwnedMutation( serverPath, renderActionPrincipalServer(vertical), - [ - `@ontos-action-boundary-owner ${vertical.appId}`, - `@ontos-action-boundary-audience ${vertical.appId}`, - ], + [`@ontos-action-boundary-owner ${vertical.appId}`, `@ontos-action-boundary-audience ${vertical.appId}`], { marker: 'export const authenticateOperationPrincipal', migration: @@ -274,18 +240,17 @@ export const planActionBoundaryScaffold = ( renderGatewayAssertionRedemptionAdapter(vertical), ['GatewayAssertionRedemption', `@ontos-action-boundary-owner ${vertical.appId}`], ); - const runnerMutation = yield* createOrAcceptOwnedMutation( - runnerPath, - renderActionHttpRunner(vertical), - ['bindActionHttpRunner', `@ontos-action-boundary-owner ${vertical.appId}`], - ); + const runnerMutation = yield* createOrAcceptOwnedMutation(runnerPath, renderActionHttpRunner(vertical), [ + 'bindActionHttpRunner', + `@ontos-action-boundary-owner ${vertical.appId}`, + ]); const dependencyMutation = yield* trySync(() => Option.fromNullishOr( withExactDependencies(vertical, { '@app/core-runtime': WORKSPACE_DEPENDENCY_VERSION, '@app/gateway-principal-verifier': WORKSPACE_DEPENDENCY_VERSION, '@app/shared-contracts': WORKSPACE_DEPENDENCY_VERSION, - effect: '4.0.0-beta.107', + effect: '4.0.0-rc.112', }), ), ); @@ -299,7 +264,13 @@ export const planActionBoundaryScaffold = ( yield* trySync(() => ensureUniqueMutationPaths(mutations)); return { mutations, - result: { appId: vertical.appId, clientPath, redemptionPath, runnerPath, serverPath }, + result: { + appId: vertical.appId, + clientPath, + redemptionPath, + runnerPath, + serverPath, + }, }; }); diff --git a/app/scripts/scaffolding/microvertical-page/scaffold.mts b/app/scripts/scaffolding/microvertical-page/scaffold.mts index 8083a2941..cff0fe20b 100644 --- a/app/scripts/scaffolding/microvertical-page/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-page/scaffold.mts @@ -1,6 +1,6 @@ import { Effect, Equal, FileSystem, Schema, Predicate } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; -import { tailwindPrefixForNamespace } from '../tailwind-prefix.mts'; import { MODULE_MANIFEST_COMPONENT_SLOT_END, MODULE_MANIFEST_COMPONENT_SLOT_START, @@ -16,6 +16,7 @@ import { createMutationEffect as createSharedMutation, discoverOntosModuleEffect as discoverSharedModule, ensureUniqueMutationPaths, + formatGeneratedMutationContent, generatedSlotContainsExactEntry, insertModuleFederationExposure, insertSortedSlot, @@ -40,6 +41,7 @@ import type { ScaffoldPlan, OntosVerticalMetadata, } from '../shared.mts'; +import { tailwindPrefixForNamespace } from '../tailwind-prefix.mts'; interface PageVerticalMetadata extends OntosVerticalMetadata { readonly locales: readonly string[]; @@ -87,10 +89,7 @@ const pageScaffoldFailureFromUnknown = (cause: unknown, fallback: string): PageS const discoverOntosModuleEffect = (workspaceRoot: string, requestedVertical: string) => discoverSharedModule(workspaceRoot, requestedVertical).pipe( Effect.mapError((cause) => - pageScaffoldFailureFromUnknown( - cause, - `vertical ${requestedVertical} could not be discovered`, - ), + pageScaffoldFailureFromUnknown(cause, `vertical ${requestedVertical} could not be discovered`), ), ); @@ -108,9 +107,7 @@ const mapFileSystemError = ( operation: Effect.Effect, ): Effect.Effect => operation.pipe( - Effect.mapError((cause) => - pageScaffoldFailureFromUnknown(cause, 'page scaffold file-system operation failed'), - ), + Effect.mapError((cause) => pageScaffoldFailureFromUnknown(cause, 'page scaffold file-system operation failed')), ); interface DirectoryEntry { @@ -159,8 +156,7 @@ const pageRouteIsInvalid = ( canonicalPath.length > 200 || canonicalSegments.length === 0 || canonicalSegments.some( - (segment) => - !staticRouteSegmentPattern.test(segment) && !parameterRouteSegmentPattern.test(segment), + (segment) => !staticRouteSegmentPattern.test(segment) && !parameterRouteSegmentPattern.test(segment), ) || new Set(parameterNames).size !== parameterNames.length || (requestedUrl === undefined && parameterNames.length > 0); @@ -172,9 +168,7 @@ const resolvePageRoute = ( ): Effect.Effect => Effect.gen(function* resolvePageRouteEffect() { const canonicalPath = requestedUrl ?? `/${vertical.slug}/${page}`; - const canonicalSegments = canonicalPath.startsWith('/') - ? canonicalPath.slice(1).split('/') - : []; + const canonicalSegments = canonicalPath.startsWith('/') ? canonicalPath.slice(1).split('/') : []; const parameterNames = canonicalSegments.flatMap((segment) => { const name = parameterRouteSegmentPattern.exec(segment)?.groups?.['name']; return name === undefined ? [] : [name]; @@ -185,9 +179,7 @@ const resolvePageRoute = ( ); } if (requestedUrl !== undefined && routeLocalePrefixPattern.test(canonicalSegments[0] ?? '')) { - return yield* pageScaffoldFailure( - '--url must not include a locale prefix; the localized router adds it', - ); + return yield* pageScaffoldFailure('--url must not include a locale prefix; the localized router adds it'); } const filesystemSegments = canonicalSegments.map((segment) => { const parameterName = parameterRouteSegmentPattern.exec(segment)?.groups?.['name']; @@ -209,8 +201,7 @@ const relativeFromRoute = (route: PageRoute, target: string, extraLevels = 0): s const renderRouteParameterSchemaFields = (componentName: string, route: PageRoute): string => route.parameterNames .map( - (name) => - ` ${name}: Schema.String.pipe(Schema.brand('${componentName}${toPascalCase(name)}RouteParameter')),`, + (name) => ` ${name}: Schema.String.pipe(Schema.brand('${componentName}${toPascalCase(name)}RouteParameter')),`, ) .join('\n'); @@ -224,9 +215,7 @@ const validateLocale = ( Effect.gen(function* validateLocaleEffect() { const expectedExport = `./locales/${locale}/${namespace}.json`; if (packageExports[`./locales/${locale}`] !== expectedExport) { - yield* pageScaffoldFailure( - `vertical ${vertical.slug} is missing its generated ${locale} locale export`, - ); + yield* pageScaffoldFailure(`vertical ${vertical.slug} is missing its generated ${locale} locale export`); } const localePath = resolveContainedPath( workspaceRoot, @@ -250,14 +239,9 @@ const discoverPageVertical = ( Effect.gen(function* discoverPageVerticalEffect() { const vertical = yield* discoverOntosModuleEffect(workspaceRoot, requestedVertical); const { topologyEntry } = vertical; - const namespace = requiredString( - topologyEntry['domain'], - `vertical ${vertical.slug} namespace`, - ); + const namespace = requiredString(topologyEntry['domain'], `vertical ${vertical.slug} namespace`); if (!namespacePattern.test(namespace)) { - return yield* pageScaffoldFailure( - `vertical ${vertical.slug} namespace is not a safe generated identifier`, - ); + return yield* pageScaffoldFailure(`vertical ${vertical.slug} namespace is not a safe generated identifier`); } const moduleFederation = asJsonObject( topologyEntry['moduleFederation'], @@ -268,9 +252,7 @@ const discoverPageVertical = ( `vertical ${vertical.slug} Module Federation boundary`, ); if (!moduleFederationNamePattern.test(mfBoundaryId)) { - return yield* pageScaffoldFailure( - `vertical ${vertical.slug} Module Federation boundary is invalid`, - ); + return yield* pageScaffoldFailure(`vertical ${vertical.slug} Module Federation boundary is invalid`); } const localeRoot = resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'locales'); if (!(yield* fileExists(localeRoot))) { @@ -288,18 +270,11 @@ const discoverPageVertical = ( } const unsupportedLocale = locales.find((locale) => !pageStarterLocales.has(locale)); if (unsupportedLocale !== undefined) { - return yield* pageScaffoldFailure( - `page scaffold has no starter translation for locale ${unsupportedLocale}`, - ); + return yield* pageScaffoldFailure(`page scaffold has no starter translation for locale ${unsupportedLocale}`); } - const packageExports = asJsonObject( - vertical.packageJson['exports'], - `vertical ${vertical.slug} package exports`, - ); + const packageExports = asJsonObject(vertical.packageJson['exports'], `vertical ${vertical.slug} package exports`); yield* Effect.all( - locales.map((locale) => - validateLocale(workspaceRoot, vertical, namespace, packageExports, locale), - ), + locales.map((locale) => validateLocale(workspaceRoot, vertical, namespace, packageExports, locale)), { concurrency: 'unbounded' }, ); const routeHeadPath = resolveContainedPath( @@ -311,9 +286,7 @@ const discoverPageVertical = ( 'ultramodern-route-head.tsx', ); if (!(yield* fileExists(routeHeadPath))) { - return yield* pageScaffoldFailure( - `vertical ${vertical.slug} generated UltramodernRouteHead is missing`, - ); + return yield* pageScaffoldFailure(`vertical ${vertical.slug} generated UltramodernRouteHead is missing`); } const resourcesName = `${toCamelCase(vertical.slug)}I18nResources`; const resourcesPath = resolveContainedPath( @@ -325,9 +298,7 @@ const discoverPageVertical = ( 'resources.ts', ); if (!(yield* fileExists(resourcesPath))) { - return yield* pageScaffoldFailure( - `vertical ${vertical.slug} generated i18n resources are missing`, - ); + return yield* pageScaffoldFailure(`vertical ${vertical.slug} generated i18n resources are missing`); } const resourcesContent = yield* readTextFile(resourcesPath); if (!resourcesContent.includes(`export const ${resourcesName} =`)) { @@ -397,9 +368,7 @@ export default ${componentName}; `; }; -const renderReadAuthorization = ( - config: Pick, -): string => { +const renderReadAuthorization = (config: Pick): string => { if (config.authorization === 'context_permission') { return `{ kind: 'context_permission', permission: '${config.permission ?? ''}' }`; } @@ -412,12 +381,9 @@ const validateReadAuthorization = ( Effect.gen(function* validateReadAuthorizationEffect() { if ( config.authorization === 'context_permission' && - (config.permission === undefined || - !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(config.permission)) + (config.permission === undefined || !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(config.permission)) ) { - yield* pageScaffoldFailure( - 'context_permission authorization requires a stable --permission value', - ); + yield* pageScaffoldFailure('context_permission authorization requires a stable --permission value'); } if (config.authorization !== 'context_permission' && config.permission !== undefined) { yield* pageScaffoldFailure('--permission is valid only for context_permission authorization'); @@ -449,11 +415,7 @@ const pageWiring = ( } as const; }; -const renderFederatedPage = ( - vertical: PageVerticalMetadata, - page: string, - route: PageRoute, -): string => { +const renderFederatedPage = (vertical: PageVerticalMetadata, page: string, route: PageRoute): string => { const componentName = `${toPascalCase(page)}Page`; const federatedComponentName = `${toPascalCase(page)}FederatedPage`; const resourcesName = `${toCamelCase(vertical.slug)}I18nResources`; @@ -467,9 +429,7 @@ const renderFederatedPage = ( const declaration = route.isDynamic ? `const ${federatedComponentName} = ({ routeParams }: ${federatedComponentName}Props) => (` : `const ${federatedComponentName} = () => (`; - const ownerPage = route.isDynamic - ? `<${componentName} routeParams={routeParams} />` - : `<${componentName} />`; + const ownerPage = route.isDynamic ? `<${componentName} routeParams={routeParams} />` : `<${componentName} />`; const ownerPageImport = route.isDynamic ? `import { ${componentName}, @@ -554,9 +514,7 @@ const renderRouteMetadata = ( config: Pick, ): string => { const keyRoot = `${vertical.namespace}.pages.${toCamelCase(page)}`; - const localisedPaths = vertical.locales - .map((locale) => ` ${locale}: '${route.canonicalPath}',`) - .join('\n'); + const localisedPaths = vertical.locales.map((locale) => ` ${locale}: '${route.canonicalPath}',`).join('\n'); return `import { defineTenantModuleEntrypoint } from '@app/core-runtime'; const routeMeta = { @@ -592,11 +550,7 @@ const renderShellConnectorPage = (route: PageRoute): string => `export { default } from '${relativeFromRoute(route, 'modules/[moduleId]/page.tsx')}'; `; -const renderShellConnectorLoader = ( - vertical: PageVerticalMetadata, - page: string, - route: PageRoute, -): string => { +const renderShellConnectorLoader = (vertical: PageVerticalMetadata, page: string, route: PageRoute): string => { const loaderImport = route.isDynamic ? `{ loader as loadModuleTarget, @@ -645,9 +599,7 @@ const renderShellConnectorMetadata = ( route: PageRoute, config: Pick, ): string => { - const localisedPaths = vertical.locales - .map((locale) => ` ${locale}: '${route.canonicalPath}',`) - .join('\n'); + const localisedPaths = vertical.locales.map((locale) => ` ${locale}: '${route.canonicalPath}',`).join('\n'); return `import { defineSystemModuleEntrypoint } from '@app/core-runtime'; const routeMeta = { @@ -744,10 +696,7 @@ interface OwnedPageRoute { readonly routePath: string; } -const ownedPageRoute = ( - owner: string, - candidate: string, -): Effect.Effect => +const ownedPageRoute = (owner: string, candidate: string): Effect.Effect => Effect.gen(function* ownedPageRouteEffect() { const matches = [...candidate.matchAll(/\broutePath:\s*'(?\/[^']+)'/gu)]; const routePath = matches[0]?.groups?.['routePath']; @@ -786,9 +735,7 @@ const ownedPageRoutes = ( const verticalRoot = resolveContainedPath(workspaceRoot, 'verticals'); const verticals = yield* readDirectoryEntries(verticalRoot); const ownedRoutes = yield* Effect.all( - verticals - .filter((entry) => entry.isDirectory) - .map((entry) => readOwnerRoutes(verticalRoot, entry.name)), + verticals.filter((entry) => entry.isDirectory).map((entry) => readOwnerRoutes(verticalRoot, entry.name)), { concurrency: 'unbounded' }, ); return ownedRoutes.flat(); @@ -803,21 +750,14 @@ const routeCollisionIdentity = (routePath: string): string => .map((segment) => (parameterRouteSegmentPattern.test(segment) ? ':parameter' : segment)) .join('/'); -const assertShellRouteSiblingsAreAvailable = ( - entries: readonly DirectoryEntry[], - segment: string, - route: PageRoute, -) => +const assertShellRouteSiblingsAreAvailable = (entries: readonly DirectoryEntry[], segment: string, route: PageRoute) => Effect.gen(function* assertShellRouteSiblingsAreAvailableEffect() { const desiredSegmentIsDynamic = isDynamicShellRouteSegment(segment); const siblingCollision = entries.find( - (entry) => - entry.isDirectory && (desiredSegmentIsDynamic || isDynamicShellRouteSegment(entry.name)), + (entry) => entry.isDirectory && (desiredSegmentIsDynamic || isDynamicShellRouteSegment(entry.name)), ); if (siblingCollision !== undefined) { - const collisionKind = isDynamicShellRouteSegment(siblingCollision.name) - ? 'dynamic' - : 'static'; + const collisionKind = isDynamicShellRouteSegment(siblingCollision.name) ? 'dynamic' : 'static'; yield* pageScaffoldFailure( `Shell route ${route.canonicalPath} collides with ${collisionKind} route segment ${siblingCollision.name}`, ); @@ -843,14 +783,10 @@ const assertShellRouteSegmentIsAvailable = ( } const child = resolveContainedPath(parent, segment); if (index === route.filesystemSegments.length - 1) { - yield* pageScaffoldFailure( - `Shell route already exists or collides with generated page: ${child}`, - ); + yield* pageScaffoldFailure(`Shell route already exists or collides with generated page: ${child}`); } if (!childEntry.isDirectory) { - yield* pageScaffoldFailure( - `Shell route ${route.canonicalPath} collides with reserved route content`, - ); + yield* pageScaffoldFailure(`Shell route ${route.canonicalPath} collides with reserved route content`); } const prefix = `/${route.canonicalSegments.slice(0, index + 1).join('/')}`; const ownsPrefix = registeredRoutes.has(prefix); @@ -862,9 +798,7 @@ const assertShellRouteSegmentIsAvailable = ( { concurrency: 'unbounded' }, ); if ((pageRouteExists || routeMetadataExists) && !ownsPrefix) { - yield* pageScaffoldFailure( - `Shell route ${route.canonicalPath} uses reserved route prefix ${prefix}`, - ); + yield* pageScaffoldFailure(`Shell route ${route.canonicalPath} uses reserved route prefix ${prefix}`); } yield* assertShellRouteSegmentIsAvailable(child, index + 1, route, registeredRoutes); }); @@ -881,18 +815,6 @@ const assertShellRouteIsAvailable = ( registeredRoutes, ); -const resolveGeneratedPageContentState = ( - pageContent: string, - vertical: PageVerticalMetadata, - page: string, - route: PageRoute, -): GeneratedPageState => { - if (pageContent === renderPage(vertical, page, route)) { - return 'current'; - } - return 'invalid'; -}; - const generatedWiringEntryMatches = ( content: string, startMarker: string, @@ -910,13 +832,17 @@ const generatedFileMatches = ( filePath: string, expected: string, ): Effect.Effect => - fileExists(filePath).pipe( - Effect.flatMap((exists) => - exists - ? readTextFile(filePath).pipe(Effect.map((content) => content === expected)) - : Effect.succeed(false), - ), - ); + Effect.gen(function* generatedFileMatchesProgram() { + if (!(yield* fileExists(filePath))) { + return false; + } + const content = yield* readTextFile(filePath); + const [actual, generated] = yield* Effect.all([ + formatGeneratedMutationContent(filePath, content), + formatGeneratedMutationContent(filePath, expected), + ]).pipe(Effect.mapError((cause) => pageScaffoldFailureFromUnknown(cause, 'generated page formatting failed'))); + return actual === generated; + }); const generatedWiringContentMatches = ( vertical: PageVerticalMetadata, @@ -975,16 +901,7 @@ const generatedWiringMatches = ( const [federation, shellClients] = yield* Effect.all( [ readTextFile(federationPath), - readTextFile( - resolveContainedPath( - workspaceRoot, - 'apps', - SHELL_APP_ID, - 'src', - 'api', - 'vertical-clients.ts', - ), - ), + readTextFile(resolveContainedPath(workspaceRoot, 'apps', SHELL_APP_ID, 'src', 'api', 'vertical-clients.ts')), ], { concurrency: 'unbounded' }, ); @@ -1012,18 +929,15 @@ const generatedWiringMatches = ( const shellRouteInventoryMatches = shellRouteEntries.length === expectedShellFiles.length && shellRouteEntries.every( - (entry) => - entry.isFile && expectedShellFiles.some(([expectedName]) => expectedName === entry.name), + (entry) => entry.isFile && expectedShellFiles.some(([expectedName]) => expectedName === entry.name), ); const exposureKey = `./Page${toPascalCase(page)}`; const expectedExposureSource = `./src/federation/page-${page}.tsx`; const exposureSource = moduleFederationExposureSource(federation, exposureKey); const federatedPagePath = resolveContainedPath(vertical.directory, expectedExposureSource); - const federatedPageExists = yield* fileExists(federatedPagePath); const federationMatches = exposureSource === expectedExposureSource && - federatedPageExists && - (yield* readTextFile(federatedPagePath)) === renderFederatedPage(vertical, page, route); + (yield* generatedFileMatches(federatedPagePath, renderFederatedPage(vertical, page, route))); const escapedModuleId = vertical.moduleId.replaceAll('.', String.raw`\.`); const navigationMatches = wiring.manifestNavigation === undefined @@ -1033,20 +947,16 @@ const generatedWiringMatches = ( MODULE_MANIFEST_SHELL_NAVIGATION_SLOT_END, ).every( (entry) => - !new RegExp( - `\\bcontributionKey\\s*:\\s*["']${escapedModuleId}\\.navigation\\.${page}["']`, - 'u', - ).test(entry), + !new RegExp(`\\bcontributionKey\\s*:\\s*["']${escapedModuleId}\\.navigation\\.${page}["']`, 'u').test( + entry, + ), ) : generatedWiringEntryMatches( vertical.manifestContent, MODULE_MANIFEST_SHELL_NAVIGATION_SLOT_START, MODULE_MANIFEST_SHELL_NAVIGATION_SLOT_END, wiring.manifestNavigation, - new RegExp( - `\\bcontributionKey\\s*:\\s*["']${vertical.moduleId}\\.navigation\\.${page}["']`, - 'u', - ), + new RegExp(`\\bcontributionKey\\s*:\\s*["']${vertical.moduleId}\\.navigation\\.${page}["']`, 'u'), ); return ( generatedWiringContentMatches(vertical, page, wiring, shellClients, navigationMatches) && @@ -1105,31 +1015,22 @@ const generatedPageState = ( ) { return 'invalid'; } - const [pageContent, routeMetadataContent] = yield* Effect.all( - [readTextFile(pagePath), readTextFile(routeMetadataPath)], - { concurrency: 'unbounded' }, - ); - const expectedMetadata = renderRouteMetadata(vertical, page, route, config); - if (routeMetadataContent !== expectedMetadata) { - return 'invalid'; - } - const pageState = resolveGeneratedPageContentState(pageContent, vertical, page, route); - if (pageState === 'invalid') { + const fileMatches = yield* Effect.all([ + generatedFileMatches(pagePath, renderPage(vertical, page, route)), + generatedFileMatches(routeMetadataPath, renderRouteMetadata(vertical, page, route, config)), + ]); + if (!fileMatches.every(Boolean)) { return 'invalid'; } const pageKey = toCamelCase(page); const localeStates = yield* Effect.all( - vertical.locales.map((locale) => - generatedLocaleState(workspaceRoot, vertical, locale, pageKey), - ), + vertical.locales.map((locale) => generatedLocaleState(workspaceRoot, vertical, locale, pageKey)), { concurrency: 'unbounded' }, ); - if (!localeStates.every((state) => state === pageState)) { + if (!localeStates.every((state) => state === 'current')) { return 'invalid'; } - return (yield* generatedWiringMatches(workspaceRoot, vertical, page, route, config)) - ? pageState - : 'invalid'; + return (yield* generatedWiringMatches(workspaceRoot, vertical, page, route, config)) ? 'current' : 'invalid'; }); const planPageScaffold = ( @@ -1181,9 +1082,7 @@ const planPageScaffold = ( result: { appId: vertical.appId, pagePath, routeMetadataPath }, }; } - return yield* pageScaffoldFailure( - `page route already exists or collides with nested content: ${routeDirectory}`, - ); + return yield* pageScaffoldFailure(`page route already exists or collides with nested content: ${routeDirectory}`); } const identity = `${vertical.moduleId}.page.${page}`; const pageComponentIdentity = new RegExp(`["']page-${page}["']\\s*:`, 'u'); @@ -1197,9 +1096,7 @@ const planPageScaffold = ( } const registeredRoutes = yield* ownedPageRoutes(workspaceRoot); const existingRouteOwner = registeredRoutes.find( - (registered) => - routeCollisionIdentity(registered.routePath) === - routeCollisionIdentity(route.canonicalPath), + (registered) => routeCollisionIdentity(registered.routePath) === routeCollisionIdentity(route.canonicalPath), ); if (existingRouteOwner !== undefined) { const reason = @@ -1214,12 +1111,7 @@ const planPageScaffold = ( new Set(registeredRoutes.map((registered) => registered.routePath)), ); const federationPath = resolveContainedPath(vertical.directory, 'module-federation.config.ts'); - const federatedPagePath = resolveContainedPath( - vertical.directory, - 'src', - 'federation', - `page-${page}.tsx`, - ); + const federatedPagePath = resolveContainedPath(vertical.directory, 'src', 'federation', `page-${page}.tsx`); const shellClientsPath = resolveContainedPath( workspaceRoot, 'apps', @@ -1228,34 +1120,25 @@ const planPageScaffold = ( 'api', 'vertical-clients.ts', ); - const [pageMutation, routeMutation, localeMutations, federationContent, shellClientsContent] = - yield* Effect.all( - [ - createMutationEffect( - pagePath, - renderPage(vertical, page, route), - 'page route could not be created', - ), - createMutationEffect( - routeMetadataPath, - renderRouteMetadata(vertical, page, route, config), - 'page route metadata could not be created', - ), - Effect.all( - vertical.locales.map((locale) => patchLocale(workspaceRoot, vertical, locale, page)), - { concurrency: 'unbounded' }, - ), - readTextFile(federationPath), - readTextFile(shellClientsPath), - ], - { concurrency: 'unbounded' }, - ); - const wiring = patchPageWiring(vertical, page, route, config); - const manifestMutation = updateMutation( - vertical.manifestPath, - vertical.manifestContent, - wiring.manifest, + const [pageMutation, routeMutation, localeMutations, federationContent, shellClientsContent] = yield* Effect.all( + [ + createMutationEffect(pagePath, renderPage(vertical, page, route), 'page route could not be created'), + createMutationEffect( + routeMetadataPath, + renderRouteMetadata(vertical, page, route, config), + 'page route metadata could not be created', + ), + Effect.all( + vertical.locales.map((locale) => patchLocale(workspaceRoot, vertical, locale, page)), + { concurrency: 'unbounded' }, + ), + readTextFile(federationPath), + readTextFile(shellClientsPath), + ], + { concurrency: 'unbounded' }, ); + const wiring = patchPageWiring(vertical, page, route, config); + const manifestMutation = updateMutation(vertical.manifestPath, vertical.manifestContent, wiring.manifest); const registrationMutation = updateMutation( vertical.registrationPath, vertical.registrationContent, diff --git a/app/scripts/scaffolding/module-api/scaffold.mts b/app/scripts/scaffolding/module-api/scaffold.mts index eec0b63b7..f4d725ab6 100644 --- a/app/scripts/scaffolding/module-api/scaffold.mts +++ b/app/scripts/scaffolding/module-api/scaffold.mts @@ -2,7 +2,6 @@ import { createCodesmithGenerator } from '../generator-adapter.mts'; import { planGovernedContributionScaffold } from '../governed-contribution/scaffold.mts'; import type { GovernedContributionScaffoldConfig } from '../shared.mts'; -export default createCodesmithGenerator( - (workspaceRoot: string, config: GovernedContributionScaffoldConfig) => - planGovernedContributionScaffold(workspaceRoot, 'module-api', config), +export default createCodesmithGenerator((workspaceRoot: string, config: GovernedContributionScaffoldConfig) => + planGovernedContributionScaffold(workspaceRoot, 'module-api', config), ); diff --git a/app/scripts/scaffolding/module-contract/scaffold.mts b/app/scripts/scaffolding/module-contract/scaffold.mts index d952dd830..7773544a2 100644 --- a/app/scripts/scaffolding/module-contract/scaffold.mts +++ b/app/scripts/scaffolding/module-contract/scaffold.mts @@ -1,5 +1,6 @@ -import { topLevelSeparators } from '../../boundary-source-structure.mts'; import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; + +import { topLevelSeparators } from '../../boundary-source-structure.mts'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_CONTRACT_GENERATOR_HEADER, @@ -91,9 +92,7 @@ import type { const moduleMarkerPattern = /^\/\/ @ontos-module-id (?[^\s]+)$/mu; const MANIFEST_FILE_NAME = 'vertical.manifest.ts'; -const renderGovernedHttpApiRoot = ( - vertical: VerticalMetadata, -): string => `${MODULE_CONTRACT_GENERATOR_HEADER} +const renderGovernedHttpApiRoot = (vertical: VerticalMetadata): string => `${MODULE_CONTRACT_GENERATOR_HEADER} // @ontos-deployment-app-id ${vertical.appId} import { HttpApi } from '@modern-js/plugin-bff/effect-client'; import { identity } from 'effect'; @@ -116,18 +115,12 @@ const initializeGovernedHttpApiRoot = (source: string, vertical: VerticalMetadat source.includes(GOVERNED_HTTP_API_ADDITION_SLOT_START) || source.includes('governedHttpApi') ) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} shared API already uses reserved governed-read composition`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} shared API already uses reserved governed-read composition`); } const structure = maskNonCode(source); - const declarations = [ - ...structure.matchAll(/export const (?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApi\.make\(/gu), - ]; + const declarations = [...structure.matchAll(/export const (?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApi\.make\(/gu)]; if (declarations.length !== 1) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} shared API must contain exactly one generated HttpApi root`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} shared API must contain exactly one generated HttpApi root`); } const [declaration] = declarations; const apiValue = declaration?.groups?.['api']; @@ -142,9 +135,12 @@ const initializeGovernedHttpApiRoot = (source: string, vertical: VerticalMetadat return `${source.slice(0, declarationStart)}${GOVERNED_HTTP_API_IMPORT_SLOT_START} ${GOVERNED_HTTP_API_IMPORT_SLOT_END} +import { identity as governedHttpApiIdentity } from 'effect'; + ${source.slice(declarationStart, statementEnd)} ${GOVERNED_HTTP_API_ADDITION_SLOT_START} - ${GOVERNED_HTTP_API_ADDITION_SLOT_END}${source.slice(statementEnd, statementEnd + 1)} + ${GOVERNED_HTTP_API_ADDITION_SLOT_END} + .pipe(governedHttpApiIdentity)${source.slice(statementEnd, statementEnd + 1)} /** Canonical composition-root binding consumed by generated governed HTTP adapters. */ export const governedHttpApi = ${apiValue};${source.slice(statementEnd + 1)}`; @@ -170,18 +166,11 @@ const initializeGovernedHttpHandlerRoot = (source: string, vertical: VerticalMet const runtimeLayerNeedle = ') satisfies EffectRuntimeLayer;'; const runtimeLayerEnd = source.lastIndexOf(runtimeLayerNeedle); if (runtimeLayerEnd === -1) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} API root must expose the pinned Effect runtime layer`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} API root must expose the pinned Effect runtime layer`); } - const runtimeLayerStart = source.lastIndexOf( - 'const layer = HttpApiBuilder.layer(', - runtimeLayerEnd, - ); + const runtimeLayerStart = source.lastIndexOf('const layer = HttpApiBuilder.layer(', runtimeLayerEnd); if (runtimeLayerStart === -1) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} API root must contain the pinned HttpApiBuilder layer`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} API root must contain the pinned HttpApiBuilder layer`); } const generatedRoot = `import { ContextAccessLive as GovernedContextAccessLive, @@ -255,11 +244,7 @@ const { scaffoldError, trySync } = createScaffoldErrorTools( 'module contract update failed', ); -const readModuleOwner = ( - fileSystem: FileSystem.FileSystem, - verticalsRoot: string, - entryName: string, -) => { +const readModuleOwner = (fileSystem: FileSystem.FileSystem, verticalsRoot: string, entryName: string) => { const manifestPath = resolveContainedPath(verticalsRoot, entryName, MANIFEST_FILE_NAME); return Effect.gen(function* readModuleOwnerEffect() { const exists = yield* fileSystem @@ -289,9 +274,7 @@ const assertUniqueModuleId = ( const entries = yield* fileSystem .readDirectory(verticalsRoot) .pipe( - Effect.mapError((cause) => - scaffoldError(`failed to inspect generated verticals at ${verticalsRoot}`, cause), - ), + Effect.mapError((cause) => scaffoldError(`failed to inspect generated verticals at ${verticalsRoot}`, cause)), ); const owners = yield* Effect.forEach( entries.filter((entryName) => entryName !== targetSlug), @@ -300,9 +283,7 @@ const assertUniqueModuleId = ( ); const duplicate = owners.find((owner) => owner?.moduleId === moduleId); if (duplicate !== undefined && duplicate !== null) { - return yield* scaffoldError( - `duplicate OntOS module ID ${moduleId} in vertical ${duplicate.entryName}`, - ); + return yield* scaffoldError(`duplicate OntOS module ID ${moduleId} in vertical ${duplicate.entryName}`); } return yield* Effect.void; }); @@ -479,21 +460,14 @@ const addArtifactCommand = ( label: string, ): Effect.Effect => Effect.gen(function* addArtifactCommandEffect() { - const script = yield* trySync(() => - requiredString(current, `vertical ${vertical.slug} ${label} script`), - ); + const script = yield* trySync(() => requiredString(current, `vertical ${vertical.slug} ${label} script`)); const command = `node ../../scripts/generate-ontos-module-contract.mts --vertical ${vertical.slug} --target ${target}`; if (script.includes('generate-ontos-module-contract.mts')) { - return yield* scaffoldError( - `vertical ${vertical.slug} ${label} script already contains module emission`, - ); + return yield* scaffoldError(`vertical ${vertical.slug} ${label} script already contains module emission`); } - const buildToken = - target === 'dist' ? 'modern build' : 'MODERNJS_DEPLOY=cloudflare modern build'; + const buildToken = target === 'dist' ? 'modern build' : 'MODERNJS_DEPLOY=cloudflare modern build'; if (!script.includes(buildToken)) { - return yield* scaffoldError( - `vertical ${vertical.slug} ${label} script is not a generated Modern build`, - ); + return yield* scaffoldError(`vertical ${vertical.slug} ${label} script is not a generated Modern build`); } return script.replace(buildToken, `${buildToken} && ${command}`); }); @@ -504,16 +478,11 @@ const patchPackage = ( ): Effect.Effect => Effect.gen(function* patchPackageEffect() { const dependencies = yield* trySync(() => ({ - ...asJsonObject( - vertical.packageJson['dependencies'], - `vertical ${vertical.slug} dependencies`, - ), + ...asJsonObject(vertical.packageJson['dependencies'], `vertical ${vertical.slug} dependencies`), })); const currentCore = dependencies['@app/core-runtime']; if (currentCore !== undefined && currentCore !== 'workspace:*') { - return yield* scaffoldError( - `vertical ${vertical.slug} has an incompatible @app/core-runtime dependency`, - ); + return yield* scaffoldError(`vertical ${vertical.slug} has an incompatible @app/core-runtime dependency`); } dependencies['@app/core-runtime'] = 'workspace:*'; const sortedDependencies = Object.fromEntries( @@ -530,12 +499,7 @@ const patchPackage = ( 'cloudflare:build', ); return yield* trySync(() => { - let content = patchJsonObjectProperty( - vertical.packageContent, - [], - 'dependencies', - sortedDependencies, - ); + let content = patchJsonObjectProperty(vertical.packageContent, [], 'dependencies', sortedDependencies); content = patchJsonObjectProperty(content, [], 'scripts', scripts); return patchJsonObjectProperty(content, ['modernjs'], 'ontosModule', { contractPath: '/.well-known/ontos-module-manifest.json', @@ -549,39 +513,22 @@ const patchPackage = ( const patchTsconfig = ( vertical: VerticalMetadata, -): Effect.Effect< - Option.Option, - ModuleContractScaffoldError | ScaffoldFailure, - FileSystem.FileSystem -> => +): Effect.Effect, ModuleContractScaffoldError | ScaffoldFailure, FileSystem.FileSystem> => Effect.gen(function* patchTsconfigEffect() { - const tsconfigPath = yield* trySync(() => - resolveContainedPath(vertical.directory, 'tsconfig.json'), - ); - const { content, value } = yield* readJsonEffect( - tsconfigPath, - `vertical ${vertical.slug} tsconfig`, - ); - const include = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.String))( - value['include'], - ).pipe( + const tsconfigPath = yield* trySync(() => resolveContainedPath(vertical.directory, 'tsconfig.json')); + const { content, value } = yield* readJsonEffect(tsconfigPath, `vertical ${vertical.slug} tsconfig`); + const include = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.String))(value['include']).pipe( Effect.mapError((cause) => scaffoldError(`vertical ${vertical.slug} tsconfig include must be a string array`, cause), ), ); const nextInclude = [ ...include, - ...[MANIFEST_FILE_NAME, 'vertical.registration.ts'].filter( - (entry) => !include.includes(entry), - ), + ...[MANIFEST_FILE_NAME, 'vertical.registration.ts'].filter((entry) => !include.includes(entry)), ]; return yield* trySync(() => Option.fromNullishOr( - updateMutation( - tsconfigPath, - content, - patchJsonObjectProperty(content, [], 'include', nextInclude), - ), + updateMutation(tsconfigPath, content, patchJsonObjectProperty(content, [], 'include', nextInclude)), ), ); }); @@ -598,69 +545,35 @@ const planModuleContractScaffold = ( const moduleId = yield* trySync(() => requireOntosModuleId(config.module)); const vertical = yield* discoverVerticalEffect(workspaceRoot, config.vertical); yield* assertUniqueModuleId(workspaceRoot, vertical.slug, moduleId); - const manifestPath = yield* trySync(() => - resolveContainedPath(vertical.directory, MANIFEST_FILE_NAME), - ); - const registrationPath = yield* trySync(() => - resolveContainedPath(vertical.directory, 'vertical.registration.ts'), - ); - const sharedApiPath = yield* trySync(() => - resolveContainedPath(vertical.directory, 'shared', 'api.ts'), - ); - const apiRootPath = yield* trySync(() => - resolveContainedPath(vertical.directory, 'api', 'index.ts'), - ); - const manifestMutation = yield* createMutationEffect( - manifestPath, - renderManifest(vertical, moduleId), - ); - const registrationMutation = yield* createMutationEffect( - registrationPath, - renderRegistration(vertical, moduleId), - ); + const manifestPath = yield* trySync(() => resolveContainedPath(vertical.directory, MANIFEST_FILE_NAME)); + const registrationPath = yield* trySync(() => resolveContainedPath(vertical.directory, 'vertical.registration.ts')); + const sharedApiPath = yield* trySync(() => resolveContainedPath(vertical.directory, 'shared', 'api.ts')); + const apiRootPath = yield* trySync(() => resolveContainedPath(vertical.directory, 'api', 'index.ts')); + const manifestMutation = yield* createMutationEffect(manifestPath, renderManifest(vertical, moduleId)); + const registrationMutation = yield* createMutationEffect(registrationPath, renderRegistration(vertical, moduleId)); const fileSystem = yield* FileSystem.FileSystem; const sharedApiExists = yield* fileSystem .exists(sharedApiPath) .pipe( - Effect.mapError((cause) => - scaffoldError(`failed to inspect vertical ${vertical.slug} shared API root`, cause), - ), + Effect.mapError((cause) => scaffoldError(`failed to inspect vertical ${vertical.slug} shared API root`, cause)), ); const sharedApiMutation = sharedApiExists ? yield* fileSystem.readFileString(sharedApiPath).pipe( - Effect.mapError((cause) => - scaffoldError(`failed to read vertical ${vertical.slug} shared API root`, cause), - ), + Effect.mapError((cause) => scaffoldError(`failed to read vertical ${vertical.slug} shared API root`, cause)), Effect.flatMap((content) => - trySync(() => - updateMutation( - sharedApiPath, - content, - initializeGovernedHttpApiRoot(content, vertical), - ), - ), + trySync(() => updateMutation(sharedApiPath, content, initializeGovernedHttpApiRoot(content, vertical))), ), ) : yield* createMutationEffect(sharedApiPath, renderGovernedHttpApiRoot(vertical)); const apiRootContent = yield* fileSystem .readFileString(apiRootPath) - .pipe( - Effect.mapError((cause) => - scaffoldError(`failed to read vertical ${vertical.slug} API root`, cause), - ), - ); + .pipe(Effect.mapError((cause) => scaffoldError(`failed to read vertical ${vertical.slug} API root`, cause))); const apiRootMutation = yield* trySync(() => - updateMutation( - apiRootPath, - apiRootContent, - initializeGovernedHttpHandlerRoot(apiRootContent, vertical), - ), + updateMutation(apiRootPath, apiRootContent, initializeGovernedHttpHandlerRoot(apiRootContent, vertical)), ); const packageContent = yield* patchPackage(vertical, moduleId); const packageMutation = yield* trySync(() => - Option.fromNullishOr( - updateMutation(vertical.packagePath, vertical.packageContent, packageContent), - ), + Option.fromNullishOr(updateMutation(vertical.packagePath, vertical.packageContent, packageContent)), ); const tsconfigMutation = yield* patchTsconfig(vertical); const mutations = EffectArray.getSomes([ @@ -674,7 +587,12 @@ const planModuleContractScaffold = ( yield* trySync(() => ensureUniqueMutationPaths(mutations)); return { mutations, - result: { appId: vertical.appId, manifestPath, moduleId, registrationPath }, + result: { + appId: vertical.appId, + manifestPath, + moduleId, + registrationPath, + }, }; }); diff --git a/app/scripts/scaffolding/outbox-message/scaffold.mts b/app/scripts/scaffolding/outbox-message/scaffold.mts index c3387310e..af0c3f9e4 100644 --- a/app/scripts/scaffolding/outbox-message/scaffold.mts +++ b/app/scripts/scaffolding/outbox-message/scaffold.mts @@ -1,5 +1,6 @@ import { Cause, Effect, FileSystem, Predicate, Result, Schema } from 'effect'; import type { PlatformError } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; import { ACTION_GENERATOR_HEADER, @@ -19,12 +20,7 @@ import { topicToSlug, updateMutation, } from '../shared.mts'; -import type { - OntosVerticalMetadata, - OutboxScaffoldConfig, - OutboxScaffoldResult, - ScaffoldPlan, -} from '../shared.mts'; +import type { OntosVerticalMetadata, OutboxScaffoldConfig, OutboxScaffoldResult, ScaffoldPlan } from '../shared.mts'; class OutboxMessageScaffoldError extends Schema.TaggedError()( 'OutboxMessageScaffoldError', @@ -36,16 +32,12 @@ class OutboxMessageScaffoldError extends Schema.TaggedError - cause === undefined - ? new OutboxMessageScaffoldError({ reason }) - : new OutboxMessageScaffoldError({ cause, reason }); + cause === undefined ? new OutboxMessageScaffoldError({ reason }) : new OutboxMessageScaffoldError({ cause, reason }); const failureFromCause = (cause: unknown): OutboxMessageScaffoldError => planningFailure(Predicate.isError(cause) ? cause.message : String(cause), cause); -const fromLegacySync = ( - operation: () => Value, -): Effect.Effect => +const fromLegacySync = (operation: () => Value): Effect.Effect => Effect.try({ catch: failureFromCause, try: operation }); const PackageExportsSchema = Schema.Struct({ @@ -84,11 +76,7 @@ const recoverDiscoveryCause = (cause: Cause.Cause) => { const FORMATTED_ACTION_GENERATOR_PREFIX = "import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime';\n"; -const renderOutboxMessage = ( - vertical: OntosVerticalMetadata, - action: string, - topic: string, -): string => { +const renderOutboxMessage = (vertical: OntosVerticalMetadata, action: string, topic: string): string => { const actionType = toPascalCase(action); const topicType = toPascalCase(topicToSlug(topic)); const base = `${actionType}${topicType}Outbox`; @@ -127,11 +115,7 @@ export const outboxTopic = '${topic}' as const; export const outboxProducerModuleKey = '${vertical.moduleId}' as const; `; -const isMatchingGeneratedAction = ( - actionContent: string, - vertical: OntosVerticalMetadata, - action: string, -): boolean => { +const isMatchingGeneratedAction = (actionContent: string, vertical: OntosVerticalMetadata, action: string): boolean => { const hasGeneratedActionPrefix = actionContent.startsWith(`${ACTION_GENERATOR_HEADER}\n`) || actionContent.startsWith(`${FORMATTED_ACTION_GENERATOR_PREFIX}${ACTION_GENERATOR_HEADER}\n`); @@ -165,22 +149,13 @@ const planOutboxScaffold = ( Effect.catchCause(recoverDiscoveryCause), ); const actionPath = yield* fromLegacySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'src', - 'actions', - `${action}.action.ts`, - ), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'src', 'actions', `${action}.action.ts`), ); const actionContent = yield* fileSystem .readFileString(actionPath) .pipe( Effect.catchIf(isNotFoundPlatformError, (cause) => - Effect.fail( - planningFailure(`Outbox Message requires the generated Action at ${actionPath}`, cause), - ), + Effect.fail(planningFailure(`Outbox Message requires the generated Action at ${actionPath}`, cause)), ), ); if (!isMatchingGeneratedAction(actionContent, vertical, action)) { @@ -193,10 +168,9 @@ const planOutboxScaffold = ( const topicSlug = topicToSlug(topic); const base = `${toPascalCase(action)}${toPascalCase(topicSlug)}Outbox`; if ( - new RegExp( - `\\b(?:${base}(?:Payload|PayloadSchema|ProducerModuleKey|Topic)|create${base}Message)\\b`, - 'u', - ).test(actionContent) + new RegExp(`\\b(?:${base}(?:Payload|PayloadSchema|ProducerModuleKey|Topic)|create${base}Message)\\b`, 'u').test( + actionContent, + ) ) { return yield* Effect.fail(planningFailure(`Outbox identifier ${base} already exists`)); } @@ -211,23 +185,10 @@ const planOutboxScaffold = ( ), ); const contractPath = yield* fromLegacySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'shared', - 'outbox', - `${topicSlug}.ts`, - ), - ); - const contractMutation = yield* createMutationEffect( - contractPath, - renderOutboxContract(vertical, topic), - ); - const messageMutation = yield* createMutationEffect( - messagePath, - renderOutboxMessage(vertical, action, topic), + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'shared', 'outbox', `${topicSlug}.ts`), ); + const contractMutation = yield* createMutationEffect(contractPath, renderOutboxContract(vertical, topic)); + const messageMutation = yield* createMutationEffect(messagePath, renderOutboxMessage(vertical, action, topic)); const exportSource = `./${action}.${topicSlug}.outbox-message.ts`; const exportEntries = [ `export { ${base}PayloadSchema } from '${exportSource}';`, @@ -237,27 +198,17 @@ const planOutboxScaffold = ( `export type { ${base}Payload } from '${exportSource}';`, ]; const patchedAction = yield* fromLegacySync(() => - insertSortedSlot( - actionContent, - OUTBOX_SLOT_START, - OUTBOX_SLOT_END, - exportEntries, - (candidate) => - /^export (?:type )?\{ [A-Za-z0-9]+ \} from '\.\/[a-z0-9.-]+\.outbox-message\.ts';$/u.test( - candidate, - ), + insertSortedSlot(actionContent, OUTBOX_SLOT_START, OUTBOX_SLOT_END, exportEntries, (candidate) => + /^export (?:type )?\{ [A-Za-z0-9]+ \} from '\.\/[a-z0-9.-]+\.outbox-message\.ts';$/u.test(candidate), ), ); const actionMutation = updateMutation(actionPath, actionContent, patchedAction); if (actionMutation === undefined) { - return yield* Effect.fail( - planningFailure('Outbox Message Action export patch unexpectedly made no change'), - ); + return yield* Effect.fail(planningFailure('Outbox Message Action export patch unexpectedly made no change')); } - const packageDocument = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(PackageExportsSchema), - { onExcessProperty: 'preserve' }, - )(vertical.packageContent).pipe( + const packageDocument = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageExportsSchema), { + onExcessProperty: 'preserve', + })(vertical.packageContent).pipe( Effect.mapError((cause) => planningFailure(`vertical ${vertical.slug} package exports must be a JSON object`, cause), ), @@ -265,9 +216,7 @@ const planOutboxScaffold = ( const exportsValue = packageDocument.exports; const contractExport = `./outbox/${topicSlug}`; if (exportsValue[contractExport] !== undefined) { - return yield* Effect.fail( - planningFailure(`Outbox contract export ${contractExport} already exists`), - ); + return yield* Effect.fail(planningFailure(`Outbox contract export ${contractExport} already exists`)); } const patchedExports = Object.fromEntries( Object.entries({ @@ -283,9 +232,7 @@ const planOutboxScaffold = ( ), ); if (packageMutation === undefined) { - return yield* Effect.fail( - planningFailure('Outbox Message package export patch unexpectedly made no change'), - ); + return yield* Effect.fail(planningFailure('Outbox Message package export patch unexpectedly made no change')); } const mutations = [contractMutation, messageMutation, actionMutation, packageMutation]; yield* fromLegacySync(() => ensureUniqueMutationPaths(mutations)); diff --git a/app/scripts/scaffolding/outbox-worker/scaffold.mts b/app/scripts/scaffolding/outbox-worker/scaffold.mts index fe5dd462b..a3591b674 100644 --- a/app/scripts/scaffolding/outbox-worker/scaffold.mts +++ b/app/scripts/scaffolding/outbox-worker/scaffold.mts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Match, Option, Predicate, Schema } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_REGISTRATION_IMPORT_SLOT_END, @@ -35,13 +36,10 @@ import type { ScaffoldPlan, } from '../shared.mts'; -class OutboxWorkerScaffoldError extends Schema.TaggedError()( - 'OutboxWorkerScaffoldError', - { - cause: Schema.Unknown, - message: Schema.String, - }, -) {} +class OutboxWorkerScaffoldError extends Schema.TaggedError()('OutboxWorkerScaffoldError', { + cause: Schema.Unknown, + message: Schema.String, +}) {} const scaffoldError = (cause: unknown, message?: string): OutboxWorkerScaffoldError => new OutboxWorkerScaffoldError({ @@ -153,8 +151,7 @@ export const ${workerVariable} = defineOutboxWorker( `; }; -const renderRegistry = - (): string => `import type { AnyOutboxWorkerRegistration } from '@app/core-runtime'; +const renderRegistry = (): string => `import type { AnyOutboxWorkerRegistration } from '@app/core-runtime'; ${OUTBOX_WORKER_IMPORT_SLOT_START} ${OUTBOX_WORKER_IMPORT_SLOT_END} @@ -165,9 +162,7 @@ export const outboxWorkers = Object.freeze([ ]) satisfies readonly AnyOutboxWorkerRegistration[]; `; -const renderWorkerHostLayer = ( - consumer: OntosVerticalMetadata, -): string => `${OUTBOX_WORKER_HOST_HEADER} +const renderWorkerHostLayer = (consumer: OntosVerticalMetadata): string => `${OUTBOX_WORKER_HOST_HEADER} // @ontos-outbox-worker-host-owner ${consumer.moduleId} import { Layer } from 'effect'; import { OutboxWorkerInfrastructureLive } from '@app/core-runtime/outbox/worker'; @@ -185,18 +180,14 @@ export const outboxWorkerLayer = Layer.merge( ); `; -const renderWorkerHostMain = ( - consumer: OntosVerticalMetadata, -): string => `${OUTBOX_WORKER_HOST_HEADER} +const renderWorkerHostMain = (consumer: OntosVerticalMetadata): string => `${OUTBOX_WORKER_HOST_HEADER} // @ontos-outbox-worker-host-owner ${consumer.moduleId} import { start${toPascalCase(consumer.slug)}OutboxWorker } from '../../scripts/outbox-worker.ts'; start${toPascalCase(consumer.slug)}OutboxWorker(); `; -const renderWorkerHostScript = ( - consumer: OntosVerticalMetadata, -): string => `${OUTBOX_WORKER_HOST_HEADER} +const renderWorkerHostScript = (consumer: OntosVerticalMetadata): string => `${OUTBOX_WORKER_HOST_HEADER} // @ontos-outbox-worker-host-owner ${consumer.moduleId} import { Layer } from 'effect'; import { @@ -253,9 +244,7 @@ const patchConsumerPackage = (consumer: OntosVerticalMetadata, producer: OntosVe dependenciesValue === undefined ? {} : { - ...(yield* trySync(() => - asJsonObject(dependenciesValue, `vertical ${consumer.slug} dependencies`), - )), + ...(yield* trySync(() => asJsonObject(dependenciesValue, `vertical ${consumer.slug} dependencies`))), }; for (const [name, version] of [ ['@app/core-runtime', 'workspace:*'], @@ -304,29 +293,17 @@ const patchConsumerPackage = (consumer: OntosVerticalMetadata, producer: OntosVe const withDependencies = yield* trySync(() => patchJsonObjectProperty(consumer.packageContent, [], 'dependencies', sortedDependencies), ); - return yield* trySync(() => - patchJsonObjectProperty(withDependencies, [], 'scripts', sortedScripts), - ); + return yield* trySync(() => patchJsonObjectProperty(withDependencies, [], 'scripts', sortedScripts)); }); -const patchConsumerTsconfig = ( - content: string, - consumer: OntosVerticalMetadata, - producer: OntosVerticalMetadata, -) => +const patchConsumerTsconfig = (content: string, consumer: OntosVerticalMetadata, producer: OntosVerticalMetadata) => Effect.gen(function* patchConsumerTsconfigEffect() { - const parsed = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))( - content, - ).pipe( - Effect.mapError((cause) => - scaffoldError(cause, `vertical ${consumer.slug} tsconfig is not valid JSON`), - ), + const parsed = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))(content).pipe( + Effect.mapError((cause) => scaffoldError(cause, `vertical ${consumer.slug} tsconfig is not valid JSON`)), ); const root = yield* trySync(() => asJsonObject(parsed, `vertical ${consumer.slug} tsconfig`)); const referencesValue = root['references']; - const references = yield* Schema.decodeUnknownEffect(TsconfigReferencesSchema)( - referencesValue, - ).pipe( + const references = yield* Schema.decodeUnknownEffect(TsconfigReferencesSchema)(referencesValue).pipe( Effect.mapError((cause) => scaffoldError(cause, `vertical ${consumer.slug} tsconfig references must be an array`), ), @@ -357,11 +334,7 @@ const patchConsumerTsconfig = ( return yield* trySync(() => patchJsonObjectProperty(content, [], 'references', patched)); }); -const isMatchingOutboxContract = ( - contract: string, - producer: OntosVerticalMetadata, - topic: string, -): boolean => +const isMatchingOutboxContract = (contract: string, producer: OntosVerticalMetadata, topic: string): boolean => contract.startsWith(`${OUTBOX_CONTRACT_GENERATOR_HEADER}\n`) && [ `// @ontos-outbox-producer ${producer.moduleId}\n`, @@ -372,11 +345,7 @@ const isMatchingOutboxContract = ( ].every((fragment) => contract.includes(fragment)) && !/(?:src\/actions|create[A-Za-z0-9]+Message|handler|repository|transport)/u.test(contract); -const planRegistryMutation = ( - registryPath: string, - registryContent: Option.Option, - worker: string, -) => +const planRegistryMutation = (registryPath: string, registryContent: Option.Option, worker: string) => Effect.gen(function* planRegistryMutationEffect() { const workerVariable = `${toCamelCase(worker)}Worker`; let registryMutation: Mutation; @@ -402,8 +371,7 @@ const planRegistryMutation = ( OUTBOX_WORKER_IMPORT_SLOT_START, OUTBOX_WORKER_IMPORT_SLOT_END, [`import { ${workerVariable} } from './${worker}.worker.ts';`], - (candidate) => - /^import \{ [A-Za-z0-9]+Worker \} from '\.\/[a-z0-9-]+\.worker\.ts';$/u.test(candidate), + (candidate) => /^import \{ [A-Za-z0-9]+Worker \} from '\.\/[a-z0-9-]+\.worker\.ts';$/u.test(candidate), ); return insertSortedSlot( withImport, @@ -464,14 +432,7 @@ const planOutboxWorkerScaffoldEffect = ( ]); const topicSlug = yield* trySync(() => topicToSlug(topic)); const contractPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - producer.slug, - 'shared', - 'outbox', - `${topicSlug}.ts`, - ), + resolveContainedPath(workspaceRoot, 'verticals', producer.slug, 'shared', 'outbox', `${topicSlug}.ts`), ); const contract = yield* readRequiredFile(contractPath, 'published producer Outbox contract'); const contractExport = `./outbox/${topicSlug}`; @@ -499,19 +460,9 @@ const planOutboxWorkerScaffoldEffect = ( } const workerPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - consumer.slug, - 'src', - 'workers', - `${worker}.worker.ts`, - ), - ); - const workerMutation = yield* createMutationEffect( - workerPath, - renderWorker(consumer, producer, worker, topic), + resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'src', 'workers', `${worker}.worker.ts`), ); + const workerMutation = yield* createMutationEffect(workerPath, renderWorker(consumer, producer, worker, topic)); const registryPath = yield* trySync(() => resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'src', 'workers', 'index.ts'), ); @@ -520,40 +471,19 @@ const planOutboxWorkerScaffoldEffect = ( const registryMutation = yield* planRegistryMutation(registryPath, registryContent, worker); const workerHostLayerPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - consumer.slug, - 'src', - 'worker-host', - 'layer.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'src', 'worker-host', 'layer.ts'), ); const workerHostMainPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - consumer.slug, - 'src', - 'worker-host', - 'main.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'src', 'worker-host', 'main.ts'), ); const workerHostScriptPath = yield* trySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - consumer.slug, - 'scripts', - 'outbox-worker.ts', - ), + resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'scripts', 'outbox-worker.ts'), ); - const [workerHostLayerMutation, workerHostMainMutation, workerHostScriptMutation] = - yield* Effect.all([ - planWorkerHostFile(workerHostLayerPath, consumer, renderWorkerHostLayer(consumer)), - planWorkerHostFile(workerHostMainPath, consumer, renderWorkerHostMain(consumer)), - planWorkerHostFile(workerHostScriptPath, consumer, renderWorkerHostScript(consumer)), - ]); + const [workerHostLayerMutation, workerHostMainMutation, workerHostScriptMutation] = yield* Effect.all([ + planWorkerHostFile(workerHostLayerPath, consumer, renderWorkerHostLayer(consumer)), + planWorkerHostFile(workerHostMainPath, consumer, renderWorkerHostMain(consumer)), + planWorkerHostFile(workerHostScriptPath, consumer, renderWorkerHostScript(consumer)), + ]); const registrationImport = `import { ${workerVariable} } from './src/workers/${worker}.worker.ts';`; const nextRegistration = yield* trySync(() => @@ -588,14 +518,9 @@ const planOutboxWorkerScaffoldEffect = ( const tsconfigPath = yield* trySync(() => resolveContainedPath(workspaceRoot, 'verticals', consumer.slug, 'tsconfig.json'), ); - const tsconfigContent = yield* readRequiredFile( - tsconfigPath, - `vertical ${consumer.slug} tsconfig`, - ); + const tsconfigContent = yield* readRequiredFile(tsconfigPath, `vertical ${consumer.slug} tsconfig`); const patchedTsconfig = yield* patchConsumerTsconfig(tsconfigContent, consumer, producer); - const tsconfigMutation = yield* trySync(() => - updateMutation(tsconfigPath, tsconfigContent, patchedTsconfig), - ); + const tsconfigMutation = yield* trySync(() => updateMutation(tsconfigPath, tsconfigContent, patchedTsconfig)); const mutations = [ workerMutation, registryMutation, diff --git a/app/scripts/scaffolding/policy/scaffold.mts b/app/scripts/scaffolding/policy/scaffold.mts index f067e78d5..02b87ef17 100644 --- a/app/scripts/scaffolding/policy/scaffold.mts +++ b/app/scripts/scaffolding/policy/scaffold.mts @@ -1,8 +1,10 @@ import { Effect, FileSystem, Match, Schema, Predicate } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; import { CORE_POLICY_SLOT_END, CORE_POLICY_SLOT_START, + createMutationEffect, discoverOntosModuleEffect, ensureUniqueMutationPaths, insertSortedSlot, @@ -13,12 +15,7 @@ import { updateMutation, withCoreDependency, } from '../shared.mts'; -import type { - Mutation, - PolicyScaffoldConfig, - PolicyScaffoldResult, - ScaffoldPlan, -} from '../shared.mts'; +import type { Mutation, PolicyScaffoldConfig, PolicyScaffoldResult, ScaffoldPlan } from '../shared.mts'; class PolicyScaffoldError extends Schema.TaggedError()('PolicyScaffoldError', { reason: Schema.String, @@ -29,35 +26,20 @@ class PolicyScaffoldError extends Schema.TaggedError()('Pol } const planningFailure = (cause: unknown): PolicyScaffoldError => - new PolicyScaffoldError({ reason: Predicate.isError(cause) ? cause.message : String(cause) }); + new PolicyScaffoldError({ + reason: Predicate.isError(cause) ? cause.message : String(cause), + }); -const fromLegacySync = ( - operation: () => Value, -): Effect.Effect => +const fromLegacySync = (operation: () => Value): Effect.Effect => Effect.try({ catch: planningFailure, try: operation }); const createPolicyMutation = ( filePath: string, content: string, ): Effect.Effect => - Effect.gen(function* createPolicyMutationEffect() { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem.exists(filePath).pipe(Effect.mapError(planningFailure)); - if (exists) { - return yield* Effect.fail( - new PolicyScaffoldError({ - reason: `refusing to overwrite existing business file: ${filePath}`, - }), - ); - } - return { content, kind: 'create', path: filePath }; - }); + createMutationEffect(filePath, content).pipe(Effect.mapError(planningFailure)); -const renderPolicy = ( - policy: string, - scope: 'global' | 'microvertical', - owner?: string, -): string => { +const renderPolicy = (policy: string, scope: 'global' | 'microvertical', owner?: string): string => { const valueName = `${toCamelCase(policy)}Policy`; const definition = scope === 'global' ? 'defineGlobalPolicy' : 'defineMicroverticalPolicy'; const policyKey = scope === 'global' ? `global.${policy}.v1` : `${owner}.${policy}.v1`; @@ -76,115 +58,90 @@ ${ownerLine} policyKey: '${policyKey}', `; }; -const planPolicyScaffold = Effect.fn('PolicyScaffold.planPolicyScaffold')( - function* planPolicyScaffoldEffect( - workspaceRoot: string, - config: PolicyScaffoldConfig, - ): Effect.fn.Return< - ScaffoldPlan, - PolicyScaffoldError, - FileSystem.FileSystem - > { - const fileSystem = yield* FileSystem.FileSystem; - const policy = yield* fromLegacySync(() => requireCanonicalSlug(config.policy, 'policy')); - if (config.scope === 'global') { - if (config.vertical !== undefined) { - return yield* Effect.fail( - new PolicyScaffoldError({ reason: '--vertical is forbidden when --scope is global' }), - ); - } - const policyPath = yield* fromLegacySync(() => - resolveContainedPath( - workspaceRoot, - 'packages', - 'core-runtime', - 'src', - 'policies', - `${policy}.policy.ts`, - ), - ); - const policyMutation = yield* createPolicyMutation( - policyPath, - renderPolicy(policy, 'global'), - ); - const indexPath = yield* fromLegacySync(() => - resolveContainedPath(workspaceRoot, 'packages', 'core-runtime', 'src', 'index.ts'), +const planPolicyScaffold = Effect.fn('PolicyScaffold.planPolicyScaffold')(function* planPolicyScaffoldEffect( + workspaceRoot: string, + config: PolicyScaffoldConfig, +): Effect.fn.Return, PolicyScaffoldError, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const policy = yield* fromLegacySync(() => requireCanonicalSlug(config.policy, 'policy')); + if (config.scope === 'global') { + if (config.vertical !== undefined) { + return yield* Effect.fail( + new PolicyScaffoldError({ + reason: '--vertical is forbidden when --scope is global', + }), ); - const indexContent = yield* fileSystem.readFileString(indexPath).pipe( - Effect.mapError((cause) => - Match.value(cause.reason).pipe( - Match.tag( - 'NotFound', - () => - new PolicyScaffoldError({ reason: `Core public index is missing at ${indexPath}` }), - ), - Match.orElse(planningFailure), + } + const policyPath = yield* fromLegacySync(() => + resolveContainedPath(workspaceRoot, 'packages', 'core-runtime', 'src', 'policies', `${policy}.policy.ts`), + ); + const policyMutation = yield* createPolicyMutation(policyPath, renderPolicy(policy, 'global')); + const indexPath = yield* fromLegacySync(() => + resolveContainedPath(workspaceRoot, 'packages', 'core-runtime', 'src', 'index.ts'), + ); + const indexContent = yield* fileSystem.readFileString(indexPath).pipe( + Effect.mapError((cause) => + Match.value(cause.reason).pipe( + Match.tag( + 'NotFound', + () => + new PolicyScaffoldError({ + reason: `Core public index is missing at ${indexPath}`, + }), ), + Match.orElse(planningFailure), ), - ); - const exportIdentifier = `${toCamelCase(policy)}Policy`; - if (new RegExp(`^export \\{ ${exportIdentifier} \\} from `, 'mu').test(indexContent)) { - return yield* Effect.fail( - new PolicyScaffoldError({ - reason: `Policy identifier ${exportIdentifier} already exists`, - }), - ); - } - const exportEntry = `export { ${exportIdentifier} } from './policies/${policy}.policy.ts';`; - const patchedIndex = yield* fromLegacySync(() => - insertSortedSlot( - indexContent, - CORE_POLICY_SLOT_START, - CORE_POLICY_SLOT_END, - [exportEntry], - (candidate) => - /^export \{ [A-Za-z][A-Za-z0-9]*Policy \} from '\.\/policies\/[a-z0-9-]+\.policy\.ts';$/u.test( - candidate, - ), - ), - ); - const indexMutation = updateMutation(indexPath, indexContent, patchedIndex); - if (indexMutation === undefined) { - return yield* Effect.fail( - new PolicyScaffoldError({ - reason: 'global Policy export patch unexpectedly made no change', - }), - ); - } - const mutations = [policyMutation, indexMutation]; - yield* fromLegacySync(() => ensureUniqueMutationPaths(mutations)); - return { mutations, result: { policyPath } }; - } - - if (config.vertical === undefined) { + ), + ); + const exportIdentifier = `${toCamelCase(policy)}Policy`; + if (new RegExp(`^export \\{ ${exportIdentifier} \\} from `, 'mu').test(indexContent)) { return yield* Effect.fail( - new PolicyScaffoldError({ reason: '--vertical is required when --scope is microvertical' }), + new PolicyScaffoldError({ + reason: `Policy identifier ${exportIdentifier} already exists`, + }), ); } - const requestedVertical = config.vertical; - const vertical = yield* discoverOntosModuleEffect(workspaceRoot, requestedVertical).pipe( - Effect.mapError(planningFailure), - ); - const policyPath = yield* fromLegacySync(() => - resolveContainedPath( - workspaceRoot, - 'verticals', - vertical.slug, - 'src', - 'policies', - `${policy}.policy.ts`, + const exportEntry = `export { ${exportIdentifier} } from './policies/${policy}.policy.ts';`; + const patchedIndex = yield* fromLegacySync(() => + insertSortedSlot(indexContent, CORE_POLICY_SLOT_START, CORE_POLICY_SLOT_END, [exportEntry], (candidate) => + /^export \{ [A-Za-z][A-Za-z0-9]*Policy \} from '\.\/policies\/[a-z0-9-]+\.policy\.ts';$/u.test(candidate), ), ); - const policyMutation = yield* createPolicyMutation( - policyPath, - renderPolicy(policy, 'microvertical', vertical.moduleId), - ); - const dependencyMutation = yield* fromLegacySync(() => withCoreDependency(vertical)); - const mutations = - dependencyMutation === undefined ? [policyMutation] : [policyMutation, dependencyMutation]; + const indexMutation = updateMutation(indexPath, indexContent, patchedIndex); + if (indexMutation === undefined) { + return yield* Effect.fail( + new PolicyScaffoldError({ + reason: 'global Policy export patch unexpectedly made no change', + }), + ); + } + const mutations = [policyMutation, indexMutation]; yield* fromLegacySync(() => ensureUniqueMutationPaths(mutations)); return { mutations, result: { policyPath } }; - }, -); + } + + if (config.vertical === undefined) { + return yield* Effect.fail( + new PolicyScaffoldError({ + reason: '--vertical is required when --scope is microvertical', + }), + ); + } + const requestedVertical = config.vertical; + const vertical = yield* discoverOntosModuleEffect(workspaceRoot, requestedVertical).pipe( + Effect.mapError(planningFailure), + ); + const policyPath = yield* fromLegacySync(() => + resolveContainedPath(workspaceRoot, 'verticals', vertical.slug, 'src', 'policies', `${policy}.policy.ts`), + ); + const policyMutation = yield* createPolicyMutation( + policyPath, + renderPolicy(policy, 'microvertical', vertical.moduleId), + ); + const dependencyMutation = yield* fromLegacySync(() => withCoreDependency(vertical)); + const mutations = dependencyMutation === undefined ? [policyMutation] : [policyMutation, dependencyMutation]; + yield* fromLegacySync(() => ensureUniqueMutationPaths(mutations)); + return { mutations, result: { policyPath } }; +}); export default createCodesmithGenerator(planPolicyScaffold); diff --git a/app/scripts/scaffolding/public-component/scaffold.mts b/app/scripts/scaffolding/public-component/scaffold.mts index 2e6b33859..2204f2ec2 100644 --- a/app/scripts/scaffolding/public-component/scaffold.mts +++ b/app/scripts/scaffolding/public-component/scaffold.mts @@ -2,7 +2,6 @@ import { createCodesmithGenerator } from '../generator-adapter.mts'; import { planGovernedContributionScaffold } from '../governed-contribution/scaffold.mts'; import type { GovernedContributionScaffoldConfig } from '../shared.mts'; -export default createCodesmithGenerator( - (workspaceRoot: string, config: GovernedContributionScaffoldConfig) => - planGovernedContributionScaffold(workspaceRoot, 'public-component', config), +export default createCodesmithGenerator((workspaceRoot: string, config: GovernedContributionScaffoldConfig) => + planGovernedContributionScaffold(workspaceRoot, 'public-component', config), ); diff --git a/app/scripts/scaffolding/report/scaffold.mts b/app/scripts/scaffolding/report/scaffold.mts index b16214c5d..6bb40050b 100644 --- a/app/scripts/scaffolding/report/scaffold.mts +++ b/app/scripts/scaffolding/report/scaffold.mts @@ -2,7 +2,6 @@ import { createCodesmithGenerator } from '../generator-adapter.mts'; import { planGovernedContributionScaffold } from '../governed-contribution/scaffold.mts'; import type { GovernedContributionScaffoldConfig } from '../shared.mts'; -export default createCodesmithGenerator( - (workspaceRoot: string, config: GovernedContributionScaffoldConfig) => - planGovernedContributionScaffold(workspaceRoot, 'report', config), +export default createCodesmithGenerator((workspaceRoot: string, config: GovernedContributionScaffoldConfig) => + planGovernedContributionScaffold(workspaceRoot, 'report', config), ); diff --git a/app/scripts/scaffolding/resource/scaffold.mts b/app/scripts/scaffolding/resource/scaffold.mts index 994050145..1467a75c0 100644 --- a/app/scripts/scaffolding/resource/scaffold.mts +++ b/app/scripts/scaffolding/resource/scaffold.mts @@ -1,4 +1,5 @@ import { Effect } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_MANIFEST_IMPORT_SLOT_END, @@ -61,8 +62,7 @@ export const ${descriptor} = { `; }; -const isResourceDescriptor = (candidate: string): boolean => - /^[a-z][A-Za-z0-9]*ResourceDescriptor,$/u.test(candidate); +const isResourceDescriptor = (candidate: string): boolean => /^[a-z][A-Za-z0-9]*ResourceDescriptor,$/u.test(candidate); const planResourceScaffold = Effect.fn('ResourceScaffold.plan')(function* planResourceScaffold( workspaceRoot: string, @@ -75,10 +75,7 @@ const planResourceScaffold = Effect.fn('ResourceScaffold.plan')(function* planRe const resourcePath = yield* tryScaffold('failed to resolve resource path', () => resolveContainedPath(vertical.directory, 'shared', 'resources', `${resource}.ts`), ); - const resourceMutation = yield* createMutationEffect( - resourcePath, - renderResource(vertical, resource), - ); + const resourceMutation = yield* createMutationEffect(resourcePath, renderResource(vertical, resource)); const descriptor = `${toCamelCase(resource)}ResourceDescriptor`; const ownerImport = `import { ${descriptor} } from './shared/resources/${resource}.ts';`; @@ -97,11 +94,7 @@ const planResourceScaffold = Effect.fn('ResourceScaffold.plan')(function* planRe isResourceDescriptor, ), ); - const manifestMutation = updateMutation( - vertical.manifestPath, - vertical.manifestContent, - nextManifest, - ); + const manifestMutation = updateMutation(vertical.manifestPath, vertical.manifestContent, nextManifest); if (manifestMutation === undefined) { return yield* scaffoldFailure('Resource manifest patch unexpectedly made no change'); } @@ -131,9 +124,7 @@ const planResourceScaffold = Effect.fn('ResourceScaffold.plan')(function* planRe } const mutations = [resourceMutation, manifestMutation, packageMutation]; - yield* tryScaffold('resource mutation paths are invalid', () => - ensureUniqueMutationPaths(mutations), - ); + yield* tryScaffold('resource mutation paths are invalid', () => ensureUniqueMutationPaths(mutations)); return { mutations, result: { resourcePath } }; }); diff --git a/app/scripts/scaffolding/retire-contribution/scaffold.mts b/app/scripts/scaffolding/retire-contribution/scaffold.mts index a1393aab1..34382574d 100644 --- a/app/scripts/scaffolding/retire-contribution/scaffold.mts +++ b/app/scripts/scaffolding/retire-contribution/scaffold.mts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Schema, Predicate } from 'effect'; + import { createCodesmithGenerator } from '../generator-adapter.mts'; import { ACTION_GENERATOR_HEADER, @@ -62,26 +63,20 @@ const trySync = (operation: () => Value, fallback: string) => try: operation, }); -const readGeneratedArtifact = Effect.fn('readGeneratedArtifact')( - function* readGeneratedArtifactEffect( - filePath: string, - label: string, - checks: readonly string[], - ) { - const fileSystem = yield* FileSystem.FileSystem; - const content = yield* fileSystem - .readFileString(filePath) - .pipe( - Effect.mapError((cause) => - scaffoldError(`matching generated ${label} is missing at ${filePath}`, cause), - ), - ); - if (checks.some((check) => !content.includes(check))) { - return yield* scaffoldError(`matching generated ${label} metadata is missing at ${filePath}`); - } - return content; - }, -); +const readGeneratedArtifact = Effect.fn('readGeneratedArtifact')(function* readGeneratedArtifactEffect( + filePath: string, + label: string, + checks: readonly string[], +) { + const fileSystem = yield* FileSystem.FileSystem; + const content = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.mapError((cause) => scaffoldError(`matching generated ${label} is missing at ${filePath}`, cause))); + if (checks.some((check) => !content.includes(check))) { + return yield* scaffoldError(`matching generated ${label} metadata is missing at ${filePath}`); + } + return content; +}); const removeOptionalGeneratedSlotEntry = Effect.fn('removeOptionalGeneratedSlotEntry')( function* removeOptionalGeneratedSlotEntryEffect( @@ -128,9 +123,7 @@ const planActionRetirement = Effect.fn('planActionRetirement')(function* planAct `failed to inspect Action ${name} Outbox dependents`, ); if (dependentCount > 0) { - return yield* scaffoldError( - `cannot retire Action ${name} while it has published Outbox dependents`, - ); + return yield* scaffoldError(`cannot retire Action ${name} while it has published Outbox dependents`); } const importLine = `import { ${symbol} } from './src/actions/${name}.action.ts';`; const { nextManifest, nextRegistration } = yield* trySync( @@ -165,8 +158,16 @@ const planActionRetirement = Effect.fn('planActionRetirement')(function* planAct `failed to retire generated Action ${name}`, ); return [ - { content: nextManifest, kind: 'update' as const, path: vertical.manifestPath }, - { content: nextRegistration, kind: 'update' as const, path: vertical.registrationPath }, + { + content: nextManifest, + kind: 'update' as const, + path: vertical.manifestPath, + }, + { + content: nextRegistration, + kind: 'update' as const, + path: vertical.registrationPath, + }, yield* deleteMutationEffect(artifactPath), ]; }); @@ -194,9 +195,7 @@ const planApiRetirement = Effect.fn('planApiRetirement')(function* planApiRetire [`${API_GENERATOR_HEADER}\n`, `export const ${toCamelCase(name)}ReadApiLive`], ] as const; yield* Effect.all( - paths.map((filePath, index) => - readGeneratedArtifact(filePath, `module API ${name}`, checks[index] ?? []), - ), + paths.map((filePath, index) => readGeneratedArtifact(filePath, `module API ${name}`, checks[index] ?? [])), { concurrency: 'unbounded' }, ); const { nextManifest, nextRegistration } = yield* trySync( @@ -228,8 +227,16 @@ const planApiRetirement = Effect.fn('planApiRetirement')(function* planApiRetire concurrency: 'unbounded', }); return [ - { content: nextManifest, kind: 'update' as const, path: vertical.manifestPath }, - { content: nextRegistration, kind: 'update' as const, path: vertical.registrationPath }, + { + content: nextManifest, + kind: 'update' as const, + path: vertical.manifestPath, + }, + { + content: nextRegistration, + kind: 'update' as const, + path: vertical.registrationPath, + }, ...deletes, ]; }); @@ -241,10 +248,7 @@ const planPageRetirement = Effect.fn('planPageRetirement')(function* planPageRet const type = `${toPascalCase(name)}Page`; const componentKey = `${vertical.moduleId}.page-${name}`; const contributionKey = `${vertical.moduleId}.page.${name}`; - const importPattern = new RegExp( - `^import \\{ ${type} \\} from '\\.\\/src\\/routes\\/.+\\/page\\.tsx';$`, - 'u', - ); + const importPattern = new RegExp(`^import \\{ ${type} \\} from '\\.\\/src\\/routes\\/.+\\/page\\.tsx';$`, 'u'); let nextManifest = yield* trySync( () => removeGeneratedSlotEntry( @@ -274,8 +278,7 @@ const planPageRetirement = Effect.fn('planPageRetirement')(function* planPageRet MODULE_MANIFEST_SHELL_PAGE_SLOT_START, MODULE_MANIFEST_SHELL_PAGE_SLOT_END, (entry) => - entry.includes(`componentKey: '${componentKey}'`) && - entry.includes(`contributionKey: '${contributionKey}'`), + entry.includes(`componentKey: '${componentKey}'`) && entry.includes(`contributionKey: '${contributionKey}'`), `shell page ${name}`, ), `failed to remove generated shell page ${name}`, @@ -304,8 +307,16 @@ const planPageRetirement = Effect.fn('planPageRetirement')(function* planPageRet `failed to remove generated page registration ${name}`, ); return [ - { content: nextManifest, kind: 'update' as const, path: vertical.manifestPath }, - { content: nextRegistration, kind: 'update' as const, path: vertical.registrationPath }, + { + content: nextManifest, + kind: 'update' as const, + path: vertical.manifestPath, + }, + { + content: nextRegistration, + kind: 'update' as const, + path: vertical.registrationPath, + }, ]; }); @@ -331,13 +342,8 @@ const planRetireContributionScaffold = Effect.fn('RetireContributionScaffold.pla } else { mutations = yield* planPageRetirement(vertical, name); } - yield* trySync( - () => ensureUniqueMutationPaths(mutations), - 'failed to validate retirement mutation paths', - ); - const deletedPaths = mutations - .filter((mutation) => mutation.kind === 'delete') - .map(({ path }) => path); + yield* trySync(() => ensureUniqueMutationPaths(mutations), 'failed to validate retirement mutation paths'); + const deletedPaths = mutations.filter((mutation) => mutation.kind === 'delete').map(({ path }) => path); return { mutations, result: { deletedPaths, kind: config.kind, name } }; }, ); diff --git a/app/scripts/scaffolding/search-provider-access/scaffold.mts b/app/scripts/scaffolding/search-provider-access/scaffold.mts index 891862c3b..84f11e2a6 100644 --- a/app/scripts/scaffolding/search-provider-access/scaffold.mts +++ b/app/scripts/scaffolding/search-provider-access/scaffold.mts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Schema } from 'effect'; + import { discoverOntosModuleEffect, ensureUniqueMutationPaths, @@ -45,9 +46,7 @@ const replaceOwnedLine = ( Effect.gen(function* replaceOwnedLineEffect() { const matches = [...content.matchAll(pattern)]; if (matches.length !== 1) { - return yield* scaffoldError( - `expected exactly one generated ${description}; found ${matches.length}`, - ); + return yield* scaffoldError(`expected exactly one generated ${description}; found ${matches.length}`); } return content.replace(pattern, replacement); }); @@ -64,9 +63,7 @@ const patchProvider = ( ): Effect.Effect => Effect.gen(function* patchProviderEffect() { if (!content.startsWith(generatedHeader)) { - return yield* scaffoldError( - 'search provider access updates require a Codesmith-owned provider', - ); + return yield* scaffoldError('search provider access updates require a Codesmith-owned provider'); } let next = yield* replaceOwnedLine( content, @@ -99,9 +96,7 @@ const patchContract = ( ): Effect.Effect => Effect.gen(function* patchContractEffect() { if (!content.startsWith(generatedHeader)) { - return yield* scaffoldError( - 'search provider access updates require a Codesmith-owned server contract', - ); + return yield* scaffoldError('search provider access updates require a Codesmith-owned server contract'); } const type = toPascalCase(config.name); const start = `export const ${type}ProviderRequestSchema = Schema.Struct({\n`; @@ -118,9 +113,7 @@ const patchContract = ( if (!/^ {2}query: .+,$/mu.test(fields)) { return yield* scaffoldError('generated provider request schema must retain its query field'); } - const knownFields = [...fields.matchAll(/^ {2}(?[A-Za-z][A-Za-z0-9]*):/gmu)].map( - ([, field]) => field, - ); + const knownFields = [...fields.matchAll(/^ {2}(?[A-Za-z][A-Za-z0-9]*):/gmu)].map(([, field]) => field); const expectedFields = new Set(['query', ...config.requestFilters]); if (knownFields.some((field) => field === undefined || !expectedFields.has(field))) { return yield* scaffoldError('provider request schema contains an unowned request filter'); @@ -161,19 +154,13 @@ const patchManifest = ( const matches = [...slot.matchAll(pattern)]; const [match] = matches; const resourceType = match?.[1]; - if ( - matches.length !== 1 || - resourceType === undefined || - !resourceType.startsWith(`${moduleId}.`) - ) { + if (matches.length !== 1 || resourceType === undefined || !resourceType.startsWith(`${moduleId}.`)) { return yield* scaffoldError(`expected exactly one generated search descriptor for ${key}`); } const requestFilterValues = config.requestFilters.map((filter) => `'${filter}'`).join(', '); const requestFilters = `[${requestFilterValues}]`; const tenantPermission = - config.tenantPermission === undefined - ? '' - : `, tenantPermission: '${config.tenantPermission}'`; + config.tenantPermission === undefined ? '' : `, tenantPermission: '${config.tenantPermission}'`; const replacement = `{ accessFiltering: '${config.accessFiltering}', key: '${key}', owningModuleId: '${moduleId}', requestFilters: ${requestFilters}, resourceType: '${resourceType}'${tenantPermission} },`; return `${content.slice(0, start + MODULE_MANIFEST_SEARCH_SLOT_START.length)}${slot.replace(pattern, replacement)}${content.slice(end)}`; }); @@ -225,9 +212,7 @@ export const planSearchProviderAccessScaffold = ( fileSystem .readFileString(filePath) .pipe( - Effect.mapError((cause) => - scaffoldError(`failed to read generated search provider file ${filePath}`, cause), - ), + Effect.mapError((cause) => scaffoldError(`failed to read generated search provider file ${filePath}`, cause)), ); const [provider, contract, server] = yield* Effect.all([ readGeneratedFile(providerPath), @@ -237,14 +222,10 @@ export const planSearchProviderAccessScaffold = ( const expectedRead = `${toCamelCase(config.name)}Read`; if ( !server.startsWith(generatedHeader) || - !server.includes( - `import { ${expectedRead} } from '../src/search/${config.name}.provider.ts';`, - ) || + !server.includes(`import { ${expectedRead} } from '../src/search/${config.name}.provider.ts';`) || !server.includes(`registration: ${expectedRead},`) ) { - return yield* scaffoldError( - 'generated search server no longer owns the expected provider registration', - ); + return yield* scaffoldError('generated search server no longer owns the expected provider registration'); } const mutations: Mutation[] = []; const nextProvider = yield* patchProvider(provider, vertical.moduleId, config); @@ -262,6 +243,11 @@ export const planSearchProviderAccessScaffold = ( yield* trySync(() => ensureUniqueMutationPaths(mutations)); return { mutations, - result: { contractPath, manifestPath: vertical.manifestPath, providerPath, serverPath }, + result: { + contractPath, + manifestPath: vertical.manifestPath, + providerPath, + serverPath, + }, }; }); diff --git a/app/scripts/scaffolding/search-provider/scaffold.mts b/app/scripts/scaffolding/search-provider/scaffold.mts index d481ed28a..a6037a544 100644 --- a/app/scripts/scaffolding/search-provider/scaffold.mts +++ b/app/scripts/scaffolding/search-provider/scaffold.mts @@ -2,7 +2,6 @@ import { createCodesmithGenerator } from '../generator-adapter.mts'; import { planGovernedContributionScaffold } from '../governed-contribution/scaffold.mts'; import type { GovernedContributionScaffoldConfig } from '../shared.mts'; -export default createCodesmithGenerator( - (workspaceRoot: string, config: GovernedContributionScaffoldConfig) => - planGovernedContributionScaffold(workspaceRoot, 'search-provider', config), +export default createCodesmithGenerator((workspaceRoot: string, config: GovernedContributionScaffoldConfig) => + planGovernedContributionScaffold(workspaceRoot, 'search-provider', config), ); diff --git a/app/scripts/scaffolding/shared.mts b/app/scripts/scaffolding/shared.mts index c59beefad..68fd1c382 100644 --- a/app/scripts/scaffolding/shared.mts +++ b/app/scripts/scaffolding/shared.mts @@ -1,22 +1,19 @@ import { NodePath } from '@effect/platform-node'; -import { scaffoldingRuntime } from '../scaffolding-runtime.mts'; import type { GeneratorCore } from '@modern-js/codesmith'; import { Effect, FileSystem, Option, Path, Predicate, Result, Schema } from 'effect'; import { format } from 'oxfmt'; -import ultraciteOxfmt from 'ultracite/oxfmt'; +import oxfmtConfig from '../../oxfmt.config.ts'; + import { ONTOS_MODULE_CONTRACT_SCHEMA_VERSION } from '../../packages/core-runtime/src/index.ts'; +import { scaffoldingRuntime } from '../scaffolding-runtime.mts'; /* eslint-disable unicorn/prefer-number-coercion -- The schema version is parsed as a base-10 integer by contract. expires: 2026-12-31. */ -export const ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION = Number.parseInt( - ONTOS_MODULE_CONTRACT_SCHEMA_VERSION, - 10, -); +export const ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION = Number.parseInt(ONTOS_MODULE_CONTRACT_SCHEMA_VERSION, 10); /* eslint-enable unicorn/prefer-number-coercion */ export const ACTION_GENERATOR_HEADER = '// @generated by OntOS Codesmith Action v1'; export const ACTION_SERVICE_GENERATOR_HEADER = '// @generated by OntOS Codesmith Action Service v1'; -export const EXTERNAL_HTTP_ADAPTER_GENERATOR_HEADER = - '// @generated by OntOS Codesmith External HTTP Adapter v1'; +export const EXTERNAL_HTTP_ADAPTER_GENERATOR_HEADER = '// @generated by OntOS Codesmith External HTTP Adapter v1'; export const RESOURCE_GENERATOR_HEADER = '// @generated by OntOS Codesmith Resource v1'; export const CORE_ACTION_SLOT_START = '// '; export const CORE_ACTION_SLOT_END = '// '; @@ -28,8 +25,7 @@ export const CORE_POLICY_SLOT_START = '// '; export const CORE_POLICY_SLOT_END = '// '; export const OUTBOX_SLOT_START = '// '; export const OUTBOX_SLOT_END = '// '; -export const OUTBOX_CONTRACT_GENERATOR_HEADER = - '// @generated by OntOS Codesmith Outbox Message Contract v1'; +export const OUTBOX_CONTRACT_GENERATOR_HEADER = '// @generated by OntOS Codesmith Outbox Message Contract v1'; export const OUTBOX_WORKER_GENERATOR_HEADER = '// @generated by OntOS Codesmith Outbox Worker v1'; export const OUTBOX_WORKER_IMPORT_SLOT_START = '// '; export const OUTBOX_WORKER_IMPORT_SLOT_END = '// '; @@ -45,22 +41,15 @@ export const GOVERNED_HTTP_API_IMPORT_SLOT_START = '// '; -export const GOVERNED_HTTP_HANDLER_IMPORT_SLOT_END = - '// '; +export const GOVERNED_HTTP_HANDLER_IMPORT_SLOT_START = '// '; +export const GOVERNED_HTTP_HANDLER_IMPORT_SLOT_END = '// '; export const GOVERNED_HTTP_HANDLER_LAYER_SLOT_START = '// '; export const GOVERNED_HTTP_HANDLER_LAYER_SLOT_END = '// '; -export const GOVERNED_HTTP_HANDLER_SUPPORT_IMPORT_SLOT_START = - '// '; -export const GOVERNED_HTTP_HANDLER_SUPPORT_IMPORT_SLOT_END = - '// '; -export const GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_START = - '// '; -export const GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_END = - '// '; -export const MODULE_CONTRACT_GENERATOR_HEADER = - '// @generated by OntOS Codesmith Module Contract v1'; +export const GOVERNED_HTTP_HANDLER_SUPPORT_IMPORT_SLOT_START = '// '; +export const GOVERNED_HTTP_HANDLER_SUPPORT_IMPORT_SLOT_END = '// '; +export const GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_START = '// '; +export const GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_END = '// '; +export const MODULE_CONTRACT_GENERATOR_HEADER = '// @generated by OntOS Codesmith Module Contract v1'; export const MODULE_MANIFEST_IMPORT_SLOT_START = '// '; export const MODULE_MANIFEST_IMPORT_SLOT_END = '// '; export const MODULE_MANIFEST_ACTION_SLOT_START = '// '; @@ -91,10 +80,8 @@ export const MODULE_REGISTRATION_ACTION_SLOT_START = '// '; -export const MODULE_REGISTRATION_COMPONENT_SLOT_END = - '// '; +export const MODULE_REGISTRATION_COMPONENT_SLOT_START = '// '; +export const MODULE_REGISTRATION_COMPONENT_SLOT_END = '// '; export const MODULE_REGISTRATION_PAGE_SLOT_START = '// '; export const MODULE_REGISTRATION_PAGE_SLOT_END = '// '; export const MODULE_REGISTRATION_REPORT_SLOT_START = '// '; @@ -114,9 +101,7 @@ export const createScaffoldErrorTools = ( const trySync = (operation: () => Value): Effect.Effect => Effect.try({ catch: (cause) => - isOwnError(cause) - ? cause - : scaffoldError(Predicate.isError(cause) ? cause.message : fallbackMessage, cause), + isOwnError(cause) ? cause : scaffoldError(Predicate.isError(cause) ? cause.message : fallbackMessage, cause), try: operation, }); return { scaffoldError, trySync }; @@ -349,8 +334,7 @@ export interface OntosVerticalMetadata extends VerticalMetadata { const canonicalSlugPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const stableAppIdPattern = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/u; const stableCoreModulePattern = /^core(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; -const stableOntosModulePattern = - /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; +const stableOntosModulePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; const topicPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u; const reservedSegments = new Set([ 'aux', @@ -385,22 +369,20 @@ const scaffoldFailureFromUnknown = (cause: unknown, fallback: string): ScaffoldF return cause.cause; } const message = - Predicate.hasProperty(cause, 'message') && isScaffoldFailureMessage(cause.message) - ? cause.message - : fallback; + Predicate.hasProperty(cause, 'message') && isScaffoldFailureMessage(cause.message) ? cause.message : fallback; return scaffoldFailure(message, cause); }; export const tryScaffold = (message: string, evaluate: () => Value) => - Effect.try({ catch: (cause) => scaffoldFailureFromUnknown(cause, message), try: evaluate }); + Effect.try({ + catch: (cause) => scaffoldFailureFromUnknown(cause, message), + try: evaluate, + }); export const raiseScaffoldFailure = (message: string, cause?: unknown): never => scaffoldingRuntime.runSync(Effect.die(scaffoldFailure(message, cause))); -const decodeResultOrRaise = ( - result: Result.Result, - message: string, -): Value => { +const decodeResultOrRaise = (result: Result.Result, message: string): Value => { if (Result.isFailure(result)) { return raiseScaffoldFailure(message, result.failure); } @@ -498,11 +480,7 @@ const closesNonCodeQuote = (state: NonCodeState, character: string): boolean => (state === DOUBLE_QUOTE_STATE && character === '"') || (state === TEMPLATE_STATE && character === '`'); -const advanceRegexState = ( - state: NonCodeState, - character: string, - regexCharacterClass: boolean, -): NonCodeTransition => { +const advanceRegexState = (state: NonCodeState, character: string, regexCharacterClass: boolean): NonCodeTransition => { if (state !== REGEX_STATE) { return { escaped: false, regexCharacterClass, state }; } @@ -553,10 +531,7 @@ const advanceNonCodeState = ( }; const shouldMaskNonCodeState = (state: NonCodeState, preserveStrings: boolean): boolean => - !preserveStrings || - state === BLOCK_COMMENT_STATE || - state === LINE_COMMENT_STATE || - state === REGEX_STATE; + !preserveStrings || state === BLOCK_COMMENT_STATE || state === LINE_COMMENT_STATE || state === REGEX_STATE; const stringCodeUnits = (content: string): string[] => { const units: string[] = []; @@ -693,18 +668,13 @@ const moduleFederationExposesRange = (content: string): ModuleFederationExposesR return { closeIndex, openIndex, propertyIndex }; }; -export const moduleFederationExposureSource = ( - content: string, - exposureKey: string, -): string | undefined => { +export const moduleFederationExposureSource = (content: string, exposureKey: string): string | undefined => { const { closeIndex, openIndex } = moduleFederationExposesRange(content); const body = content.slice(openIndex + 1, closeIndex); const searchableBody = maskNonCode(body, true); const escapedKey = exposureKey.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); const keyMatches = [ - ...searchableBody.matchAll( - new RegExp(`(?:'${escapedKey}'|"${escapedKey}"|\`${escapedKey}\`)\\s*:`, 'gu'), - ), + ...searchableBody.matchAll(new RegExp(`(?:'${escapedKey}'|"${escapedKey}"|\`${escapedKey}\`)\\s*:`, 'gu')), ]; if (keyMatches.length > 1) { return raiseScaffoldFailure(`Module Federation exposure ${exposureKey} is duplicated`); @@ -724,11 +694,7 @@ export const moduleFederationExposureSource = ( return match?.groups?.['single'] ?? match?.groups?.['double'] ?? match?.groups?.['template']; }; -export const insertModuleFederationExposure = ( - content: string, - exposureKey: string, - sourcePath: string, -): string => { +export const insertModuleFederationExposure = (content: string, exposureKey: string, sourcePath: string): string => { const { closeIndex, openIndex, propertyIndex } = moduleFederationExposesRange(content); const body = content.slice(openIndex + 1, closeIndex); if (moduleFederationExposureSource(content, exposureKey) !== undefined) { @@ -745,18 +711,13 @@ export const insertModuleFederationExposure = ( return `${content.slice(0, openIndex + 1)}${nextBody}${content.slice(closeIndex)}`; }; -export const resolveContainedPath = ( - workspaceRoot: string, - ...segments: readonly string[] -): string => { +export const resolveContainedPath = (workspaceRoot: string, ...segments: readonly string[]): string => { const root = nodePath.resolve(workspaceRoot); const target = nodePath.resolve(root, ...segments); const relative = nodePath.relative(root, target); if ( relative === '' || - (!relative.startsWith(`..${nodePath.sep}`) && - relative !== '..' && - !nodePath.isAbsolute(relative)) + (!relative.startsWith(`..${nodePath.sep}`) && relative !== '..' && !nodePath.isAbsolute(relative)) ) { return target; } @@ -774,18 +735,14 @@ export const requireCanonicalSlug = (value: string, label: string): string => { export const requireTopic = (value: string): string => { if (!topicPattern.test(value)) { - return raiseScaffoldFailure( - 'topic must be a lowercase dot-separated identifier with optional kebab-case segments', - ); + return raiseScaffoldFailure('topic must be a lowercase dot-separated identifier with optional kebab-case segments'); } return value; }; export const requireCoreModuleKey = (value: string): string => { if (!stableCoreModulePattern.test(value)) { - return raiseScaffoldFailure( - 'module must be a stable lowercase core.* identifier without paths or traversal', - ); + return raiseScaffoldFailure('module must be a stable lowercase core.* identifier without paths or traversal'); } return value; }; @@ -823,11 +780,7 @@ export const readJsonEffect = (filePath: string, label: string) => } const content = yield* fileSystem .readFileString(filePath) - .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to read ${label} at ${filePath}`, cause), - ), - ); + .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read ${label} at ${filePath}`, cause))); const parsed = Schema.decodeUnknownResult(JsonValueFromStringSchema)(content); if (Result.isFailure(parsed)) { return yield* scaffoldFailure(`${label} is not valid JSON at ${filePath}`, parsed.failure); @@ -1023,9 +976,7 @@ const assertUniqueGeneratedVerticalAppIdsEffect = (workspaceRoot: string) => const entries = yield* fileSystem .readDirectory(verticalRoot) .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to inspect generated verticals at ${verticalRoot}`, cause), - ), + Effect.mapError((cause) => scaffoldFailure(`failed to inspect generated verticals at ${verticalRoot}`, cause)), ); const identities = yield* Effect.forEach( entries, @@ -1035,14 +986,8 @@ const assertUniqueGeneratedVerticalAppIdsEffect = (workspaceRoot: string) => if (!(yield* pathExistsEffect(packagePath))) { return null; } - const { value } = yield* readJsonEffect( - packagePath, - `vertical ${entryName} package metadata`, - ); - const modernjs = asJsonObject( - value['modernjs'], - `vertical ${entryName} modernjs metadata`, - ); + const { value } = yield* readJsonEffect(packagePath, `vertical ${entryName} package metadata`); + const modernjs = asJsonObject(value['modernjs'], `vertical ${entryName} modernjs metadata`); if (modernjs['role'] !== 'module-federation-remote') { return null; } @@ -1075,25 +1020,14 @@ const resolveVerticalTopologyEntryEffect = ( modernjs: JsonObject, ): Effect.Effect => Effect.gen(function* resolveVerticalTopologyEntryProgram() { - const topologyReference = requiredString( - modernjs['topology'], - `vertical ${vertical.slug} topology reference`, - ); + const topologyReference = requiredString(modernjs['topology'], `vertical ${vertical.slug} topology reference`); if (nodePath.isAbsolute(topologyReference)) { - return yield* scaffoldFailure( - `vertical ${vertical.slug} topology reference must be workspace-relative`, - ); + return yield* scaffoldFailure(`vertical ${vertical.slug} topology reference must be workspace-relative`); } const topologyPath = nodePath.resolve(vertical.directory, topologyReference); - const canonicalTopologyPath = resolveContainedPath( - workspaceRoot, - 'topology', - 'reference-topology.json', - ); + const canonicalTopologyPath = resolveContainedPath(workspaceRoot, 'topology', 'reference-topology.json'); if (topologyPath !== canonicalTopologyPath) { - return yield* scaffoldFailure( - `vertical ${vertical.slug} must reference topology/reference-topology.json`, - ); + return yield* scaffoldFailure(`vertical ${vertical.slug} must reference topology/reference-topology.json`); } const { value: topology } = yield* readJsonEffect(topologyPath, 'generated workspace topology'); const entries = topologyEntries(topology); @@ -1141,9 +1075,7 @@ export const discoverVerticalEffect = ( const packageName = requiredString(packageJson['name'], `vertical ${slug} package name`); const modernjs = asJsonObject(packageJson['modernjs'], `vertical ${slug} modernjs metadata`); if (modernjs['role'] !== 'module-federation-remote') { - return yield* scaffoldFailure( - `vertical ${slug} is not a generated Module Federation remote package`, - ); + return yield* scaffoldFailure(`vertical ${slug} is not a generated Module Federation remote package`); } const appId = requiredString(modernjs['appId'], `vertical ${slug} appId`); if (!stableAppIdPattern.test(appId)) { @@ -1162,11 +1094,7 @@ export const discoverVerticalEffect = ( packagePath, slug, }; - const topologyEntry = yield* resolveVerticalTopologyEntryEffect( - workspaceRoot, - vertical, - modernjs, - ); + const topologyEntry = yield* resolveVerticalTopologyEntryEffect(workspaceRoot, vertical, modernjs); return { ...vertical, topologyEntry }; }); @@ -1174,39 +1102,22 @@ const readGeneratedModuleOwnerEffect = ( filePath: string, vertical: VerticalMetadata, label: string, -): Effect.Effect< - { readonly content: string; readonly moduleId: string }, - ScaffoldFailure, - FileSystem.FileSystem -> => +): Effect.Effect<{ readonly content: string; readonly moduleId: string }, ScaffoldFailure, FileSystem.FileSystem> => Effect.gen(function* readGeneratedModuleOwnerProgram() { const fileSystem = yield* FileSystem.FileSystem; if (!(yield* pathExistsEffect(filePath))) { - return yield* scaffoldFailure( - `vertical ${vertical.slug} requires scaffold:module-contract before ${label}`, - ); + return yield* scaffoldFailure(`vertical ${vertical.slug} requires scaffold:module-contract before ${label}`); } const content = yield* fileSystem .readFileString(filePath) - .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to read vertical ${vertical.slug} ${label}`, cause), - ), - ); + .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read vertical ${vertical.slug} ${label}`, cause))); if (!content.startsWith(`${MODULE_CONTRACT_GENERATOR_HEADER}\n`)) { - return yield* scaffoldFailure( - `vertical ${vertical.slug} ${label} is not a generated module owner`, - ); + return yield* scaffoldFailure(`vertical ${vertical.slug} ${label} is not a generated module owner`); } - const deploymentAppId = /^\/\/ @ontos-deployment-app-id (?[^\s]+)$/mu.exec(content) - ?.groups?.['appId']; - const moduleId = /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(content)?.groups?.[ - 'moduleId' - ]; + const deploymentAppId = /^\/\/ @ontos-deployment-app-id (?[^\s]+)$/mu.exec(content)?.groups?.['appId']; + const moduleId = /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(content)?.groups?.['moduleId']; if (deploymentAppId !== vertical.appId || moduleId === undefined) { - return yield* scaffoldFailure( - `vertical ${vertical.slug} ${label} identity markers are inconsistent`, - ); + return yield* scaffoldFailure(`vertical ${vertical.slug} ${label} identity markers are inconsistent`); } requireOntosModuleId(moduleId); return { content, moduleId }; @@ -1225,10 +1136,7 @@ export const discoverOntosModuleEffect = ( readGeneratedModuleOwnerEffect(manifestPath, vertical, 'manifest'), readGeneratedModuleOwnerEffect(registrationPath, vertical, 'registration'), ]); - const modernjs = asJsonObject( - vertical.packageJson['modernjs'], - `vertical ${vertical.slug} modernjs metadata`, - ); + const modernjs = asJsonObject(vertical.packageJson['modernjs'], `vertical ${vertical.slug} modernjs metadata`); const ontosModule = asJsonObject( modernjs['ontosModule'], `vertical ${vertical.slug} generated OntOS module metadata`, @@ -1255,7 +1163,7 @@ export const discoverOntosModuleEffect = ( }; }); -const formatGeneratedMutationContent = ( +export const formatGeneratedMutationContent = ( filePath: string, content: string, ): Effect.Effect => @@ -1266,14 +1174,11 @@ const formatGeneratedMutationContent = ( } const formatted = yield* Effect.tryPromise({ catch: (cause) => scaffoldFailure(`failed to format generated source ${filePath}`, cause), - try: async () => - await format(filePath, content, { extends: [ultraciteOxfmt], singleQuote: true }), + try: async () => await format(filePath, content, oxfmtConfig), }); if (formatted.errors.length > 0) { return yield* scaffoldFailure( - `failed to format generated source ${filePath}: ${formatted.errors - .map((error) => error.message) - .join('; ')}`, + `failed to format generated source ${filePath}: ${formatted.errors.map((error) => error.message).join('; ')}`, ); } return formatted.code; @@ -1307,11 +1212,7 @@ export const createOrAcceptGeneratedMutationEffect = ( const [current, expected] = yield* Effect.all([ fileSystem .readFileString(filePath) - .pipe( - Effect.mapError((cause) => - scaffoldFailure(`failed to read generated business file ${filePath}`, cause), - ), - ), + .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to read generated business file ${filePath}`, cause))), formatGeneratedMutationContent(filePath, content), ]); if (current === expected || acceptsCurrent(current)) { @@ -1320,11 +1221,7 @@ export const createOrAcceptGeneratedMutationEffect = ( return yield* scaffoldFailure(`refusing to overwrite existing business file: ${filePath}`); }); -export const updateMutation = ( - filePath: string, - previous: string, - content: string, -): Mutation | undefined => +export const updateMutation = (filePath: string, previous: string, content: string): Mutation | undefined => previous === content ? undefined : { content, kind: 'update', path: filePath }; export const deleteMutationEffect = ( @@ -1345,12 +1242,12 @@ export const withCoreDependency = (vertical: VerticalMetadata): Mutation | undef const dependencies: MutableJsonObject = dependenciesValue === undefined ? {} - : { ...asJsonObject(dependenciesValue, `vertical ${vertical.slug} dependencies`) }; + : { + ...asJsonObject(dependenciesValue, `vertical ${vertical.slug} dependencies`), + }; const current = dependencies[CORE_RUNTIME_PACKAGE]; if (current !== undefined && current !== WORKSPACE_DEPENDENCY_VERSION) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} has an incompatible ${CORE_RUNTIME_PACKAGE} dependency`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} has an incompatible ${CORE_RUNTIME_PACKAGE} dependency`); } if (current === WORKSPACE_DEPENDENCY_VERSION) { return undefined; @@ -1374,14 +1271,14 @@ export const withExactDependencies = ( const dependencies: MutableJsonObject = dependenciesValue === undefined ? {} - : { ...asJsonObject(dependenciesValue, `vertical ${vertical.slug} dependencies`) }; + : { + ...asJsonObject(dependenciesValue, `vertical ${vertical.slug} dependencies`), + }; let changed = false; for (const [name, version] of Object.entries(required)) { const current = dependencies[name]; if (current !== undefined && current !== version) { - return raiseScaffoldFailure( - `vertical ${vertical.slug} has an incompatible ${name} dependency`, - ); + return raiseScaffoldFailure(`vertical ${vertical.slug} has an incompatible ${name} dependency`); } if (current === undefined) { dependencies[name] = version; @@ -1405,9 +1302,7 @@ export const ensureUniqueMutationPaths = (mutations: readonly Mutation[]): void const paths = new Set(); for (const mutation of mutations) { if (paths.has(mutation.path)) { - return raiseScaffoldFailure( - `scaffold planned the same path more than once: ${mutation.path}`, - ); + return raiseScaffoldFailure(`scaffold planned the same path more than once: ${mutation.path}`); } paths.add(mutation.path); } @@ -1421,33 +1316,25 @@ export const applyMutationPlanEffect = ( ensureUniqueMutationPaths(plan.mutations); for (const mutation of plan.mutations) { const relative = nodePath.relative(core.outputPath, mutation.path); - if ( - relative.startsWith(`..${nodePath.sep}`) || - relative === '..' || - nodePath.isAbsolute(relative) - ) { - return yield* scaffoldFailure( - `Codesmith mutation escapes its output root: ${mutation.path}`, - ); + if (relative.startsWith(`..${nodePath.sep}`) || relative === '..' || nodePath.isAbsolute(relative)) { + return yield* scaffoldFailure(`Codesmith mutation escapes its output root: ${mutation.path}`); } } - const writes = plan.mutations.filter( - (mutation): mutation is WriteMutation => mutation.kind !== 'delete', - ); - const deletions = plan.mutations.filter( - (mutation): mutation is DeleteMutation => mutation.kind === 'delete', + const writes = yield* Effect.forEach( + plan.mutations.filter((mutation): mutation is WriteMutation => mutation.kind !== 'delete'), + (mutation) => + formatGeneratedMutationContent(mutation.path, mutation.content).pipe( + Effect.map((content) => ({ ...mutation, content })), + ), ); + const deletions = plan.mutations.filter((mutation): mutation is DeleteMutation => mutation.kind === 'delete'); yield* Effect.forEach( writes, (mutation) => Effect.tryPromise({ catch: (cause) => scaffoldFailure(`Codesmith failed to write ${mutation.path}`, cause), try: async () => - await core.output.fs( - nodePath.relative(core.outputPath, mutation.path), - mutation.content, - 'utf-8', - ), + await core.output.fs(nodePath.relative(core.outputPath, mutation.path), mutation.content, 'utf-8'), }).pipe(Effect.uninterruptible), { concurrency: 'unbounded', discard: true }, ); @@ -1455,9 +1342,7 @@ export const applyMutationPlanEffect = ( yield* Effect.forEach(deletions, (mutation) => fileSystem.remove(mutation.path), { concurrency: 'unbounded', discard: true, - }).pipe( - Effect.mapError((cause) => scaffoldFailure('Codesmith failed to delete an artifact', cause)), - ); + }).pipe(Effect.mapError((cause) => scaffoldFailure('Codesmith failed to delete an artifact', cause))); return plan.result; }); @@ -1473,9 +1358,7 @@ const dedentGeneratedSlotBody = (slotBody: string): string => { return ''; } const indentation = Math.min( - ...lines - .filter((line) => line.trim().length > 0) - .map((line) => /^[ \t]*/u.exec(line)?.[0].length ?? 0), + ...lines.filter((line) => line.trim().length > 0).map((line) => /^[ \t]*/u.exec(line)?.[0].length ?? 0), ); return lines.map((line) => line.slice(indentation)).join('\n'); }; @@ -1581,14 +1464,8 @@ const isCompleteFluentSlotTail = (state: GeneratedSlotScanState, source: string) !state.blockComment && generatedSlotDepthIsZero(state); -const isGeneratedSlotFluentBoundary = ( - state: GeneratedSlotScanState, - character: string, - source: string, -): boolean => - character === '.' && - /\n\s*\.$/u.test(source) && - isCompleteFluentSlotTail(state, source.slice(0, -1)); +const isGeneratedSlotFluentBoundary = (state: GeneratedSlotScanState, character: string, source: string): boolean => + character === '.' && /\n\s*\.$/u.test(source) && isCompleteFluentSlotTail(state, source.slice(0, -1)); const scanGeneratedSlotEntries = ( body: string, @@ -1602,9 +1479,7 @@ const scanGeneratedSlotEntries = ( const previousCharacter = body.charAt(index - 1); const nextCharacter = body.charAt(index + 1); current += character; - if ( - consumeGeneratedSlotProtectedCharacter(state, character, previousCharacter, nextCharacter) - ) { + if (consumeGeneratedSlotProtectedCharacter(state, character, previousCharacter, nextCharacter)) { continue; } updateGeneratedSlotDepth(state, character); @@ -1642,9 +1517,7 @@ const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { let current = scanGeneratedSlotEntries(body, state, entries, fluentTailBoundaries); if (isCompleteFluentSlotTail(state, current)) { entries.push( - ...[0, ...fluentTailBoundaries].map((start, index) => - current.slice(start, fluentTailBoundaries[index]).trim(), - ), + ...[0, ...fluentTailBoundaries].map((start, index) => current.slice(start, fluentTailBoundaries[index]).trim()), ); current = ''; } @@ -1658,6 +1531,8 @@ const normalizeGeneratedSlotEntry = (entry: string): string => entry .replaceAll(/,\s*(?[\]})])/gu, '$') .replaceAll(/\s+/gu, ' ') + .replaceAll(/\(\s+/gu, '(') + .replaceAll(/\s+\)/gu, ')') .replaceAll(/\s+(?[}\]])/gu, '$') .trim(); @@ -1680,13 +1555,9 @@ export const readGeneratedSlotEntries = ( content.includes(startMarker, start + startMarker.length) || content.includes(endMarker, end + endMarker.length) ) { - return raiseScaffoldFailure( - `generated owner file does not contain one valid ${startMarker} slot`, - ); + return raiseScaffoldFailure(`generated owner file does not contain one valid ${startMarker} slot`); } - const entries = Result.try(() => - splitGeneratedSlotEntries(content.slice(start + startMarker.length, end)), - ); + const entries = Result.try(() => splitGeneratedSlotEntries(content.slice(start + startMarker.length, end))); if (Result.isFailure(entries)) { return raiseScaffoldFailure( `generated owner slot contains unsupported developer content: ${startMarker}`, @@ -1728,9 +1599,7 @@ export const removeGeneratedSlotEntry = ( const entries = readGeneratedSlotEntries(content, startMarker, endMarker); const matching = entries.filter(matches); if (matching.length !== 1) { - return raiseScaffoldFailure( - `expected exactly one generated ${label}; found ${matching.length}`, - ); + return raiseScaffoldFailure(`expected exactly one generated ${label}; found ${matching.length}`); } const remaining = entries.filter((entry) => !matches(entry)); return renderGeneratedSlotEntries(content, startMarker, endMarker, remaining); @@ -1759,9 +1628,7 @@ export const insertSortedSlot = ( ): string => { const existing = readGeneratedSlotEntries(content, startMarker, endMarker); if (existing.some((line) => !validateEntry(line))) { - return raiseScaffoldFailure( - `generated owner slot contains unsupported developer content: ${startMarker}`, - ); + return raiseScaffoldFailure(`generated owner slot contains unsupported developer content: ${startMarker}`); } const existingNormalized = new Set(existing.map(normalizeGeneratedSlotEntry)); for (const entry of additions) { diff --git a/app/scripts/scaffolding/tailwind-prefix.mts b/app/scripts/scaffolding/tailwind-prefix.mts index 96eeee5b5..eb49fcd69 100644 --- a/app/scripts/scaffolding/tailwind-prefix.mts +++ b/app/scripts/scaffolding/tailwind-prefix.mts @@ -1,17 +1,6 @@ import { Result, Schema } from 'effect'; -const digitWords = [ - 'zero', - 'one', - 'two', - 'three', - 'four', - 'five', - 'six', - 'seven', - 'eight', - 'nine', -] as const; +const digitWords = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'] as const; class TailwindPrefixError extends Schema.TaggedError()('TailwindPrefixError', { message: Schema.String, diff --git a/app/scripts/scaffolding/tests/fixture-files.mts b/app/scripts/scaffolding/tests/fixture-files.mts index 7451b305a..cf0154cd9 100644 --- a/app/scripts/scaffolding/tests/fixture-files.mts +++ b/app/scripts/scaffolding/tests/fixture-files.mts @@ -1,12 +1,9 @@ -import { Effect } from 'effect'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -export const write = Effect.fn(function* writeFixture( - root: string, - relativePath: string, - content: string, -) { +import { Effect } from 'effect'; + +export const write = Effect.fn(function* writeFixture(root: string, relativePath: string, content: string) { const target = path.join(root, relativePath); yield* Effect.promise(async () => await mkdir(path.dirname(target), { recursive: true })); yield* Effect.promise(async () => await writeFile(target, content, 'utf-8')); @@ -19,9 +16,7 @@ const visitTree = ( snapshot: Record, ): Effect.Effect => Effect.gen(function* visitFixtureTree() { - const entries = yield* Effect.promise( - async () => await readdir(directory, { withFileTypes: true }), - ); + const entries = yield* Effect.promise(async () => await readdir(directory, { withFileTypes: true })); yield* Effect.forEach( entries.toSorted((left, right) => left.name.localeCompare(right.name)), Effect.fn(function* visitFixtureEntry(entry) { @@ -29,9 +24,7 @@ const visitTree = ( if (entry.isDirectory() && !excludedDirectories.includes(entry.name)) { yield* visitTree(root, target, excludedDirectories, snapshot); } else if (entry.isFile()) { - snapshot[path.relative(root, target)] = yield* Effect.promise( - async () => await readFile(target, 'utf-8'), - ); + snapshot[path.relative(root, target)] = yield* Effect.promise(async () => await readFile(target, 'utf-8')); } }), { concurrency: 'unbounded', discard: true }, diff --git a/app/scripts/scaffolding/tests/fixture-ownership.mts b/app/scripts/scaffolding/tests/fixture-ownership.mts index d958ccb97..1a2a259a1 100644 --- a/app/scripts/scaffolding/tests/fixture-ownership.mts +++ b/app/scripts/scaffolding/tests/fixture-ownership.mts @@ -1,7 +1,8 @@ -import { Effect } from 'effect'; import { mkdir, rm, symlink } from 'node:fs/promises'; import path from 'node:path'; +import { Effect } from 'effect'; + /** Link only the dependencies a generated subprocess fixture actually needs. */ export const linkFixtureDependencies = Effect.fn(function* linkFixtureDependencies( root: string, diff --git a/app/scripts/scaffolding/tests/module-contract-generator.test.mts b/app/scripts/scaffolding/tests/module-contract-generator.test.mts index c9826daef..b62b3deef 100644 --- a/app/scripts/scaffolding/tests/module-contract-generator.test.mts +++ b/app/scripts/scaffolding/tests/module-contract-generator.test.mts @@ -1,20 +1,20 @@ -import { write } from './fixture-files.mts'; -import { linkFixtureDependencies, withCreatedFixture } from './fixture-ownership.mts'; -import { Cause, Effect, Schema } from 'effect'; -import { expect, it } from 'effect-rstest'; -import { NodeServices } from '@effect/platform-node'; - import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; + +import { NodeServices } from '@effect/platform-node'; +import { Cause, Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { privateOwnerImportViolation, unconstrainedHttpApiContractSchemaViolation, } from '../../ultramodern-api-boundary-rules.mts'; -// Match the native module identity used by the generated fixture's owner bundle. - import { getHelpText, runScaffoldEffect } from '../cli.mts'; import type { JsonValue } from '../shared.mts'; +// Match the native module identity used by the generated fixture's owner bundle. +import { write } from './fixture-files.mts'; +import { linkFixtureDependencies, withCreatedFixture } from './fixture-ownership.mts'; const { checkOntosModuleContracts } = await import( /* webpackIgnore: true */ @@ -62,7 +62,9 @@ const ModulePackageSchema = Schema.Struct({ }), scripts: StringRecordSchema, }); -const ModuleTsconfigSchema = Schema.Struct({ include: Schema.Array(Schema.String) }); +const ModuleTsconfigSchema = Schema.Struct({ + include: Schema.Array(Schema.String), +}); const ModuleContractDocumentSchema = Schema.Struct({ deployment: Schema.Struct({ appId: AppIdSchema }), manifest: Schema.Struct({ @@ -74,13 +76,13 @@ const ModuleContractDocumentSchema = Schema.Struct({ schemaVersion: Schema.String, }); const decodeModulePackage = (source: string) => - Schema.decodeUnknownEffect(ModulePackageSchema, { onExcessProperty: 'preserve' })( - JSON.parse(source), - ); + Schema.decodeUnknownEffect(ModulePackageSchema, { + onExcessProperty: 'preserve', + })(JSON.parse(source)); const decodeModuleContract = (source: string) => - Schema.decodeUnknownEffect(ModuleContractDocumentSchema, { onExcessProperty: 'preserve' })( - JSON.parse(source), - ); + Schema.decodeUnknownEffect(ModuleContractDocumentSchema, { + onExcessProperty: 'preserve', + })(JSON.parse(source)); const appRoot = path.resolve(import.meta.dirname, '..', '..', '..'); const json = (value: JsonValue): string => `${JSON.stringify(value, null, 2)}\n`; @@ -106,9 +108,7 @@ export default defineEffectBff({ api: fixtureApi, layer }); const createFixture = (): Effect.Effect => Effect.gen(function* mergedScenario2() { - const root = yield* Effect.promise(() => - mkdtemp(path.join(tmpdir(), 'ontos-module-contract-')), - ); + const root = yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), 'ontos-module-contract-'))); yield* write(root, 'package.json', json({ name: 'fixture', private: true, type: 'module' })); yield* write( root, @@ -138,13 +138,13 @@ const createFixture = (): Effect.Effect => yield* write( root, 'verticals/property-registry/tsconfig.json', - json({ compilerOptions: { composite: true }, include: ['src', 'shared'], references: [] }), - ); - yield* write( - root, - 'verticals/property-registry/module-federation.config.ts', - `export default { exposes: {} };\n`, + json({ + compilerOptions: { composite: true }, + include: ['src', 'shared'], + references: [], + }), ); + yield* write(root, 'verticals/property-registry/module-federation.config.ts', `export default { exposes: {} };\n`); yield* write( root, 'verticals/documents-center/package.json', @@ -168,17 +168,16 @@ const createFixture = (): Effect.Effect => yield* write( root, 'verticals/documents-center/tsconfig.json', - json({ compilerOptions: { composite: true }, include: ['src'], references: [] }), - ); - yield* write( - root, - 'verticals/documents-center/module-federation.config.ts', - 'export default {};\n', - ); - yield* Effect.all( - [writePinnedEffectApi(root, APP_ID), writePinnedEffectApi(root, DOCUMENTS_APP_ID)], - { concurrency: 'unbounded' }, + json({ + compilerOptions: { composite: true }, + include: ['src'], + references: [], + }), ); + yield* write(root, 'verticals/documents-center/module-federation.config.ts', 'export default {};\n'); + yield* Effect.all([writePinnedEffectApi(root, APP_ID), writePinnedEffectApi(root, DOCUMENTS_APP_ID)], { + concurrency: 'unbounded', + }); yield* write( root, 'topology/reference-topology.json', @@ -190,7 +189,10 @@ const createFixture = (): Effect.Effect => domain: 'property', id: APP_ID, kind: 'vertical', - moduleFederation: { name: 'verticalPropertyRegistry', role: 'remote' }, + moduleFederation: { + name: 'verticalPropertyRegistry', + role: 'remote', + }, package: '@app/property-registry', path: 'verticals/property-registry', }, @@ -199,7 +201,10 @@ const createFixture = (): Effect.Effect => domain: 'documents', id: DOCUMENTS_APP_ID, kind: 'vertical', - moduleFederation: { name: 'verticalDocumentsCenter', role: 'remote' }, + moduleFederation: { + name: 'verticalDocumentsCenter', + role: 'remote', + }, package: '@app/documents-center', path: 'verticals/documents-center', }, @@ -227,18 +232,10 @@ const createFixture = (): Effect.Effect => const withFixture = withCreatedFixture(createFixture()); -const scaffold = Effect.fn(function* scenario4( - root: string, - vertical = APP_ID, - module = MODULE_ID, -) { - return yield* runScaffoldEffect( - MODULE_CONTRACT_COMMAND, - [VERTICAL_FLAG, vertical, '--module', module], - { - workspaceRoot: root, - }, - ).pipe(Effect.provide(NodeServices.layer)); +const scaffold = Effect.fn(function* scenario4(root: string, vertical = APP_ID, module = MODULE_ID) { + return yield* runScaffoldEffect(MODULE_CONTRACT_COMMAND, [VERTICAL_FLAG, vertical, '--module', module], { + workspaceRoot: root, + }).pipe(Effect.provide(NodeServices.layer)); }); it.live( @@ -248,7 +245,10 @@ it.live( const result = yield* runScaffoldEffect(MODULE_CONTRACT_COMMAND, ['--help'], { workspaceRoot: missingRoot, }).pipe(Effect.provide(NodeServices.layer)); - expect(result).toEqual({ help: getHelpText(MODULE_CONTRACT_COMMAND), kind: 'help' }); + expect(result).toEqual({ + help: getHelpText(MODULE_CONTRACT_COMMAND), + kind: 'help', + }); if (result.kind !== 'help') { throw new Error('Expected help result'); } @@ -291,10 +291,7 @@ it.live( 'module.access', ], ], - [ - 'outbox-message', - [VERTICAL_FLAG, APP_ID, '--action', 'create-property', '--topic', 'property.created'], - ], + ['outbox-message', [VERTICAL_FLAG, APP_ID, '--action', 'create-property', '--topic', 'property.created']], [ 'outbox-worker', [ @@ -310,18 +307,13 @@ it.live( 'owner_local_background', ], ], - [ - 'policy', - ['--scope', 'microvertical', VERTICAL_FLAG, APP_ID, '--policy', 'property-visible'], - ], + ['policy', ['--scope', 'microvertical', VERTICAL_FLAG, APP_ID, '--policy', 'property-visible']], ] as const; yield* Effect.all( commands.map( Effect.fn(function* scenario8([command, flags]) { return yield* expectFailure( - runScaffoldEffect(command, flags, { workspaceRoot: root }).pipe( - Effect.provide(NodeServices.layer), - ), + runScaffoldEffect(command, flags, { workspaceRoot: root }).pipe(Effect.provide(NodeServices.layer)), (error) => expect(String(error)).toMatch(/requires scaffold:module-contract/u), ); }), @@ -341,9 +333,7 @@ it.live( yield* expectFailure(scaffold(root, '../property', MODULE_ID), (error) => expect(String(error)).toMatch(/lower-kebab-case/u), ); - yield* expectFailure(scaffold(root, APP_ID, APP_ID), (error) => - expect(String(error)).toMatch(/dotted/u), - ); + yield* expectFailure(scaffold(root, APP_ID, APP_ID), (error) => expect(String(error)).toMatch(/dotted/u)); yield* expectFailure(scaffold(root, APP_ID, 'core.modules'), (error) => expect(String(error)).toMatch(/non-core/u), ); @@ -351,12 +341,10 @@ it.live( const packageAfterFirst = yield* Effect.promise(() => readFile(path.join(root, PROPERTY_PACKAGE_PATH), 'utf-8'), ); - yield* expectFailure(scaffold(root), (error) => - expect(String(error)).toMatch(/refusing to overwrite/u), + yield* expectFailure(scaffold(root), (error) => expect(String(error)).toMatch(/refusing to overwrite/u)); + expect(yield* Effect.promise(() => readFile(path.join(root, PROPERTY_PACKAGE_PATH), 'utf-8'))).toBe( + packageAfterFirst, ); - expect( - yield* Effect.promise(() => readFile(path.join(root, PROPERTY_PACKAGE_PATH), 'utf-8')), - ).toBe(packageAfterFirst); yield* expectFailure(scaffold(root, DOCUMENTS_APP_ID, MODULE_ID), (error) => expect(String(error)).toMatch(/duplicate OntOS module ID/u), ); @@ -372,14 +360,9 @@ it.live( Effect.fn(function* scenario12(root) { const result = yield* scaffold(root); expect(result.kind).toBe('generated'); - const manifest = yield* Effect.promise(() => - readFile(path.join(root, PROPERTY_MANIFEST_PATH), 'utf-8'), - ); + const manifest = yield* Effect.promise(() => readFile(path.join(root, PROPERTY_MANIFEST_PATH), 'utf-8')); const registration = yield* Effect.promise(() => - readFile( - path.join(root, 'verticals/property-registry/vertical.registration.ts'), - 'utf-8', - ), + readFile(path.join(root, 'verticals/property-registry/vertical.registration.ts'), 'utf-8'), ); expect(manifest).toMatch(/@ontos-deployment-app-id property-registry/u); expect(manifest).toMatch(/@ontos-module-id property\.registry/u); @@ -407,9 +390,7 @@ it.live( }); expect(packageJson.exports).toEqual({ '.': './src/index.ts' }); expect(packageJson.scripts['existing']).toBe('preserve-me'); - expect(packageJson.scripts['build'] ?? '').toMatch( - /--vertical property-registry --target dist/u, - ); + expect(packageJson.scripts['build'] ?? '').toMatch(/--vertical property-registry --target dist/u); expect(packageJson.scripts['cloudflare:build'] ?? '').toMatch( /--vertical property-registry --target cloudflare-dist/u, ); @@ -427,12 +408,7 @@ it.live( ), ), ); - expect(tsconfig.include).toEqual([ - 'src', - 'shared', - 'vertical.manifest.ts', - 'vertical.registration.ts', - ]); + expect(tsconfig.include).toEqual(['src', 'shared', 'vertical.manifest.ts', 'vertical.registration.ts']); }), ); }), @@ -446,9 +422,7 @@ it.live( yield* scaffold(root); yield* scaffold(root, DOCUMENTS_APP_ID, DOCUMENTS_MODULE_ID); const authoredManifestPath = path.join(root, PROPERTY_MANIFEST_PATH); - const authoredManifest = yield* Effect.promise(() => - readFile(authoredManifestPath, 'utf-8'), - ); + const authoredManifest = yield* Effect.promise(() => readFile(authoredManifestPath, 'utf-8')); yield* Effect.promise(() => writeFile( authoredManifestPath, @@ -477,7 +451,10 @@ it.live( ...decodedPackage, modernjs: { ...decodedPackage.modernjs, - ontosModule: { ...decodedPackage.modernjs.ontosModule, schemaVersion: 0 }, + ontosModule: { + ...decodedPackage.modernjs.ontosModule, + schemaVersion: 0, + }, }, }; yield* Effect.promise(() => writeFile(packagePath, json(incompatiblePackage), 'utf-8')); @@ -503,9 +480,7 @@ it.live( expect(document.manifest.module.id).toBe(MODULE_ID); expect(document.schemaVersion).toBe('2'); expect(Object.hasOwn(document.manifest, 'dependencies')).toBe(false); - expect(document.manifest.publicSurface.api[0]?.operationKeys).toEqual([ - 'property.listUnits', - ]); + expect(document.manifest.publicSurface.api[0]?.operationKeys).toEqual(['property.listUnits']); expect(firstContent).not.toMatch(/vertical\.registration|function|handler|sourcePath/u); const headers = yield* Effect.promise(() => readFile(path.join(root, 'verticals/property-registry/dist/public/_headers'), 'utf-8'), @@ -527,11 +502,7 @@ it.live( const manifestPath = path.join(root, PROPERTY_MANIFEST_PATH); const manifest = yield* Effect.promise(() => readFile(manifestPath, 'utf-8')); yield* Effect.promise(() => - writeFile( - manifestPath, - manifest.replace('// ', ''), - 'utf-8', - ), + writeFile(manifestPath, manifest.replace('// ', ''), 'utf-8'), ); yield* expectFailure( generateOntosModuleContract({ @@ -570,11 +541,7 @@ it.live( it('permits owner-local registration imports but rejects cross-deployment owner imports', () => { const root = '/workspace/app'; expect( - privateOwnerImportViolation( - root, - 'verticals/billing/src/worker-host/main.ts', - '../../vertical.registration.ts', - ), + privateOwnerImportViolation(root, 'verticals/billing/src/worker-host/main.ts', '../../vertical.registration.ts'), ).toBe(undefined); expect( privateOwnerImportViolation( @@ -617,9 +584,9 @@ export const untouched = true; readFile(path.join(root, 'verticals/property-registry/shared/api.ts'), 'utf-8'), ); expect(generated).toMatch(/HttpApi\.make\('Fixture;Api'\)/u); - expect(generated).toMatch( - /return api; \}\)\s*\/\/ /u, - ); + expect(generated).toMatch(/return api;\s*\}\)\s*\/\/ /u); + expect(generated).toMatch(/<\/generated-governed-http-api-additions>\s*\.pipe\(governedHttpApiIdentity\);/u); + expect(generated).toMatch(/import \{ identity as governedHttpApiIdentity \} from 'effect';/u); expect(generated).toMatch(/export const governedHttpApi = fixtureApi;/u); expect(generated).toMatch(/export const untouched = true;/u); }), @@ -709,10 +676,7 @@ it('follows imported payload, query, parameter, success, and error schemas', () HttpApiEndpoint.post('execute', '/reads/example', { ${member}: UnsafeSchema }); `, ], - [ - unsafeContractFixturePath, - `export const UnsafeSchema = Schema.Struct({ nested: Schema.Unknown });`, - ], + [unsafeContractFixturePath, `export const UnsafeSchema = Schema.Struct({ nested: Schema.Unknown });`], ]); expect( unconstrainedHttpApiContractSchemaViolation(sources.get(contractApiFixturePath) ?? '', { @@ -749,10 +713,7 @@ it('covers every supported endpoint constructor through direct and aliased paths ReexportedEndpoint.options('execute', '/reads/example', { success: Schema.Any }); `, ], - [ - contractBarrelFixturePath, - `export { HttpApiEndpoint as ReexportedEndpoint } from 'effect/unstable/httpapi';`, - ], + [contractBarrelFixturePath, `export { HttpApiEndpoint as ReexportedEndpoint } from 'effect/unstable/httpapi';`], ]); expect( unconstrainedHttpApiContractSchemaViolation(sources.get(contractApiFixturePath) ?? '', { @@ -1101,10 +1062,7 @@ it('follows star barrels, default imports, and package export maps', () => { }, ]; for (const fixture of fixtures) { - const sources = new Map([ - [contractApiFixturePath, fixture.entry], - ...fixture.extraSources, - ]); + const sources = new Map([[contractApiFixturePath, fixture.entry], ...fixture.extraSources]); expect( unconstrainedHttpApiContractSchemaViolation(sources.get(contractApiFixturePath) ?? '', { file: contractApiFixturePath, @@ -1129,9 +1087,7 @@ it('covers ordinary endpoint aliases and TypeScript module forms', () => { HttpApiEndpoint.get('read', '/reads/example', { success: S.Any }); `, ]) { - expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch( - /must use concrete/u, - ); + expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch(/must use concrete/u); } const fixtures: readonly (readonly [string, ReadonlyMap])[] = [ @@ -1140,12 +1096,7 @@ it('covers ordinary endpoint aliases and TypeScript module forms', () => { import SafeDefault, { UnsafeSchema } from './unsafe'; HttpApiEndpoint.get('read', '/reads/example', { success: UnsafeSchema }); `, - new Map([ - [ - unsafeContractFixturePath, - `export default Schema.String; export const UnsafeSchema = Schema.Any;`, - ], - ]), + new Map([[unsafeContractFixturePath, `export default Schema.String; export const UnsafeSchema = Schema.Any;`]]), ], [ ` @@ -1163,10 +1114,7 @@ it('covers ordinary endpoint aliases and TypeScript module forms', () => { HttpApiEndpoint.get('read', '/reads/example', { success: UnsafeSchema }); `, new Map([ - [ - packageFixturePath, - `{"exports":{"./api":{"types":"./src/unsafe.ts","default":"./dist/unsafe.js"}}}`, - ], + [packageFixturePath, `{"exports":{"./api":{"types":"./src/unsafe.ts","default":"./dist/unsafe.js"}}}`], [packageUnsafeFixturePath, `export const UnsafeSchema = Schema.Any;`], ]), ], @@ -1210,9 +1158,7 @@ it('covers destructured, computed, and provenance-safe aliases', () => { factory('InvalidProblem', 400, { values: Schema.Record(Schema.String, Schema.String) }); `, ]) { - expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch( - /must use concrete/u, - ); + expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch(/must use concrete/u); } expect( @@ -1308,10 +1254,7 @@ it('evaluates package export conditions, wildcard specificity, and null exclusio [ '@app/example/api', new Map([ - [ - packageFixturePath, - `{"exports":{"./api":{"types":"./src/safe.d.ts","default":"./src/unsafe.ts"}}}`, - ], + [packageFixturePath, `{"exports":{"./api":{"types":"./src/safe.d.ts","default":"./src/unsafe.ts"}}}`], ['packages/example/src/safe.d.ts', `export const UnsafeSchema: unknown;`], [packageUnsafeFixturePath, `export const UnsafeSchema = Schema.Any;`], ]), @@ -1389,10 +1332,7 @@ it('terminates on safe and unsafe cyclic re-exports', () => { const safeSources = new Map([ [contractApiFixturePath, entry], ['contracts/cycle-a.ts', `export * from './cycle-b';`], - [ - 'contracts/cycle-b.ts', - `export * from './cycle-a'; export const ResponseSchema = Schema.String;`, - ], + ['contracts/cycle-b.ts', `export * from './cycle-a'; export const ResponseSchema = Schema.String;`], ]); expect( unconstrainedHttpApiContractSchemaViolation(entry, { @@ -1402,10 +1342,7 @@ it('terminates on safe and unsafe cyclic re-exports', () => { ).toBe(undefined); const unsafeSources = new Map([ ...safeSources, - [ - 'contracts/cycle-b.ts', - `export * from './cycle-a'; export const ResponseSchema = Schema.Any;`, - ] as const, + ['contracts/cycle-b.ts', `export * from './cycle-a'; export const ResponseSchema = Schema.Any;`] as const, ]); expect( unconstrainedHttpApiContractSchemaViolation(entry, { @@ -1422,10 +1359,7 @@ it('honors explicit export precedence and star-export binding identity', () => { `; const explicitSources = new Map([ [contractApiFixturePath, entry], - [ - contractBarrelFixturePath, - `export { SafeSchema as ResponseSchema } from './safe'; export * from './unsafe';`, - ], + [contractBarrelFixturePath, `export { SafeSchema as ResponseSchema } from './safe'; export * from './unsafe';`], ['contracts/safe.ts', `export const SafeSchema = Schema.String;`], [unsafeContractFixturePath, `export const ResponseSchema = Schema.Any;`], ]); @@ -1545,9 +1479,7 @@ it('tracks object, mutable, rest, var, enum, and namespace provenance', () => { HttpApiEndpoint.get('read', '/reads/example', { success: ResponseSchema }); `, ]) { - expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch( - /must use concrete/u, - ); + expect(unconstrainedHttpApiContractSchemaViolation(content) ?? '').toMatch(/must use concrete/u); } for (const content of [ @@ -1581,14 +1513,8 @@ it('tracks object, mutable, rest, var, enum, and namespace provenance', () => { it('follows external schema and endpoint provider re-exports', () => { const fixtures: readonly (readonly [string, string])[] = [ [`export * from 'effect/Schema';`, `import { Any as UnsafeSchema } from './barrel';`], - [ - `export { Unknown as UnsafeSchema } from 'effect';`, - `import { UnsafeSchema } from './barrel';`, - ], - [ - `export * as S from 'effect/Schema';`, - `import { S } from './barrel'; const UnsafeSchema = S.Any;`, - ], + [`export { Unknown as UnsafeSchema } from 'effect';`, `import { UnsafeSchema } from './barrel';`], + [`export * as S from 'effect/Schema';`, `import { S } from './barrel'; const UnsafeSchema = S.Any;`], ]; for (const [barrel, imported] of fixtures) { const entry = `${imported} @@ -1661,10 +1587,7 @@ it('follows local and imported aliases of Problem Details factories', () => { sources, }) ?? '', ).toMatch(/must use concrete/u); - for (const extensions of [ - `{ field: Schema.Unknown }`, - `{ values: Schema.Record(Schema.String, Schema.String) }`, - ]) { + for (const extensions of [`{ field: Schema.Unknown }`, `{ values: Schema.Record(Schema.String, Schema.String) }`]) { const inlineSources = new Map([ ...sources, [ diff --git a/app/scripts/scaffolding/tests/resource-generator.test.mts b/app/scripts/scaffolding/tests/resource-generator.test.mts index 2f87528ca..e8e0cd850 100644 --- a/app/scripts/scaffolding/tests/resource-generator.test.mts +++ b/app/scripts/scaffolding/tests/resource-generator.test.mts @@ -1,20 +1,19 @@ -import { linkFixtureDependencies, withCreatedFixture } from './fixture-ownership.mts'; -import { snapshotTree, write } from './fixture-files.mts'; -import { Cause, Effect, Fiber, FileSystem, Schema } from 'effect'; -import { afterEach, expect, it, rs } from 'effect-rstest'; - -import { CodeSmith, GeneratorCore } from '@modern-js/codesmith'; -import { applyMutationPlanEffect } from '../shared.mts'; -import { NodeServices } from '@effect/platform-node'; - import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; - import { pathToFileURL } from 'node:url'; + +import { NodeServices } from '@effect/platform-node'; +import { CodeSmith, GeneratorCore } from '@modern-js/codesmith'; +import { Cause, Effect, Fiber, FileSystem, Schema } from 'effect'; +import { afterEach, expect, it, rs } from 'effect-rstest'; + import { getHelpText, runScaffoldEffect, ScaffoldingError } from '../cli.mts'; +import { applyMutationPlanEffect } from '../shared.mts'; +import { snapshotTree, write } from './fixture-files.mts'; +import { linkFixtureDependencies, withCreatedFixture } from './fixture-ownership.mts'; afterEach(() => { rs.restoreAllMocks(); @@ -38,21 +37,13 @@ const generatedResourceModuleSchema = Schema.Struct({ RentalUnitRefSchema: Schema.declare(Schema.isSchema), }); -type JsonValue = - | boolean - | number - | string - | null - | readonly JsonValue[] - | { readonly [key: string]: JsonValue }; +type JsonValue = boolean | number | string | null | readonly JsonValue[] | { readonly [key: string]: JsonValue }; const json = (value: JsonValue): string => `${JSON.stringify(value, null, 2)}\n`; const createFixture = (): Effect.Effect => Effect.gen(function* mergedScenario9() { - const root = yield* Effect.promise(() => - mkdtemp(path.join(tmpdir(), 'ontos-resource-scaffold-')), - ); + const root = yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), 'ontos-resource-scaffold-'))); yield* write(root, 'package.json', json({ name: 'fixture', private: true, type: 'module' })); yield* write( root, @@ -87,11 +78,7 @@ const createFixture = (): Effect.Effect => references: [], }), ); - yield* write( - root, - 'verticals/property-registry/module-federation.config.ts', - 'export default { exposes: {} };\n', - ); + yield* write(root, 'verticals/property-registry/module-federation.config.ts', 'export default { exposes: {} };\n'); yield* write( root, 'verticals/property-registry/shared/api.ts', @@ -165,26 +152,18 @@ export declare const ShellSearchContributionSchema: Schema.Codec --resource /u, - ); + expect(result.help).toMatch(/scaffold:resource -- --vertical --resource /u); expect(result.help).toMatch(/lower-kebab-case/u); }), ); @@ -225,10 +202,7 @@ it.live( const result = yield* scaffoldResource(root); expect(result.kind).toBe('generated'); - const resourcePath = path.join( - root, - 'verticals/property-registry/shared/resources/rental-unit.ts', - ); + const resourcePath = path.join(root, 'verticals/property-registry/shared/resources/rental-unit.ts'); const [resource, manifest, packageSource] = yield* Effect.all( [ Effect.promise(() => readFile(resourcePath, 'utf-8')), @@ -241,13 +215,9 @@ it.live( expect(resource).toMatch(/import \{ Schema \} from 'effect';/u); expect(resource).toMatch(/export const RentalUnitRefSchema = Schema\.Struct/u); expect(resource).toMatch(/Schema\.isMaxLength\(300\)/u); - expect(resource).toMatch( - /const TenantIdSchema = Schema\.String\.check\(Schema\.isUUID\(\)\)/u, - ); + expect(resource).toMatch(/const TenantIdSchema = Schema\.String\.check\(Schema\.isUUID\(\)\)/u); expect(resource).toMatch(/moduleId: Schema\.Literal\('property\.registry'\)/u); - expect(resource).toMatch( - /resourceType: Schema\.Literal\('property\.registry\.rental-unit'\)/u, - ); + expect(resource).toMatch(/resourceType: Schema\.Literal\('property\.registry\.rental-unit'\)/u); expect(resource).toMatch(/resourceId: ResourceIdSchema/u); expect(resource).toMatch(/tenantId: TenantIdSchema/u); expect(resource).toMatch(/export type RentalUnitRef = typeof RentalUnitRefSchema\.Type;/u); @@ -263,14 +233,10 @@ it.live( const modulePackage = yield* Schema.decodeUnknownEffect(packageJsonSchema, { onExcessProperty: 'preserve', })(JSON.parse(packageSource)); - expect(modulePackage.exports['./resources/rental-unit']).toBe( - './shared/resources/rental-unit.ts', - ); + expect(modulePackage.exports['./resources/rental-unit']).toBe('./shared/resources/rental-unit.ts'); const generatedModule = yield* Schema.decodeUnknownEffect(generatedResourceModuleSchema)( - yield* Effect.promise( - () => import(`${pathToFileURL(resourcePath).href}?test=${randomUUID()}`), - ), + yield* Effect.promise(() => import(`${pathToFileURL(resourcePath).href}?test=${randomUUID()}`)), ); const rentalUnitRefSchema = Schema.make>( generatedModule.RentalUnitRefSchema.ast, @@ -331,9 +297,7 @@ it.live( yield* withFixture( Effect.fn(function* scenario12(root) { const beforeTraversal = yield* snapshotTree(root, ['node_modules']); - const failureCause1 = yield* Effect.flip( - Effect.sandbox(scaffoldResource(root, '../unsafe')), - ); + const failureCause1 = yield* Effect.flip(Effect.sandbox(scaffoldResource(root, '../unsafe'))); expect(String(Cause.squash(failureCause1))).toMatch(/lower-kebab-case/u); expect(yield* snapshotTree(root, ['node_modules'])).toEqual(beforeTraversal); @@ -354,10 +318,7 @@ it.live( yield* Effect.promise(() => writeFile( manifestPath, - manifest.replace( - '// ', - '// invalid-resource-slot', - ), + manifest.replace('// ', '// invalid-resource-slot'), 'utf-8', ), ); @@ -378,9 +339,7 @@ it.live( './resources/rental-unit': './someone-elses-contract.ts', }, }; - yield* Effect.promise(() => - writeFile(packagePath, json(packageWithExportCollision), 'utf-8'), - ); + yield* Effect.promise(() => writeFile(packagePath, json(packageWithExportCollision), 'utf-8')); yield* assertResourceScaffoldRefused(root, /resource contract export .* already exists/u); }), ); @@ -451,7 +410,11 @@ it.live( ); yield* applyMutationPlanEffect(core, { mutations: [ - { content: 'export {};', kind: 'create', path: path.join(root, 'generated.ts') }, + { + content: 'export {};', + kind: 'create', + path: path.join(root, 'generated.ts'), + }, ], result: null, }); @@ -477,13 +440,9 @@ it.live( Effect.fn(function* scenario20(root) { yield* Effect.promise(() => writeFile(path.join(root, verticalPackagePath), '[]')); const before = yield* snapshotTree(root, ['node_modules']); - const failure = yield* runScaffoldEffect( - 'resource', - [verticalFlag, verticalName, '--resource', resourceName], - { - workspaceRoot: root, - }, - ).pipe(Effect.flip, Effect.provide(NodeServices.layer)); + const failure = yield* runScaffoldEffect('resource', [verticalFlag, verticalName, '--resource', resourceName], { + workspaceRoot: root, + }).pipe(Effect.flip, Effect.provide(NodeServices.layer)); expect(Schema.is(ScaffoldingError)(failure)).toBe(true); expect(failure.message).toMatch(/JSON object/u); expect(yield* snapshotTree(root, ['node_modules'])).toEqual(before); diff --git a/app/scripts/scaffolding/tests/retire-contribution.test.mts b/app/scripts/scaffolding/tests/retire-contribution.test.mts index 55cf10fd2..f2398c87c 100644 --- a/app/scripts/scaffolding/tests/retire-contribution.test.mts +++ b/app/scripts/scaffolding/tests/retire-contribution.test.mts @@ -1,23 +1,18 @@ -import { withCreatedFixture } from './fixture-ownership.mts'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import { fileURLToPath } from 'node:url'; -import { - insertSortedSlot, - readGeneratedSlotEntries, - removeGeneratedSlotEntry, -} from '../shared.mts'; -import { snapshotTree, write } from './fixture-files.mts'; -import { Cause, Effect } from 'effect'; -import { expect, it } from 'effect-rstest'; - -import { NodeServices } from '@effect/platform-node'; - import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NodeServices } from '@effect/platform-node'; +import { Cause, Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; import { getHelpText, runScaffoldEffect } from '../cli.mts'; +import { insertSortedSlot, readGeneratedSlotEntries, removeGeneratedSlotEntry } from '../shared.mts'; import type { JsonValue } from '../shared.mts'; +import { snapshotTree, write } from './fixture-files.mts'; +import { withCreatedFixture } from './fixture-ownership.mts'; const expectFailure = (self: Effect.Effect, check: (cause: unknown) => void) => Effect.matchCauseEffect(self, { @@ -106,9 +101,7 @@ export const inventoryRegistration = { const createFixture = (): Effect.Effect => Effect.gen(function* scenario5() { - const root = yield* Effect.promise(() => - mkdtemp(path.join(tmpdir(), 'ontos-retire-contribution-')), - ); + const root = yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), 'ontos-retire-contribution-'))); yield* write( root, 'verticals/inventory/package.json', @@ -190,11 +183,7 @@ export const archiveItemAction = {}; const withFixture = withCreatedFixture(createFixture()); -const retire = Effect.fn(function* scenario7( - root: string, - kind: 'action' | 'api' | 'page', - name: string, -) { +const retire = Effect.fn(function* scenario7(root: string, kind: 'action' | 'api' | 'page', name: string) { return yield* runScaffoldEffect( RETIRE_CONTRIBUTION_COMMAND, [VERTICAL_FLAG, 'inventory', '--kind', kind, '--name', name], @@ -208,7 +197,10 @@ it.live( const result = yield* runScaffoldEffect(RETIRE_CONTRIBUTION_COMMAND, ['--help'], { workspaceRoot: path.join(tmpdir(), 'retire-help-missing'), }).pipe(Effect.provide(NodeServices.layer)); - expect(result).toEqual({ help: getHelpText(RETIRE_CONTRIBUTION_COMMAND), kind: 'help' }); + expect(result).toEqual({ + help: getHelpText(RETIRE_CONTRIBUTION_COMMAND), + kind: 'help', + }); if (result.kind !== 'help') { throw new Error('Expected help result'); } @@ -247,24 +239,16 @@ it.live( yield* retire(root, 'page', ITEM_DETAIL); yield* Effect.promise(() => - access( - path.join(root, 'verticals/inventory/src/routes/[lang]/inventory/items/[id]/page.tsx'), - ), + access(path.join(root, 'verticals/inventory/src/routes/[lang]/inventory/items/[id]/page.tsx')), ); const [nextManifest, nextRegistration] = yield* Effect.all( [ - Effect.promise(() => - readFile(path.join(root, 'verticals/inventory/vertical.manifest.ts'), 'utf-8'), - ), - Effect.promise(() => - readFile(path.join(root, 'verticals/inventory/vertical.registration.ts'), 'utf-8'), - ), + Effect.promise(() => readFile(path.join(root, 'verticals/inventory/vertical.manifest.ts'), 'utf-8')), + Effect.promise(() => readFile(path.join(root, 'verticals/inventory/vertical.registration.ts'), 'utf-8')), ], { concurrency: 'unbounded' }, ); - expect(nextManifest).not.toMatch( - /archiveItemAction|ItemDetailApi|ItemDetailPage|item-detail/u, - ); + expect(nextManifest).not.toMatch(/archiveItemAction|ItemDetailApi|ItemDetailPage|item-detail/u); expect(nextRegistration).not.toMatch(/archiveItemAction|item-detail/u); }), ); @@ -283,9 +267,7 @@ it.live( expect(yield* snapshotTree(root)).toEqual(beforeTraversal); const actionPath = path.join(root, ARCHIVE_ITEM_ACTION_PATH); - yield* Effect.promise(() => - writeFile(actionPath, 'export const archiveItemAction = {};\n', 'utf-8'), - ); + yield* Effect.promise(() => writeFile(actionPath, 'export const archiveItemAction = {};\n', 'utf-8')); const beforeCustomized = yield* snapshotTree(root); yield* expectFailure(retire(root, 'action', ARCHIVE_ITEM), (error) => expect(String(error)).toMatch(/matching generated Action/u), @@ -348,21 +330,13 @@ after`; ${end} after`); expect(readGeneratedSlotEntries(inserted, start, end)).toEqual([entry]); - const removed = removeGeneratedSlotEntry( - inserted, - start, - end, - (candidate) => candidate === entry, - 'item', - ); + const removed = removeGeneratedSlotEntry(inserted, start, end, (candidate) => candidate === entry, 'item'); expect(removed).toBe(`before ${start} ${end} after`); - expect(() => removeGeneratedSlotEntry(removed, start, end, () => true, 'item')).toThrow( - /found 0/u, - ); + expect(() => removeGeneratedSlotEntry(removed, start, end, () => true, 'item')).toThrow(/found 0/u); }); it.live( @@ -402,16 +376,7 @@ it.live( message: 'search provider access flags are internally inconsistent', }, { - args: [ - 'retire-contribution', - '--', - VERTICAL_FLAG, - 'inventory', - '--name', - 'item', - '--kind', - 'invalid', - ], + args: ['retire-contribution', '--', VERTICAL_FLAG, 'inventory', '--name', 'item', '--kind', 'invalid'], message: '--kind must be action, api, or page', }, ]; @@ -419,10 +384,7 @@ it.live( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; for (const { args, message } of cases) { const output = yield* spawner.string( - ChildProcess.make(process.execPath, [ - fileURLToPath(new URL('../cli.mts', import.meta.url)), - ...args, - ]), + ChildProcess.make(process.execPath, [fileURLToPath(new URL('../cli.mts', import.meta.url)), ...args]), ); expect(output.includes(message), output).toBeTruthy(); } diff --git a/app/scripts/scaffolding/tests/scaffold-generators.test.mts b/app/scripts/scaffolding/tests/scaffold-generators.test.mts index 69243e9be..4efe391eb 100644 --- a/app/scripts/scaffolding/tests/scaffold-generators.test.mts +++ b/app/scripts/scaffolding/tests/scaffold-generators.test.mts @@ -1,10 +1,3 @@ -import { linkFixtureDependencies } from './fixture-ownership.mts'; -import { snapshotTree, write } from './fixture-files.mts'; -import { Cause, Clock, ConfigProvider, Predicate, Redacted } from 'effect'; -import type { Scope } from 'effect'; -import { expect, it } from 'effect-rstest'; -import { NodeServices } from '@effect/platform-node'; - import { spawnSync } from 'node:child_process'; import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; @@ -12,8 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { TrustedPrincipalContextSchema } from '../../../packages/core-runtime/src/actions/principal-context.ts'; -import type { TrustedPrincipalContext } from '../../../packages/core-runtime/src/actions/principal-context.ts'; +import { NodeServices } from '@effect/platform-node'; import { defineEffectBff, Effect, @@ -27,31 +19,37 @@ import { Layer, Schema, } from '@modern-js/plugin-bff/effect-edge'; -import { - GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS, - GATEWAY_ASSERTION_TTL_SECONDS, -} from '../../../packages/shared-contracts/src/gateway-context.ts'; +import { Cause, Clock, ConfigProvider, Predicate, Redacted } from 'effect'; +import type { Scope } from 'effect'; +import { expect, it } from 'effect-rstest'; import { SignJWT, exportJWK, generateKeyPair, generateSecret, importJWK } from 'jose'; import type { JWK } from 'jose'; + +import type { GatewayIssuerConfigValue } from '../../../apps/shell-super-app/api/auth/gateway-issuer-config.ts'; import { issueGatewayContextAssertion, makeGatewayIssuerLayer, } from '../../../apps/shell-super-app/api/auth/gateway-issuer.ts'; -import type { GatewayIssuerConfigValue } from '../../../apps/shell-super-app/api/auth/gateway-issuer-config.ts'; -import { getHelpText, runScaffoldEffect, ScaffoldingError } from '../cli.mts'; -import type { ScaffoldCommand } from '../cli.mts'; -import type { JsonValue } from '../shared.mts'; -import { - assertPublishedOutboxDependencyUsage, - publishedOutboxContractExports, -} from '../../published-outbox-contracts.mts'; import { defineAction } from '../../../packages/core-runtime/src/actions/definition.ts'; +import { TrustedPrincipalContextSchema } from '../../../packages/core-runtime/src/actions/principal-context.ts'; +import type { TrustedPrincipalContext } from '../../../packages/core-runtime/src/actions/principal-context.ts'; import { GatewayAssertionRedemptionService } from '../../../packages/core-runtime/src/auth/gateway-assertion-redemption.ts'; import { defineSystemModuleEntrypoint } from '../../../packages/core-runtime/src/modules/module-entrypoint.ts'; import { makeActionTestHarness } from '../../../packages/core-runtime/src/testing/actions.ts'; import type { GatewayPrincipalVerifierLive } from '../../../packages/gateway-principal-verifier/src/server.ts'; +import { + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS, + GATEWAY_ASSERTION_TTL_SECONDS, +} from '../../../packages/shared-contracts/src/gateway-context.ts'; import type { bindActionHttpRunner as ActionHttpRunnerBinding } from '../../../verticals/party-registry/api/action-http-runner.ts'; import { hasValidGovernedHttpCompositionRoot } from '../../generated-governed-http-boundary.mts'; +import { + assertPublishedOutboxDependencyUsage, + publishedOutboxContractExports, +} from '../../published-outbox-contracts.mts'; +import { getHelpText, runScaffoldEffect, ScaffoldingError } from '../cli.mts'; +import type { ScaffoldCommand } from '../cli.mts'; +import type { JsonValue } from '../shared.mts'; import { GOVERNED_HTTP_API_ADDITION_SLOT_END, GOVERNED_HTTP_API_ADDITION_SLOT_START, @@ -66,6 +64,8 @@ import { insertSortedSlot, readGeneratedSlotEntries, } from '../shared.mts'; +import { snapshotTree, write } from './fixture-files.mts'; +import { linkFixtureDependencies } from './fixture-ownership.mts'; const expectFailure = (self: Effect.Effect, check: (cause: unknown) => void) => Effect.matchCauseEffect(self, { @@ -76,10 +76,10 @@ const expectFailure = (self: Effect.Effect, check: (cause: unk }), }); -class FirstScaffoldTestError extends Schema.TaggedError()( - 'FirstScaffoldTestError', - { cause: Schema.optionalKey(Schema.Unknown), message: Schema.String }, -) {} +class FirstScaffoldTestError extends Schema.TaggedError()('FirstScaffoldTestError', { + cause: Schema.optionalKey(Schema.Unknown), + message: Schema.String, +}) {} const firstScaffoldErrors = createScaffoldErrorTools( FirstScaffoldTestError, @@ -110,9 +110,7 @@ it.effect( it('scaffold error tools omit undefined causes and retain defined causes', () => { expect(Object.hasOwn(firstScaffoldErrors.scaffoldError('absent'), 'cause')).toBe(false); const absentCause = firstScaffoldErrors.scaffoldError('absent').cause; - expect(Object.hasOwn(firstScaffoldErrors.scaffoldError('undefined', absentCause), 'cause')).toBe( - false, - ); + expect(Object.hasOwn(firstScaffoldErrors.scaffoldError('undefined', absentCause), 'cause')).toBe(false); for (const cause of [null, false, 0, '', { detail: 'retained' }]) { const failure = firstScaffoldErrors.scaffoldError('defined', cause); expect(Object.hasOwn(failure, 'cause')).toBe(true); @@ -145,12 +143,7 @@ it.effect( }), ); -for (const [index, cause] of [ - undefined, - null, - 'thrown string', - { message: 'not an Error' }, -].entries()) { +for (const [index, cause] of [undefined, null, 'thrown string', { message: 'not an Error' }].entries()) { it.effect( `scaffold error tools use owner fallback for non-error ${index}`, Effect.fn(function* nonErrorScaffoldFailure() { @@ -229,35 +222,28 @@ interface GeneratedOperationGatewayModule { readonly makeOperationGateway: ( acquire: (payload: { readonly audience: string }) => Effect.Effect<{ readonly token: string }>, ) => { - readonly invoke: ( - attempt: (authorization: string) => Effect.Effect, - ) => Effect.Effect; + readonly invoke: (attempt: (authorization: string) => Effect.Effect) => Effect.Effect; }; } const GeneratedPrincipalModuleSchema = Schema.Struct({ - ActionPrincipalVerifierLive: Schema.declare< - GeneratedPrincipalModule['ActionPrincipalVerifierLive'] - >((value): value is GeneratedPrincipalModule['ActionPrincipalVerifierLive'] => - Predicate.isObject(value), + ActionPrincipalVerifierLive: Schema.declare( + (value): value is GeneratedPrincipalModule['ActionPrincipalVerifierLive'] => Predicate.isObject(value), ), verifyActionPrincipal: Schema.declare( - (value): value is GeneratedPrincipalModule['verifyActionPrincipal'] => - Predicate.isFunction(value), + (value): value is GeneratedPrincipalModule['verifyActionPrincipal'] => Predicate.isFunction(value), ), }); const GeneratedActionHttpRunnerModuleSchema = Schema.Struct({ bindActionHttpRunner: Schema.declare( - (value): value is GeneratedActionHttpRunnerModule['bindActionHttpRunner'] => - Predicate.isFunction(value), + (value): value is GeneratedActionHttpRunnerModule['bindActionHttpRunner'] => Predicate.isFunction(value), ), }); const GeneratedOperationGatewayModuleSchema = Schema.Struct({ makeOperationGateway: Schema.declare( - (value): value is GeneratedOperationGatewayModule['makeOperationGateway'] => - Predicate.isFunction(value), + (value): value is GeneratedOperationGatewayModule['makeOperationGateway'] => Predicate.isFunction(value), ), }); @@ -285,15 +271,17 @@ const problemFields = { title: Schema.String, type: Schema.String, }; -const asProblemDetails = HttpApiSchema.asJson({ contentType: 'application/problem+json' }); -const ActionAuthenticationProblemSchema = Schema.TaggedStruct( - 'ActionAuthenticationProblem', - problemFields, -).pipe(asProblemDetails, HttpApiSchema.status(401)); -const ActionVerificationUnavailableProblemSchema = Schema.TaggedStruct( - 'ActionVerificationUnavailableProblem', - { ...problemFields, retryable: Schema.Literal(true) }, -).pipe(asProblemDetails, HttpApiSchema.status(503)); +const asProblemDetails = HttpApiSchema.asJson({ + contentType: 'application/problem+json', +}); +const ActionAuthenticationProblemSchema = Schema.TaggedStruct('ActionAuthenticationProblem', problemFields).pipe( + asProblemDetails, + HttpApiSchema.status(401), +); +const ActionVerificationUnavailableProblemSchema = Schema.TaggedStruct('ActionVerificationUnavailableProblem', { + ...problemFields, + retryable: Schema.Literal(true), +}).pipe(asProblemDetails, HttpApiSchema.status(503)); type EndpointProblem = | typeof ActionAuthenticationProblemSchema.Type | typeof ActionVerificationUnavailableProblemSchema.Type; @@ -309,20 +297,18 @@ const actionAuthenticationProblem = (): typeof ActionAuthenticationProblemSchema type: 'https://ontos.dev/problems/action-authentication-required', }); -const actionVerificationUnavailableProblem = - (): typeof ActionVerificationUnavailableProblemSchema.Type => ({ - _tag: 'ActionVerificationUnavailableProblem', - detail: 'Action identity verification is temporarily unavailable.', - retryable: true, - status: 503, - title: 'Action verification unavailable', - type: 'https://ontos.dev/problems/action-verification-unavailable', - }); +const actionVerificationUnavailableProblem = (): typeof ActionVerificationUnavailableProblemSchema.Type => ({ + _tag: 'ActionVerificationUnavailableProblem', + detail: 'Action identity verification is temporarily unavailable.', + retryable: true, + status: 503, + title: 'Action verification unavailable', + type: 'https://ontos.dev/problems/action-verification-unavailable', +}); const failActionAuthentication = () => bearerChallenge.pipe(Effect.andThen(Effect.fail(actionAuthenticationProblem()))); -const failActionVerificationUnavailable = () => - Effect.fail(actionVerificationUnavailableProblem()); +const failActionVerificationUnavailable = () => Effect.fail(actionVerificationUnavailableProblem()); const generatedPrincipalErrorHandlers = { ActionPrincipalConfigurationError: failActionVerificationUnavailable, ActionPrincipalExpiredError: failActionAuthentication, @@ -332,7 +318,9 @@ const generatedPrincipalErrorHandlers = { ActionPrincipalUnavailableError: failActionVerificationUnavailable, }; -const GeneratedBindingResultSchema = Schema.Struct({ accepted: Schema.Literal(true) }); +const GeneratedBindingResultSchema = Schema.Struct({ + accepted: Schema.Literal(true), +}); const generatedBindingAction = defineAction( { @@ -346,7 +334,10 @@ const generatedBindingAction = defineAction( domainEvents: {}, entrypoint: defineSystemModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'core.test.generated-action-http', moduleKey: 'core.shell', role: 'action', @@ -373,13 +364,13 @@ const InventoryLocaleSchema = Schema.Struct({ }); const decodeFixturePackage = (source: string) => - Schema.decodeUnknownEffect(FixturePackageSchema, { onExcessProperty: 'preserve' })( - JSON.parse(source), - ); + Schema.decodeUnknownEffect(FixturePackageSchema, { + onExcessProperty: 'preserve', + })(JSON.parse(source)); const decodeInventoryLocale = (source: string) => - Schema.decodeUnknownEffect(InventoryLocaleSchema, { onExcessProperty: 'preserve' })( - JSON.parse(source), - ); + Schema.decodeUnknownEffect(InventoryLocaleSchema, { + onExcessProperty: 'preserve', + })(JSON.parse(source)); const inventorySlug = 'inventory-stock'; const shellAppId = 'shell-super-app'; @@ -442,10 +433,8 @@ const shellVerticalClientsFile = 'apps/shell-super-app/src/api/vertical-clients. const inventoryManifestFile = 'verticals/inventory-stock/vertical.manifest.ts'; const inventoryRegistrationFile = 'verticals/inventory-stock/vertical.registration.ts'; const inventoryFederationConfigFile = 'verticals/inventory-stock/module-federation.config.ts'; -const inventorySearchProviderFile = - 'verticals/inventory-stock/src/search/inventory-items.provider.ts'; -const inventorySearchContractFile = - 'verticals/inventory-stock/shared/apis/inventory-items-search.ts'; +const inventorySearchProviderFile = 'verticals/inventory-stock/src/search/inventory-items.provider.ts'; +const inventorySearchContractFile = 'verticals/inventory-stock/shared/apis/inventory-items-search.ts'; const inventoryModuleApiContractFile = 'verticals/inventory-stock/shared/apis/resource-detail.ts'; @@ -455,13 +444,11 @@ const inventoryModuleApiClientFile = 'verticals/inventory-stock/src/api/resource const inventoryModuleApiServerFile = 'verticals/inventory-stock/api/resource-detail-read-server.ts'; -const inventorySearchClientFile = - 'verticals/inventory-stock/src/api/inventory-items-search-client.ts'; +const inventorySearchClientFile = 'verticals/inventory-stock/src/api/inventory-items-search-client.ts'; const inventorySearchServerFile = 'verticals/inventory-stock/api/inventory-items-search-server.ts'; -const inventoryReportProviderFile = - 'verticals/inventory-stock/src/reports/stock-levels.provider.ts'; +const inventoryReportProviderFile = 'verticals/inventory-stock/src/reports/stock-levels.provider.ts'; const inventoryReportContractFile = 'verticals/inventory-stock/shared/apis/stock-levels-report.ts'; @@ -523,7 +510,7 @@ const inventoryHandlerRootFile = 'verticals/inventory-stock/api/index.ts'; const appRoot = path.resolve(import.meta.dirname, '..', '..', '..'); const require = createRequire(import.meta.url); -const createEntry = require.resolve('@modern-js/create'); +const createEntry = require.resolve('@modern-js/ultramodern-create'); const esbuildPath = require.resolve('esbuild/bin/esbuild', { paths: [path.dirname(createEntry)], }); @@ -540,9 +527,7 @@ const makeGatewayKey = ( unknown > => Effect.gen(function* scenario1() { - const pair = yield* Effect.promise(() => - generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }), - ); + const pair = yield* Effect.promise(() => generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true })); const privateJwk = yield* Effect.promise(() => exportJWK(pair.privateKey)); const publicJwk = yield* Effect.promise(() => exportJWK(pair.publicKey)); return { @@ -564,11 +549,7 @@ const makeGatewayKey = ( const createVertical = (root: string, vertical: FixtureVertical): Effect.Effect => Effect.gen(function* mergedScenario15() { - yield* write( - root, - `verticals/${vertical.slug}/module-federation.config.ts`, - 'export default { exposes: {} };\n', - ); + yield* write(root, `verticals/${vertical.slug}/module-federation.config.ts`, 'export default { exposes: {} };\n'); yield* write( root, `verticals/${vertical.slug}/tsconfig.json`, @@ -655,9 +636,7 @@ export const fixtureApi = HttpApi.make('FixtureApi').add( ); const resourcesName = `${vertical.slug .split('-') - .map((segment, index) => - index === 0 ? segment : `${segment[0]?.toUpperCase() ?? ''}${segment.slice(1)}`, - ) + .map((segment, index) => (index === 0 ? segment : `${segment[0]?.toUpperCase() ?? ''}${segment.slice(1)}`)) .join('')}I18nResources`; yield* write( root, @@ -737,19 +716,17 @@ export const coreActionCatalog = [ topologyFile, json({ schemaVersion: 1, - verticals: [inventoryVertical, billingVertical, hrVertical, contactsVertical].map( - (vertical) => ({ - domain: vertical.namespace, - id: vertical.appId, - kind: 'vertical', - moduleFederation: { - name: vertical.mfBoundaryId, - role: 'remote', - }, - package: `@app/${vertical.slug}`, - path: `verticals/${vertical.slug}`, - }), - ), + verticals: [inventoryVertical, billingVertical, hrVertical, contactsVertical].map((vertical) => ({ + domain: vertical.namespace, + id: vertical.appId, + kind: 'vertical', + moduleFederation: { + name: vertical.mfBoundaryId, + role: 'remote', + }, + package: `@app/${vertical.slug}`, + path: `verticals/${vertical.slug}`, + })), }), ); yield* Effect.all( @@ -802,11 +779,7 @@ const run = Effect.fn(function* scenario12( command, (() => { let flags = [...scaffoldArguments]; - if ( - command === 'action' && - flags.includes('--action') && - !flags.includes(scaffoldFlag.legalEntityScope) - ) { + if (command === 'action' && flags.includes('--action') && !flags.includes(scaffoldFlag.legalEntityScope)) { flags = [...flags, scaffoldFlag.legalEntityScope, 'optional']; } if (!flags.includes(scaffoldFlag.authorization)) { @@ -821,13 +794,7 @@ const run = Effect.fn(function* scenario12( } else if (command === scaffoldCommand.outboxWorker) { flags = [...flags, scaffoldFlag.authorization, 'owner_local_background']; } else if (contextPermissionCommands.has(command)) { - flags = [ - ...flags, - scaffoldFlag.authorization, - 'context_permission', - '--permission', - 'module.access', - ]; + flags = [...flags, scaffoldFlag.authorization, 'context_permission', '--permission', 'module.access']; } } return flags; @@ -847,9 +814,7 @@ const assertScaffoldRefused = Effect.fn(function* assertScaffoldRefused( expected: RegExp, ) { const before = yield* snapshotTree(fixture.root); - yield* expectFailure(run(fixture, command, commandArguments), (error) => - expect(String(error)).toMatch(expected), - ); + yield* expectFailure(run(fixture, command, commandArguments), (error) => expect(String(error)).toMatch(expected)); expect(yield* snapshotTree(fixture.root)).toEqual(before); }); @@ -921,22 +886,16 @@ it.live( expect(getHelpText('action')).toMatch(/--vertical /u); expect(getHelpText('action')).toMatch(/--scope core --module /u); expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch(/--url /u); - expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch( - /defaults to \/\//u, - ); + expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch(/defaults to \/\//u); expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch(/:parameter/u); - expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch( - /\/contacts\/customers\/:id\/edit/u, - ); + expect(getHelpText(scaffoldCommand.microverticalPage)).toMatch(/\/contacts\/customers\/:id\/edit/u); expect(getHelpText(scaffoldCommand.externalHttpAdapter)).toMatch( /scaffold:external-http-adapter -- --vertical --provider --operation /u, ); expect(getHelpText(scaffoldCommand.externalHttpAdapter)).toMatch( /--vertical contacts --provider ares --operation subject/u, ); - expect(getHelpText(scaffoldCommand.searchProviderAccess)).toMatch( - /--tenant-permission read_party_identity/u, - ); + expect(getHelpText(scaffoldCommand.searchProviderAccess)).toMatch(/--tenant-permission read_party_identity/u); }), ); @@ -946,7 +905,9 @@ it.live( yield* withFixture( Effect.fn(function* mergedScenario17(fixture) { yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'verticals/retired/node_modules'), { recursive: true }), + mkdir(path.join(fixture.root, 'verticals/retired/node_modules'), { + recursive: true, + }), ); yield* addInventoryItemResourceType(fixture); yield* run(fixture, scaffoldCommand.searchProvider, [ @@ -981,7 +942,7 @@ it.live( { concurrency: 'unbounded' }, ); expect(manifest).toMatch( - /accessFiltering: 'tenant_scope'.*requestFilters: \['includeArchived'\].*tenantPermission: 'read_party_identity'/u, + /accessFiltering: 'tenant_scope'[\s\S]*?requestFilters: \['includeArchived'\][\s\S]*?tenantPermission: 'read_party_identity'/u, ); expect(provider).toMatch(/legalEntityScope: 'optional'/u); expect(provider).toMatch(/permissionTarget: 'tenant'/u); @@ -1001,10 +962,7 @@ it.live( const providerPath = path.join(fixture.root, inventorySearchProviderFile); yield* Effect.promise(() => - writeFile( - providerPath, - `${provider}\n// Owner-customized searchable semantics remain untouched.\n`, - ), + writeFile(providerPath, `${provider}\n// Owner-customized searchable semantics remain untouched.\n`), ); const beforeIdempotentUpdate = yield* snapshotTree(fixture.root); yield* run(fixture, scaffoldCommand.searchProviderAccess, [ @@ -1023,10 +981,7 @@ it.live( ]); expect(yield* snapshotTree(fixture.root)).toEqual(beforeIdempotentUpdate); yield* Effect.promise(() => - writeFile( - providerPath, - provider.replace('// @generated by OntOS Codesmith ', '// custom '), - ), + writeFile(providerPath, provider.replace('// @generated by OntOS Codesmith ', '// custom ')), ); yield* assertScaffoldRefused( fixture, @@ -1061,12 +1016,7 @@ it.live( '--name', 'party-match-decision', ]); - yield* run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - 'party-match', - ]); + yield* run(fixture, scaffoldCommand.moduleApi, [scaffoldFlag.vertical, inventorySlug, '--name', 'party-match']); const sources = yield* Effect.all( [inventoryManifestFile, inventoryRegistrationFile].map( Effect.fn(function* scenario20(owner) { @@ -1076,9 +1026,7 @@ it.live( { concurrency: 'unbounded' }, ); for (const source of sources) { - expect(source.indexOf("'party-match':") < source.indexOf("'party-match-decision':")).toBe( - true, - ); + expect(source.indexOf("'party-match':") < source.indexOf("'party-match-decision':")).toBe(true); } }), ); @@ -1226,7 +1174,8 @@ console.log(JSON.stringify({ calls, endpointRequestsAfterGatewayFailure, gateway ], { cwd: fixture.root, encoding: 'utf-8' }, ); - expect(result.status, result.stderr || result.error?.message).toBe(0); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); const proof = yield* Schema.decodeUnknownEffect( Schema.fromJsonString( Schema.Struct({ @@ -1297,9 +1246,7 @@ it.live( const search = serverName.endsWith('-search-server.ts'); const suffix = search ? 'search' : 'read'; const name = serverName.slice(0, -`-${suffix}-server.ts`.length); - const camel = name.replaceAll(/-(?[a-z])/gu, (_, letter: string) => - letter.toUpperCase(), - ); + const camel = name.replaceAll(/-(?[a-z])/gu, (_, letter: string) => letter.toUpperCase()); const pascal = `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; const fixtureNameValue = search ? 'inventory-items' : 'resource-detail'; const fixtureCamel = search ? 'inventoryItems' : 'resourceDetail'; @@ -1311,10 +1258,7 @@ it.live( .replaceAll(`/${name}`, `/${fixtureNameValue}`) .replaceAll(pascal, fixturePascal) .replaceAll(camel, fixtureCamel) - .replaceAll( - `${fixturePascal}SearchClientOptions`, - `${fixturePascal}ClientOptions`, - ) + .replaceAll(`${fixturePascal}SearchClientOptions`, `${fixturePascal}ClientOptions`) .replaceAll('partyRegistryApi', 'fixtureApi') .replaceAll('/party-registry-api', '/inventory-stock-api'), ); @@ -1327,19 +1271,11 @@ it.live( `verticals/inventory-stock/src/api/${fixtureNameValue}${search ? '-search' : ''}-client.ts`, ); expect( - normalize( - yield* Effect.promise(() => - readFile(path.join(owner, 'api', serverName), 'utf-8'), - ), - ), + normalize(yield* Effect.promise(() => readFile(path.join(owner, 'api', serverName), 'utf-8'))), serverName, ).toBe(compactGovernedSource(expectedServer)); expect( - normalize( - yield* Effect.promise(() => - readFile(path.join(owner, 'src/api', clientName), 'utf-8'), - ), - ), + normalize(yield* Effect.promise(() => readFile(path.join(owner, 'src/api', clientName), 'utf-8'))), clientName, ).toBe(compactGovernedSource(expectedClient)); }), @@ -1347,7 +1283,7 @@ it.live( { concurrency: 'unbounded' }, ); const sharedApi = yield* readFixtureFile(fixture.root, inventorySharedApiFile); - expect(sharedApi).not.toMatch(/governedHttpApi/u); + expect(sharedApi).not.toMatch(/export const governedHttpApi\s*=/u); }), ); }), @@ -1356,9 +1292,7 @@ it.live( it.live( 'the migrated Party governed API slot accepts future generated additions', Effect.fn(function* mergedScenario22() { - const source = yield* Effect.promise(() => - readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8')); const next = insertSortedSlot( source, GOVERNED_HTTP_API_ADDITION_SLOT_START, @@ -1390,11 +1324,7 @@ const assertRelocatedSlotRefused = Effect.fn(function* assertRelocatedSlotRefuse ) { const slot = requiredGeneratedSlot(validSource, slotStart, slotEnd); yield* Effect.promise(() => - writeFile( - file, - `${validSource.replace(slot, '')}\nconst relocatedSlot = String.raw\`${slot}\`;\n`, - 'utf-8', - ), + writeFile(file, `${validSource.replace(slot, '')}\nconst relocatedSlot = String.raw\`${slot}\`;\n`, 'utf-8'), ); yield* assertScaffoldRefused( fixture, @@ -1411,12 +1341,8 @@ const assertGovernedReadClients = (clients: readonly string[]): void => { expect(client).toMatch(/defaultApiPrefix: '\/inventory-stock-api'/u); expect(client).toMatch(/operationGateway\.invoke\(\(credential\) =>/u); expect(client).toMatch(/WithAuthorization/u); - expect(client).toMatch( - /credential,\s+defaultApiPrefix: '\/inventory-stock-api',\s+requestCorrelation,/u, - ); - expect(client).not.toMatch( - /makeEffectHttpApiClient|Context\.Reference|HttpClientRequest|HttpClient\.mapRequest/u, - ); + expect(client).toMatch(/credential,\s+defaultApiPrefix: '\/inventory-stock-api',\s+requestCorrelation,/u); + expect(client).not.toMatch(/makeEffectHttpApiClient|Context\.Reference|HttpClientRequest|HttpClient\.mapRequest/u); } }; const assertGovernedReadProviders = (providers: readonly string[]): void => { @@ -1446,10 +1372,7 @@ const assertComposedGovernedReads = (composedApi: string, composedHandlers: stri expect(composedApi).toMatch(new RegExp(`\\.addHttpApi\\(${contract}\\)`, 'u')); expect(composedHandlers).toMatch(new RegExp(`import \\{ ${layer} \\}`, 'u')); expect(composedHandlers).toMatch( - new RegExp( - `${layer}\\.pipe\\([\\s\\S]*?GovernedReadLayer\\.provide\\(governedReadRuntimeLive\\)`, - 'u', - ), + new RegExp(`${layer}\\.pipe\\([\\s\\S]*?GovernedReadLayer\\.provide\\(governedReadRuntimeLive\\)`, 'u'), ); } }; @@ -1533,9 +1456,7 @@ it.live( expect(nextManifest).toMatch(/inventory\.stock\.search\.inventory-items/u); expect(nextManifest).toMatch(/inventory\.stock\.report\.stock-levels/u); expect(registration).toMatch(/import\('\.\/src\/api\/resource-detail-client\.ts'\)/u); - expect(registration).toMatch( - /import\('\.\/src\/api\/inventory-items-search-client\.ts'\)/u, - ); + expect(registration).toMatch(/import\('\.\/src\/api\/inventory-items-search-client\.ts'\)/u); expect(registration).toMatch(/import\('\.\/src\/api\/stock-levels-report-client\.ts'\)/u); expect(federation).toMatch(/\.\/InventoryAlerts/u); expect(federation).toMatch(/\.\/InventorySummary/u); @@ -1550,10 +1471,7 @@ it.live( expect(reportClient).toMatch(/export interface StockLevelsClientOptions/u); expect(reportClient).not.toMatch(/export interface StockLevelsReportClientOptions/u); const moduleApiClient = yield* readFixtureFile(fixture.root, inventoryModuleApiClientFile); - const moduleApiContract = yield* readFixtureFile( - fixture.root, - inventoryModuleApiContractFile, - ); + const moduleApiContract = yield* readFixtureFile(fixture.root, inventoryModuleApiContractFile); const secondModuleApiContract = yield* readFixtureFile( fixture.root, 'verticals/inventory-stock/shared/apis/resource-history.ts', @@ -1568,14 +1486,8 @@ it.live( const searchServer = yield* readFixtureFile(fixture.root, inventorySearchServerFile); const reportServer = yield* readFixtureFile(fixture.root, inventoryReportServerFile); const moduleApiServer = yield* readFixtureFile(fixture.root, inventoryModuleApiServerFile); - const operationBoundary = yield* readFixtureFile( - fixture.root, - inventoryActionPrincipalFile, - ); - const composedApi = yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/shared/api.ts', - ); + const operationBoundary = yield* readFixtureFile(fixture.root, inventoryActionPrincipalFile); + const composedApi = yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/shared/api.ts'); const composedHandlers = yield* readFixtureFile(fixture.root, inventoryHandlerRootFile); expect(searchClient).toMatch(/api: InventoryItemsSearchApi,/u); expect(reportClient).toMatch(/api: StockLevelsReportApi,/u); @@ -1762,7 +1674,9 @@ console.log( principalId: '00000000-0000-4000-8000-000000000001', tenantId: '00000000-0000-4000-8000-000000000002', }; - const expectedGeneratedTransport = { correlationId: 'generated-correlation' }; + const expectedGeneratedTransport = { + correlationId: 'generated-correlation', + }; const lastGeneratedLine = execution.stdout.trim().split('\n').at(-1); if (lastGeneratedLine === undefined) { expect.unreachable('expected generated runtime output'); @@ -1808,9 +1722,7 @@ console.log( // Restore the generated contract after the execution-only authentication stub. Reruns // must validate the real owned boundary, not silently accept handwritten fixture code. yield* write(fixture.root, inventoryActionPrincipalFile, operationBoundary); - const packageJson = yield* decodeFixturePackage( - yield* readFixtureFile(fixture.root, inventoryPackageFile), - ); + const packageJson = yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile)); expect(packageJson.dependencies['@app/shared-contracts']).toBe(workspaceVersion); // Owner contracts, reads, and clients remain adaptable; thin HTTP adapters stay generator-owned. @@ -1819,24 +1731,15 @@ console.log( inventoryModuleApiContractFile, 'export const ResourceDetailOwnerExtensionSchema = Schema.Struct({ note: Schema.String });', ], - [ - inventoryModuleApiReadFile, - 'export const resourceDetailOwnerProjection = (value: string) => value;', - ], + [inventoryModuleApiReadFile, 'export const resourceDetailOwnerProjection = (value: string) => value;'], [inventoryModuleApiClientFile, '// Owner-maintained client documentation.'], - [ - inventorySearchProviderFile, - 'export const inventoryItemsOwnerRanking = (score: number) => score;', - ], + [inventorySearchProviderFile, 'export const inventoryItemsOwnerRanking = (score: number) => score;'], [ inventorySearchContractFile, 'export const InventoryItemsOwnerFilterSchema = Schema.Struct({ tag: Schema.String });', ], [inventorySearchClientFile, '// Owner-maintained search client documentation.'], - [ - inventoryReportProviderFile, - 'export const stockLevelsOwnerProjection = (column: string) => column;', - ], + [inventoryReportProviderFile, 'export const stockLevelsOwnerProjection = (column: string) => column;'], [ inventoryReportContractFile, 'export const StockLevelsOwnerColumnSchema = Schema.Struct({ column: Schema.String });', @@ -1864,6 +1767,17 @@ console.log( .replace("label: 'Stock Levels'", "label: 'Warehouse stock'"), ); + for (const [relativePath] of adaptedGeneratedArtifacts) { + const filePath = path.join(fixture.root, relativePath); + const formatted = spawnSync(oxfmtPath, [`--stdin-filepath=${relativePath}`], { + cwd: appRoot, + encoding: 'utf-8', + input: yield* Effect.promise(() => readFile(filePath, 'utf-8')), + }); + expect(formatted.status, formatted.stderr).toBe(0); + yield* Effect.promise(() => writeFile(filePath, formatted.stdout, 'utf-8')); + } + const beforeRepeat = yield* snapshotTree(fixture.root); yield* run(fixture, scaffoldCommand.moduleApi, [ scaffoldFlag.vertical, @@ -1927,15 +1841,9 @@ console.log( ...(generated.resource ? [scaffoldFlag.resource, 'item'] : []), ]); expect(yield* snapshotTree(fixture.root)).toEqual(beforeRepeat); - const serverPath = path.join( - fixture.root, - 'verticals/inventory-stock/api', - generated.server, - ); + const serverPath = path.join(fixture.root, 'verticals/inventory-stock/api', generated.server); const ownedServer = yield* Effect.promise(() => readFile(serverPath, 'utf-8')); - yield* Effect.promise(() => - writeFile(serverPath, `${ownedServer}// owner customization\n`, 'utf-8'), - ); + yield* Effect.promise(() => writeFile(serverPath, `${ownedServer}// owner customization\n`, 'utf-8')); yield* expectFailure( run(fixture, generated.command, [ scaffoldFlag.vertical, @@ -1973,10 +1881,7 @@ console.log( GOVERNED_HTTP_API_ADDITION_SLOT_END, ]); - const registrationPath = path.join( - fixture.root, - 'verticals/inventory-stock/vertical.registration.ts', - ); + const registrationPath = path.join(fixture.root, 'verticals/inventory-stock/vertical.registration.ts'); const validRegistration = yield* Effect.promise(() => readFile(registrationPath, 'utf-8')); const resourceDetailRegistration = " 'resource-detail': () => import('./src/api/resource-detail-client.ts'),\n"; @@ -2025,19 +1930,11 @@ console.log( GOVERNED_HTTP_HANDLER_LAYER_SLOT_END, ]); yield* expectFailure( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - '../unsafe', - ]), + run(fixture, scaffoldCommand.moduleApi, [scaffoldFlag.vertical, inventorySlug, '--name', '../unsafe']), (error) => expect(String(error)).toMatch(/lower-kebab-case/u), ); expect(yield* snapshotTree(fixture.root)).toEqual(beforeRepeat); - const billingFederationPath = path.join( - fixture.root, - 'verticals/billing/module-federation.config.ts', - ); + const billingFederationPath = path.join(fixture.root, 'verticals/billing/module-federation.config.ts'); yield* Effect.promise(() => writeFile( billingFederationPath, @@ -2060,14 +1957,10 @@ void ignored; '--name', 'billing-summary', ]); - const commentSafeFederation = yield* Effect.promise(() => - readFile(billingFederationPath, 'utf-8'), - ); + const commentSafeFederation = yield* Effect.promise(() => readFile(billingFederationPath, 'utf-8')); expect(commentSafeFederation).toMatch(/\/exposes: \\\{\\\}\/u/u); expect(commentSafeFederation).toMatch(/\.\/BillingSummary/u); - yield* Effect.promise(() => - writeFile(billingFederationPath, 'export default {};\n', 'utf-8'), - ); + yield* Effect.promise(() => writeFile(billingFederationPath, 'export default {};\n', 'utf-8')); yield* assertScaffoldRefused( fixture, scaffoldCommand.publicComponent, @@ -2084,17 +1977,10 @@ it.live( Effect.fn(function* mergedScenario34() { yield* withFixture( Effect.fn(function* mergedScenario33(fixture) { - const scaffoldArguments = [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ] as const; + const scaffoldArguments = [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail] as const; yield* run(fixture, scaffoldCommand.moduleApi, scaffoldArguments); const apiContract = yield* readFixtureFile(fixture.root, inventoryModuleApiContractFile); - const assertInvalidApiContractRerunRejected = ( - invalidApiContract: string, - ): Effect.Effect => + const assertInvalidApiContractRerunRejected = (invalidApiContract: string): Effect.Effect => Effect.gen(function* mergedScenario32() { yield* write(fixture.root, inventoryModuleApiContractFile, invalidApiContract); yield* assertScaffoldRefused( @@ -2104,9 +1990,7 @@ it.live( /refusing to overwrite existing business file/u, ); }); - yield* assertInvalidApiContractRerunRejected( - apiContract.replace('/reads/resource-detail', '/reads/wrong'), - ); + yield* assertInvalidApiContractRerunRejected(apiContract.replace('/reads/resource-detail', '/reads/wrong')); yield* assertInvalidApiContractRerunRejected( apiContract.replace( "HttpApiEndpoint.post('execute', '/reads/resource-detail', {", @@ -2129,9 +2013,7 @@ it.live( ); yield* run(fixture, scaffoldCommand.moduleApi, scaffoldArguments); const repairedManifest = yield* readFixtureFile(fixture.root, inventoryManifestFile); - expect(repairedManifest.split(/\r?\n/u).filter((line) => line === ownerImport).length).toBe( - 1, - ); + expect(repairedManifest.split(/\r?\n/u).filter((line) => line === ownerImport).length).toBe(1); const registration = yield* readFixtureFile(fixture.root, inventoryRegistrationFile); const entry = "'resource-detail': () => import('./src/api/resource-detail-client.ts'),"; @@ -2148,10 +2030,7 @@ it.live( yield* write( fixture.root, inventoryRegistrationFile, - registration.replace( - entry, - "'resource-detail': () => import('./src/api/evil-client.ts'),", - ), + registration.replace(entry, "'resource-detail': () => import('./src/api/evil-client.ts'),"), ); yield* assertScaffoldRefused( fixture, @@ -2163,8 +2042,8 @@ it.live( const wrongSlotRegistration = registration .replace(`${entry}\n`, '') .replace( - ' // ', - ` ${entry}\n // `, + ' // ', + ` ${entry}\n // `, ); yield* write(fixture.root, inventoryRegistrationFile, wrongSlotRegistration); yield* assertScaffoldRefused( @@ -2183,19 +2062,13 @@ it.live( ), ); yield* run(fixture, scaffoldCommand.moduleApi, scaffoldArguments); - const commentSafeRegistration = yield* readFixtureFile( - fixture.root, - inventoryRegistrationFile, - ); + const commentSafeRegistration = yield* readFixtureFile(fixture.root, inventoryRegistrationFile); expect(commentSafeRegistration.split(entry).length - 1).toBe(1); yield* write( fixture.root, inventoryManifestFile, - repairedManifest.replace( - ownerImport, - "import { ResourceDetailApi } from './shared/apis/evil.ts';", - ), + repairedManifest.replace(ownerImport, "import { ResourceDetailApi } from './shared/apis/evil.ts';"), ); yield* assertScaffoldRefused( fixture, @@ -2241,23 +2114,12 @@ it.live( yield* assertSpoofsRejected(remaining, command, commandArguments); }); yield* addInventoryItemResourceType(fixture); - const moduleArguments = [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ] as const; + const moduleArguments = [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail] as const; yield* run(fixture, scaffoldCommand.moduleApi, moduleArguments); const moduleSpoofs = [ - [ - inventoryModuleApiContractFile, - "export const ResourceDetailApi = HttpApi.make('ResourceDetailApi')", - ], + [inventoryModuleApiContractFile, "export const ResourceDetailApi = HttpApi.make('ResourceDetailApi')"], [inventoryModuleApiReadFile, 'export const resourceDetailRead = defineRead('], - [ - inventoryModuleApiServerFile, - 'export const resourceDetailReadApiLive = HttpApiBuilder.group(', - ], + [inventoryModuleApiServerFile, 'export const resourceDetailReadApiLive = HttpApiBuilder.group('], ] as const; yield* assertSpoofsRejected(moduleSpoofs, scaffoldCommand.moduleApi, moduleArguments); @@ -2324,16 +2186,9 @@ it.live( inventorySearchContractFile, "export const InventoryItemsSearchApi = HttpApi.make('InventoryItemsSearchApi')", ], - [ - inventorySearchServerFile, - 'export const inventoryItemsReadApiLive = HttpApiBuilder.group(', - ], + [inventorySearchServerFile, 'export const inventoryItemsReadApiLive = HttpApiBuilder.group('], ] as const; - yield* assertSpoofsRejected( - providerSpoofs, - scaffoldCommand.searchProvider, - searchArguments, - ); + yield* assertSpoofsRejected(providerSpoofs, scaffoldCommand.searchProvider, searchArguments); }), ); }), @@ -2344,14 +2199,9 @@ it.live( Effect.fn(function* mergedScenario44() { yield* withFixture( Effect.fn(function* mergedScenario43(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); const packagePath = path.join(fixture.root, inventoryPackageFile); - const packageJson = yield* decodeFixturePackage( - yield* Effect.promise(() => readFile(packagePath, 'utf-8')), - ); + const packageJson = yield* decodeFixturePackage(yield* Effect.promise(() => readFile(packagePath, 'utf-8'))); yield* Effect.promise(() => writeFile( packagePath, @@ -2381,10 +2231,7 @@ it.live( Effect.fn(function* mergedScenario47() { yield* withFixture( Effect.fn(function* mergedScenario46(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); yield* Effect.promise(() => rm(path.join(fixture.root, inventoryActionGatewayFile))); yield* run(fixture, scaffoldCommand.moduleApi, [ @@ -2474,43 +2321,17 @@ it.live( ], [ 'action', - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - '--unknown', - 'x', - ], + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--unknown', 'x'], /unknown flag --unknown/u, ], [ 'action', - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - '--action', - 'again', - ], + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--action', 'again'], /only once/u, ], - [ - 'action', - [scaffoldFlag.vertical, '', '--action', fixtureName.action], - /non-empty value/u, - ], - [ - 'action', - [scaffoldFlag.vertical, '../billing', '--action', fixtureName.action], - /lower-kebab-case/u, - ], - [ - 'action', - [scaffoldFlag.vertical, '/absolute/billing', '--action', fixtureName.action], - /lower-kebab-case/u, - ], + ['action', [scaffoldFlag.vertical, '', '--action', fixtureName.action], /non-empty value/u], + ['action', [scaffoldFlag.vertical, '../billing', '--action', fixtureName.action], /lower-kebab-case/u], + ['action', [scaffoldFlag.vertical, '/absolute/billing', '--action', fixtureName.action], /lower-kebab-case/u], [ 'action', [ @@ -2528,14 +2349,7 @@ it.live( ['action', ['--scope', 'core', '--action', fixtureName.action], /--module is required/u], [ 'action', - [ - '--scope', - 'other', - '--module', - fixtureName.actionModule, - '--action', - fixtureName.action, - ], + ['--scope', 'other', '--module', fixtureName.actionModule, '--action', fixtureName.action], /--scope core is required/u, ], [ @@ -2558,39 +2372,17 @@ it.live( [scaffoldFlag.vertical, inventorySlug, '--unknown', 'x'], /unknown flag --unknown/u, ], - [ - scaffoldCommand.microverticalActionBoundary, - [scaffoldFlag.vertical, '../billing'], - /lower-kebab-case/u, - ], + [scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, '../billing'], /lower-kebab-case/u], [ 'policy', - [ - '--scope', - 'global', - '--policy', - fixtureName.policy, - scaffoldFlag.vertical, - inventorySlug, - ], + ['--scope', 'global', '--policy', fixtureName.policy, scaffoldFlag.vertical, inventorySlug], /forbidden/u, ], ['policy', ['--scope', 'microvertical', '--policy', fixtureName.policy], /required/u], - [ - 'policy', - ['--scope', 'other', '--policy', fixtureName.policy], - /global or microvertical/u, - ], + ['policy', ['--scope', 'other', '--policy', fixtureName.policy], /global or microvertical/u], [ scaffoldCommand.outboxMessage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - '--topic', - 'Not.Safe', - ], + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--topic', 'Not.Safe'], /dot-separated/u, ], [ @@ -2637,10 +2429,7 @@ it.live( ]); expect(result.kind).toBe('generated'); const server = yield* readFixtureFile(fixture.root, inventoryActionPrincipalFile); - const actionHttpRunner = yield* readFixtureFile( - fixture.root, - inventoryActionHttpRunnerFile, - ); + const actionHttpRunner = yield* readFixtureFile(fixture.root, inventoryActionHttpRunnerFile); const client = yield* readFixtureFile(fixture.root, inventoryActionGatewayFile); const redemption = yield* readFixtureFile( fixture.root, @@ -2653,15 +2442,11 @@ it.live( } expect(server).toMatch(/@app\/gateway-principal-verifier\/server/u); expect(server).toMatch(/bindGatewayPrincipalVerifier\(ACTION_GATEWAY_AUDIENCE\)/u); - expect(server).not.toMatch( - /createLocalJWKSet|decodeProtectedHeader|jwtVerify|PublicVerificationKeySchema/u, - ); + expect(server).not.toMatch(/createLocalJWKSet|decodeProtectedHeader|jwtVerify|PublicVerificationKeySchema/u); expect(client).toMatch(/makeOperationGateway as makeSharedOperationGateway/u); expect(client).toMatch(/makeSharedOperationGateway\(ACTION_GATEWAY_AUDIENCE, acquire\)/u); expect(client).toMatch(/export const operationGateway = makeOperationGateway\(\)/u); - expect(client).not.toMatch( - /ActionGatewayIssuer|ActionGatewayAttempt|makeActionGateway|\bactionGateway\b/u, - ); + expect(client).not.toMatch(/ActionGatewayIssuer|ActionGatewayAttempt|makeActionGateway|\bactionGateway\b/u); expect(client).not.toMatch(/Effect\.flatMap|Bearer \$\{|acquire\(\{ audience/u); expect(client).not.toMatch( /api\/auth\/action-principal|gateway-assertion-redemption|GatewayContextProtectedHeader|verticals\//u, @@ -2673,14 +2458,12 @@ it.live( expect(actionHttpRunner).toMatch(/authenticateOperationPrincipal/u); expect(actionHttpRunner).not.toMatch(/ActionRuntime|ActionCoreError|HttpApiEndpoint/u); expect(redemption).toMatch(/GatewayAssertionRedemptionUnavailableError/u); - const packageJson = yield* decodeFixturePackage( - yield* readFixtureFile(fixture.root, inventoryPackageFile), - ); + const packageJson = yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile)); expect(packageJson.dependencies).toEqual({ '@app/core-runtime': workspaceVersion, '@app/gateway-principal-verifier': workspaceVersion, '@app/shared-contracts': workspaceVersion, - effect: '4.0.0-beta.107', + effect: '4.0.0-rc.112', zeta: '1.0.0', }); expect(packageJson.scripts['existing']).toBe(preservedFixtureValue); @@ -2696,15 +2479,9 @@ it.live( Effect.fn(function* scenario30() { yield* withFixture( Effect.fn(function* scenario31(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); const afterFirstRun = yield* snapshotTree(fixture.root); - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); expect(yield* snapshotTree(fixture.root)).toEqual(afterFirstRun); }), ); @@ -2734,10 +2511,7 @@ it.live( yield* withFixture( Effect.fn(function* mergedScenario56(fixture) { yield* addInventoryItemResourceType(fixture); - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); const generated = yield* readFixtureFile(fixture.root, inventoryActionPrincipalFile); const legacy = generated.replace( /const verifyOperationPrincipal =[\s\S]*$/u, @@ -2749,21 +2523,16 @@ it.live( const calls: readonly [ScaffoldCommand, readonly string[]][] = [ [scaffoldCommand.microverticalActionBoundary, []], [scaffoldCommand.moduleApi, ['--name', fixtureName.resourceDetail]], - [ - scaffoldCommand.searchProvider, - ['--name', fixtureName.inventoryItems, scaffoldFlag.resource, 'item'], - ], + [scaffoldCommand.searchProvider, ['--name', fixtureName.inventoryItems, scaffoldFlag.resource, 'item']], ['report', ['--name', fixtureName.stockLevels, scaffoldFlag.resource, 'item']], ]; yield* Effect.all( calls.map( Effect.fn(function* mergedScenario55([command, args]) { - yield* expectFailure( - run(fixture, command, [scaffoldFlag.vertical, inventorySlug, ...args]), - (error) => - expect(String(error)).toMatch( - /incompatible generated Action boundary:.*export authenticateOperationPrincipal.*provide ActionPrincipalVerifierLive|refusing to overwrite existing business file: operation boundary/u, - ), + yield* expectFailure(run(fixture, command, [scaffoldFlag.vertical, inventorySlug, ...args]), (error) => + expect(String(error)).toMatch( + /incompatible generated Action boundary:.*export authenticateOperationPrincipal.*provide ActionPrincipalVerifierLive|refusing to overwrite existing business file: operation boundary/u, + ), ); expect(yield* snapshotTree(fixture.root)).toEqual(before); }), @@ -2780,16 +2549,10 @@ it.live( Effect.fn(function* preserveOwnerPrincipalAdaptations() { yield* withFixture( Effect.fn(function* preserveOwnerPrincipalFixture(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); const adapted = `${yield* readFixtureFile(fixture.root, inventoryActionPrincipalFile)}\n// Owner-specific diagnostics remain private to this adapter.\n`; yield* write(fixture.root, inventoryActionPrincipalFile, adapted); - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); yield* run(fixture, scaffoldCommand.moduleApi, [ scaffoldFlag.vertical, inventorySlug, @@ -2807,16 +2570,12 @@ it.live( Effect.fn(function* mergedScenario68() { yield* withFixture( Effect.fn(function* mergedScenario67(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - 'billing', - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, 'billing']); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@app'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@app'), { + recursive: true, + }), ); yield* Effect.promise(() => symlink( @@ -2888,31 +2647,19 @@ it.live( () => import(pathToFileURL(path.join(fixture.root, inventoryActionPrincipalFile)).href), ), ); - const billingGeneratedModule = yield* Schema.decodeUnknownEffect( - GeneratedPrincipalModuleSchema, - )( + const billingGeneratedModule = yield* Schema.decodeUnknownEffect(GeneratedPrincipalModuleSchema)( yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(fixture.root, 'verticals/billing/api/auth/action-principal.ts'), - ).href - ), + () => import(pathToFileURL(path.join(fixture.root, 'verticals/billing/api/auth/action-principal.ts')).href), ), ); - const generatedClientModule = yield* Schema.decodeUnknownEffect( - GeneratedOperationGatewayModuleSchema, - )( - yield* Effect.promise( - () => import(pathToFileURL(path.join(fixture.root, inventoryActionGatewayFile)).href), - ), + const generatedClientModule = yield* Schema.decodeUnknownEffect(GeneratedOperationGatewayModuleSchema)( + yield* Effect.promise(() => import(pathToFileURL(path.join(fixture.root, inventoryActionGatewayFile)).href)), ); const generatedActionHttpRunnerModule = yield* Schema.decodeUnknownEffect( GeneratedActionHttpRunnerModuleSchema, )( yield* Effect.promise( - () => - import(pathToFileURL(path.join(fixture.root, inventoryActionHttpRunnerFile)).href), + () => import(pathToFileURL(path.join(fixture.root, inventoryActionHttpRunnerFile)).href), ), ); const current = yield* makeGatewayKey('current'); @@ -2929,7 +2676,10 @@ it.live( issuedAt: number, audience: string = inventorySlug, ) { - return yield* issueGatewayContextAssertion({ audience, principal }).pipe( + return yield* issueGatewayContextAssertion({ + audience, + principal, + }).pipe( Effect.provide( makeGatewayIssuerLayer({ currentTimeSeconds: Effect.succeed(issuedAt), @@ -2950,11 +2700,7 @@ it.live( const billingAssertion = yield* issue(current.configuration, 1_700_000_000, 'billing'); const retiringAssertion = yield* issue(retiring.configuration, 1_700_000_000); const testRedemption = { consume: () => Effect.void }; - const verify = ( - token: string, - override: GeneratedPrincipalEnvironment = environment, - now = 1_700_000_001, - ) => + const verify = (token: string, override: GeneratedPrincipalEnvironment = environment, now = 1_700_000_001) => generatedModule.verifyActionPrincipal(`Bearer ${token}`, { currentTimeSeconds: Effect.succeed(now), environment: override, @@ -2975,8 +2721,7 @@ it.live( environment, redemption: testRedemption, }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalScopeError')(error)).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalScopeError')(error)).toBe(true), ); yield* expectFailure( billingGeneratedModule.verifyActionPrincipal(`Bearer ${currentAssertion.token}`, { @@ -2984,8 +2729,7 @@ it.live( environment, redemption: testRedemption, }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalScopeError')(error)).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalScopeError')(error)).toBe(true), ); expect(yield* verify(retiringAssertion.token)).toEqual(principal); yield* expectFailure(verify('not-a-jwt'), (error) => @@ -3006,10 +2750,7 @@ it.live( ...environment, ONTOS_GATEWAY_PUBLIC_JWKS: JSON.stringify(jwks), }), - (error) => - expect( - isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error), - ).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error)).toBe(true), ); }), ), @@ -3020,25 +2761,20 @@ it.live( ...environment, ONTOS_GATEWAY_ISSUER: 'file:///not-an-http-issuer', }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error)).toBe( - true, - ), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error)).toBe(true), ); yield* expectFailure( verify( retiringAssertion.token, { ...environment, - ONTOS_GATEWAY_PUBLIC_JWKS: JSON.stringify({ keys: [current.publicJwk] }), + ONTOS_GATEWAY_PUBLIC_JWKS: JSON.stringify({ + keys: [current.publicJwk], + }), }, - 1_700_000_000 + - GATEWAY_ASSERTION_TTL_SECONDS + - GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS + - 1, + 1_700_000_000 + GATEWAY_ASSERTION_TTL_SECONDS + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS + 1, ), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalInvalidError')(error)).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalInvalidError')(error)).toBe(true), ); const wrongAudience = yield* issue(current.configuration, 1_700_000_000, 'billing'); yield* expectFailure(verify(wrongAudience.token), (error) => @@ -3069,9 +2805,7 @@ it.live( yield* expectFailure(verify(future.token), (error) => expect(isGeneratedPrincipalError('ActionPrincipalInvalidError')(error)).toBe(true), ); - const signingKey = yield* Effect.promise(() => - importJWK(current.configuration.privateJwk, 'EdDSA'), - ); + const signingKey = yield* Effect.promise(() => importJWK(current.configuration.privateJwk, 'EdDSA')); const mismatchedSubject = yield* Effect.promise(() => new SignJWT({ principal, ver: 1 }) .setProtectedHeader({ alg: 'EdDSA', kid: 'current', typ: 'JWT' }) @@ -3130,8 +2864,7 @@ it.live( environment, redemption: testRedemption, }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalMissingError')(error)).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalMissingError')(error)).toBe(true), ); yield* expectFailure( generatedModule.verifyActionPrincipal('bearer malformed', { @@ -3139,8 +2872,7 @@ it.live( environment, redemption: testRedemption, }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalInvalidError')(error)).toBe(true), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalInvalidError')(error)).toBe(true), ); yield* expectFailure( generatedModule.verifyActionPrincipal(`Bearer ${currentAssertion.token}`, { @@ -3148,10 +2880,7 @@ it.live( environment: {}, redemption: testRedemption, }), - (error) => - expect(isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error)).toBe( - true, - ), + (error) => expect(isGeneratedPrincipalError('ActionPrincipalConfigurationError')(error)).toBe(true), ); let acquisitions = 0; const authorizations: string[] = []; @@ -3172,10 +2901,7 @@ it.live( const actionApi = HttpApi.make('generatedActionIdentityFixture').add( HttpApiGroup.make('action').add( HttpApiEndpoint.post('invoke', '/actions/invoke', { - error: [ - ActionAuthenticationProblemSchema, - ActionVerificationUnavailableProblemSchema, - ], + error: [ActionAuthenticationProblemSchema, ActionVerificationUnavailableProblemSchema], success: TrustedPrincipalContextSchema, }), ), @@ -3193,10 +2919,7 @@ it.live( environment: endpointEnvironment, redemption: testRedemption, }) - .pipe( - Effect.tap(markActionReached), - Effect.catchTags(generatedPrincipalErrorHandlers), - ), + .pipe(Effect.tap(markActionReached), Effect.catchTags(generatedPrincipalErrorHandlers)), ), ); const actionRuntime = defineEffectBff({ @@ -3210,9 +2933,7 @@ it.live( ); expect(missingResponse.status).toBe(401); expect(missingResponse.headers.get('www-authenticate')).toBe('Bearer'); - expect(missingResponse.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); + expect(missingResponse.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); expect(actionReached).toBe(false); endpointEnvironment = {}; const unavailableResponse = yield* Effect.promise(() => @@ -3246,10 +2967,7 @@ it.live( const generatedBindingApi = HttpApi.make('generatedActionRunnerFixture').add( HttpApiGroup.make('action').add( HttpApiEndpoint.post('invoke', '/actions/generated-runner', { - error: [ - ActionAuthenticationProblemSchema, - ActionVerificationUnavailableProblemSchema, - ], + error: [ActionAuthenticationProblemSchema, ActionVerificationUnavailableProblemSchema], success: GeneratedBindingResultSchema, }), ), @@ -3262,27 +2980,24 @@ it.live( actionPermission: 'allowed', tenantPermission: 'allowed', }); - const generatedBindingGroupLive = HttpApiBuilder.group( - generatedBindingApi, - 'action', - (handlers) => - handlers.handle('invoke', ({ request }) => - runGeneratedActionHttp({ - endpointHeaders: { - idempotencyKey: request.headers['idempotency-key'], - traceId: 'generated-trace', - }, - internalProblem: actionVerificationUnavailableProblem, - invalidCorrelationProblem: actionAuthenticationProblem, - mapError: actionVerificationUnavailableProblem, - payload: {}, - registration: generatedBindingAction, - requestHeaders: { - authorization: Redacted.make(request.headers['authorization']), - 'x-correlation-id': request.headers['x-correlation-id'], - }, - }), - ), + const generatedBindingGroupLive = HttpApiBuilder.group(generatedBindingApi, 'action', (handlers) => + handlers.handle('invoke', ({ request }) => + runGeneratedActionHttp({ + endpointHeaders: { + idempotencyKey: request.headers['idempotency-key'], + traceId: 'generated-trace', + }, + internalProblem: actionVerificationUnavailableProblem, + invalidCorrelationProblem: actionAuthenticationProblem, + mapError: actionVerificationUnavailableProblem, + payload: {}, + registration: generatedBindingAction, + requestHeaders: { + authorization: Redacted.make(request.headers['authorization']), + 'x-correlation-id': request.headers['x-correlation-id'], + }, + }), + ), ).pipe( Layer.provide(generatedModule.ActionPrincipalVerifierLive), Layer.provide(Layer.succeed(GatewayAssertionRedemptionService, testRedemption)), @@ -3311,12 +3026,15 @@ it.live( }), ), ); - const generatedBindingBody = yield* Schema.decodeUnknownEffect( - GeneratedBindingResultSchema, - )(yield* Effect.promise(() => generatedBindingResponse.json())); + const generatedBindingBody = yield* Schema.decodeUnknownEffect(GeneratedBindingResultSchema)( + yield* Effect.promise(() => generatedBindingResponse.json()), + ); expect( generatedBindingResponse.status, - JSON.stringify({ body: generatedBindingBody, snapshot: harness.snapshot() }), + JSON.stringify({ + body: generatedBindingBody, + snapshot: harness.snapshot(), + }), ).toBe(200); expect(generatedBindingBody).toEqual({ accepted: true }); expect(harness.snapshot().invocations.length).toBe(1); @@ -3331,12 +3049,7 @@ it.live( Effect.fn(function* scenario38() { yield* withFixture( Effect.fn(function* scenario39(fixture) { - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - 'create-order2', - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', 'create-order2']); const action = yield* readFixtureFile( fixture.root, 'verticals/inventory-stock/src/actions/create-order2.action.ts', @@ -3400,9 +3113,7 @@ export const createOrder2Action = defineAction( // // `); - const packageJson = yield* decodeFixturePackage( - yield* readFixtureFile(fixture.root, inventoryPackageFile), - ); + const packageJson = yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile)); expect(packageJson.dependencies).toEqual({ '@app/core-runtime': workspaceVersion, zeta: '1.0.0', @@ -3464,10 +3175,7 @@ it.live( scaffoldFlag.operation, 'subject', ]); - const adapterPath = path.join( - fixture.root, - 'verticals/contacts/src/integrations/ares/ares-subject.service.ts', - ); + const adapterPath = path.join(fixture.root, 'verticals/contacts/src/integrations/ares/ares-subject.service.ts'); expect(result).toEqual({ kind: 'generated', result: { adapterPath }, @@ -3477,9 +3185,7 @@ it.live( ...Object.keys(before).filter((file) => before[file] !== after[file]), ...Object.keys(after).filter((file) => before[file] !== after[file]), ]); - expect([...changedPaths]).toEqual([ - 'verticals/contacts/src/integrations/ares/ares-subject.service.ts', - ]); + expect([...changedPaths]).toEqual(['verticals/contacts/src/integrations/ares/ares-subject.service.ts']); expect(after['verticals/contacts/src/integrations/ares/ares-subject.service.ts']) .toBe(`// @generated by OntOS Codesmith External HTTP Adapter v1 import { Context, Effect, Layer, Schema } from 'effect'; @@ -3497,10 +3203,9 @@ export interface AresSubjectServiceContract { readonly subject: () => Effect.Effect; } -export class AresSubjectService extends Context.Service< - AresSubjectService, - AresSubjectServiceContract ->()('@app/contacts/integrations/ares/ares-subject/AresSubjectService') {} +export class AresSubjectService extends Context.Service()( + '@app/contacts/integrations/ares/ares-subject/AresSubjectService', +) {} const makeAresSubjectService = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; @@ -3519,8 +3224,7 @@ const makeAresSubjectService = Effect.gen(function* () { export const AresSubjectServiceLive = Layer.effect(AresSubjectService, makeAresSubjectService); `); - const source = - after['verticals/contacts/src/integrations/ares/ares-subject.service.ts'] ?? ''; + const source = after['verticals/contacts/src/integrations/ares/ares-subject.service.ts'] ?? ''; expect(source).toMatch(/HttpClient\.HttpClient/u); expect(source).toMatch(/Layer\.effect/u); expect(source).not.toMatch( @@ -3530,14 +3234,7 @@ export const AresSubjectServiceLive = Layer.effect(AresSubjectService, makeAresS yield* assertScaffoldRefused( fixture, scaffoldCommand.externalHttpAdapter, - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], /refusing to overwrite/u, ); }), @@ -3552,18 +3249,9 @@ it.live( Effect.fn(function* scenario45(fixture) { const before = yield* snapshotTree(fixture.root); const invalidCalls: readonly [readonly string[], RegExp][] = [ - [ - [scaffoldFlag.vertical, 'contacts', scaffoldFlag.operation, 'subject'], - /missing required flag --provider/u, - ], - [ - [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares'], - /missing required flag --operation/u, - ], - [ - [scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], - /missing required flag --vertical/u, - ], + [[scaffoldFlag.vertical, 'contacts', scaffoldFlag.operation, 'subject'], /missing required flag --provider/u], + [[scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares'], /missing required flag --operation/u], + [[scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], /missing required flag --vertical/u], [ [ scaffoldFlag.vertical, @@ -3591,87 +3279,37 @@ it.live( /only once/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'Ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'Ares', scaffoldFlag.operation, 'subject'], /provider must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'Subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'Subject'], /operation must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'src', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'src', scaffoldFlag.operation, 'subject'], /provider must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'node_modules', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'node_modules'], /operation must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - '../ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, '../ares', scaffoldFlag.operation, 'subject'], /provider must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - '../subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, '../subject'], /operation must be canonical lower-kebab-case/u, ], [ - [ - scaffoldFlag.vertical, - 'missing', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'missing', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], /package metadata is missing/u, ], ]; for (const [generatorArguments, expected] of invalidCalls) { - yield* expectFailure( - run(fixture, scaffoldCommand.externalHttpAdapter, generatorArguments), - (error) => expect(String(error)).toMatch(expected), + yield* expectFailure(run(fixture, scaffoldCommand.externalHttpAdapter, generatorArguments), (error) => + expect(String(error)).toMatch(expected), ); expect(yield* snapshotTree(fixture.root)).toEqual(before); } @@ -3690,24 +3328,14 @@ it.live( yield* Effect.promise(() => writeFile( manifestPath, - manifest.replace( - '// @generated by OntOS Codesmith Module Contract v1', - '// developer-owned manifest', - ), + manifest.replace('// @generated by OntOS Codesmith Module Contract v1', '// developer-owned manifest'), 'utf-8', ), ); yield* assertScaffoldRefused( fixture, scaffoldCommand.externalHttpAdapter, - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], /is not a generated module owner/u, ); }), @@ -3723,14 +3351,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.externalHttpAdapter, - [ - scaffoldFlag.vertical, - 'contacts', - scaffoldFlag.provider, - 'ares', - scaffoldFlag.operation, - 'subject', - ], + [scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject'], /ENOTDIR|not a directory/u, ); }), @@ -3802,10 +3423,8 @@ it.live( expect(action).not.toMatch(/verticals|fetch\(/u); const coreIndex = yield* readFixtureFile(fixture.root, coreRuntimeIndexFile); - const accountExport = - "export { accountChangeAction } from './modules/actions/account-change.action.ts';"; - const zExport = - "export { zLastChangeAction } from './modules/actions/z-last-change.action.ts';"; + const accountExport = "export { accountChangeAction } from './modules/actions/account-change.action.ts';"; + const zExport = "export { zLastChangeAction } from './modules/actions/z-last-change.action.ts';"; expect(coreIndex.includes(accountExport)).toBe(true); expect(coreIndex.includes(zExport)).toBe(true); expect(coreIndex.indexOf(accountExport) < coreIndex.indexOf(zExport)).toBe(true); @@ -3820,8 +3439,7 @@ it.live( expect(coreCatalog.includes('zLastChangeAction.descriptor,')).toBe(true); expect(coreCatalog.indexOf(accountImport) < coreCatalog.indexOf(zImport)).toBe(true); expect( - coreCatalog.indexOf('accountChangeAction.descriptor,') < - coreCatalog.indexOf('zLastChangeAction.descriptor,'), + coreCatalog.indexOf('accountChangeAction.descriptor,') < coreCatalog.indexOf('zLastChangeAction.descriptor,'), ).toBe(true); expect(coreCatalog).toMatch(/export const existingCatalogSurface = true/u); @@ -3907,9 +3525,7 @@ it.live( yield* withFixture( Effect.fn(function* scenario58(fixture) { const packagePath = path.join(fixture.root, inventoryPackageFile); - const packageJson = yield* decodeFixturePackage( - yield* Effect.promise(() => readFile(packagePath, 'utf-8')), - ); + const packageJson = yield* decodeFixturePackage(yield* Effect.promise(() => readFile(packagePath, 'utf-8'))); yield* Effect.promise(() => writeFile( packagePath, @@ -3945,7 +3561,10 @@ it.live( billingPackagePath, json({ ...billingPackage, - modernjs: { ...billingPackage.modernjs, appId: inventoryVertical.appId }, + modernjs: { + ...billingPackage.modernjs, + appId: inventoryVertical.appId, + }, }), 'utf-8', ), @@ -3967,9 +3586,7 @@ it.live( yield* withFixture( Effect.fn(function* scenario62(fixture) { const packagePath = path.join(fixture.root, inventoryPackageFile); - const packageJson = yield* decodeFixturePackage( - yield* Effect.promise(() => readFile(packagePath, 'utf-8')), - ); + const packageJson = yield* decodeFixturePackage(yield* Effect.promise(() => readFile(packagePath, 'utf-8'))); yield* Effect.promise(() => writeFile( packagePath, @@ -3997,18 +3614,11 @@ it.live( yield* withFixture( Effect.fn(function* scenario64(fixture) { const packagePath = path.join(fixture.root, inventoryPackageFile); - const packageJson = yield* decodeFixturePackage( - yield* Effect.promise(() => readFile(packagePath, 'utf-8')), - ); + const packageJson = yield* decodeFixturePackage(yield* Effect.promise(() => readFile(packagePath, 'utf-8'))); const styledPackage = JSON.stringify(packageJson, null, 4).replaceAll('\n', '\r\n'); yield* Effect.promise(() => writeFile(packagePath, styledPackage, 'utf-8')); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); const patched = yield* Effect.promise(() => readFile(packagePath, 'utf-8')); expect(patched).toMatch(/\r\n {4}"dependencies": \{\r\n/u); @@ -4025,20 +3635,11 @@ it.live( Effect.fn(function* scenario65() { yield* withFixture( Effect.fn(function* scenario66(fixture) { - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); const actionPath = path.join(fixture.root, inventoryActionFile); const generatedAction = yield* Effect.promise(() => readFile(actionPath, 'utf-8')); yield* Effect.promise(() => - writeFile( - actionPath, - `${generatedAction}\nexport const developerOwned = true;\n`, - 'utf-8', - ), + writeFile(actionPath, `${generatedAction}\nexport const developerOwned = true;\n`, 'utf-8'), ); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, @@ -4061,11 +3662,7 @@ it.live( 'verticals/inventory-stock/src/actions/create-order.orders-created.outbox-message.ts', ); expect(message).toBe(`import type { OutboxMessage } from '@app/core-runtime'; -import { - OutboxPayloadSchema, - outboxProducerModuleKey, - outboxTopic, -} from '@app/inventory-stock/outbox/orders-created'; +import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic } from '@app/inventory-stock/outbox/orders-created'; import type { OutboxPayload } from '@app/inventory-stock/outbox/orders-created'; export const CreateOrderOrdersCreatedOutboxPayloadSchema = OutboxPayloadSchema; @@ -4073,9 +3670,7 @@ export type CreateOrderOrdersCreatedOutboxPayload = OutboxPayload; export const CreateOrderOrdersCreatedOutboxProducerModuleKey = outboxProducerModuleKey; export const CreateOrderOrdersCreatedOutboxTopic = outboxTopic; -export const createCreateOrderOrdersCreatedOutboxMessage = ( - payload: OutboxPayload, -): OutboxMessage => ({ +export const createCreateOrderOrdersCreatedOutboxMessage = (payload: OutboxPayload): OutboxMessage => ({ payloadJson: payload, producerModuleKey: CreateOrderOrdersCreatedOutboxProducerModuleKey, topic: CreateOrderOrdersCreatedOutboxTopic, @@ -4095,12 +3690,8 @@ export type OutboxPayload = Schema.Schema.Type; export const outboxTopic = 'orders.created' as const; export const outboxProducerModuleKey = 'inventory.stock' as const; `); - const producerPackage = yield* decodeFixturePackage( - yield* readFixtureFile(fixture.root, inventoryPackageFile), - ); - expect(producerPackage.exports['./outbox/orders-created']).toBe( - generatedOutboxContractPath, - ); + const producerPackage = yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile)); + expect(producerPackage.exports['./outbox/orders-created']).toBe(generatedOutboxContractPath); const action = yield* Effect.promise(() => readFile(actionPath, 'utf-8')); const createdExport = "export { CreateOrderOrdersCreatedOutboxPayloadSchema } from './create-order.orders-created.outbox-message.ts';"; @@ -4108,9 +3699,7 @@ export const outboxProducerModuleKey = 'inventory.stock' as const; "export { CreateOrderOrdersShippedOutboxPayloadSchema } from './create-order.orders-shipped.outbox-message.ts';"; expect(action.indexOf(createdExport) < action.indexOf(shippedExport)).toBe(true); expect(action).toMatch(/export const developerOwned = true;/u); - expect(message).not.toMatch( - /addDomainEvent|addOutboxMessage|subjectResource|transport|worker/u, - ); + expect(message).not.toMatch(/addDomainEvent|addOutboxMessage|subjectResource|transport|worker/u); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, @@ -4123,14 +3712,7 @@ export const outboxProducerModuleKey = 'inventory.stock' as const; yield* assertScaffoldRefused( fixture, scaffoldCommand.outboxMessage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - '--topic', - 'events.foo1-bar', - ], + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--topic', 'events.foo1-bar'], /Outbox identifier CreateOrderEventsFoo1BarOutbox already exists/u, ); }), @@ -4146,14 +3728,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.outboxMessage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - 'missing-action', - '--topic', - fixtureName.ordersCreated, - ], + [scaffoldFlag.vertical, inventorySlug, '--action', 'missing-action', '--topic', fixtureName.ordersCreated], /requires the generated Action/u, ); @@ -4165,23 +3740,11 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.outboxMessage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - 'handwritten', - '--topic', - fixtureName.ordersCreated, - ], + [scaffoldFlag.vertical, inventorySlug, '--action', 'handwritten', '--topic', fixtureName.ordersCreated], /only the matching generated Action/u, ); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); const governedActionPath = inventoryActionFile; const governedAction = yield* readFixtureFile(fixture.root, governedActionPath); yield* write( @@ -4192,14 +3755,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.outboxMessage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - '--topic', - fixtureName.ordersCreated, - ], + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--topic', fixtureName.ordersCreated], /matching generated Action with its governed write entrypoint/u, ); yield* write(fixture.root, governedActionPath, governedAction); @@ -4242,12 +3798,7 @@ it.live( yield* withFixture( Effect.fn(function* mergedScenario70(fixture) { const billingApiBefore = yield* readFixtureFile(fixture.root, billingApiIndexFile); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, inventorySlug, @@ -4283,11 +3834,7 @@ it.live( // @ontos-outbox-worker-topic orders.created import { Effect, Schema } from 'effect'; import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { - OutboxPayloadSchema, - outboxProducerModuleKey, - outboxTopic, -} from '@app/inventory-stock/outbox/orders-created'; +import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic } from '@app/inventory-stock/outbox/orders-created'; export class OrdersCreatedLoggerNotImplemented extends Schema.TaggedError()( 'OrdersCreatedLoggerNotImplemented', @@ -4356,10 +3903,7 @@ export { OutboxRepositoryLive as outboxWorkerRepositoryLive } from '@app/core-ru /** Add owner-local repositories and services required by worker handlers here. */ const outboxWorkerHandlerLayer = Layer.empty; -export const outboxWorkerLayer = Layer.merge( - OutboxWorkerInfrastructureLive, - outboxWorkerHandlerLayer, -); +export const outboxWorkerLayer = Layer.merge(OutboxWorkerInfrastructureLive, outboxWorkerHandlerLayer); `); expect(yield* readFixtureFile(fixture.root, 'verticals/billing/src/worker-host/main.ts')) .toBe(`// @generated by scaffold:outbox-worker worker-host @@ -4372,10 +3916,7 @@ startBillingOutboxWorker(); .toBe(`// @generated by scaffold:outbox-worker worker-host // @ontos-outbox-worker-host-owner billing.core import { Layer } from 'effect'; -import { - extractOutboxWorkerSubscriptions, - startOutboxWorkerProcess, -} from '@app/core-runtime/outbox/worker'; +import { extractOutboxWorkerSubscriptions, startOutboxWorkerProcess } from '@app/core-runtime/outbox/worker'; import { outboxWorkers } from '../src/workers/index.ts'; import { outboxWorkerCorePersistenceLive, @@ -4440,8 +3981,7 @@ export const startBillingOutboxWorker = (): void => ]); const registry = yield* readFixtureFile(fixture.root, billingWorkersIndexFile); expect( - registry.indexOf('ordersCreatedLoggerWorker') < - registry.indexOf('ordersShippedProjectorWorker'), + registry.indexOf('ordersCreatedLoggerWorker') < registry.indexOf('ordersShippedProjectorWorker'), ).toBeTruthy(); yield* assertScaffoldRefused( fixture, @@ -4468,12 +4008,7 @@ it.live( Effect.fn(function* scenario72() { yield* withFixture( Effect.fn(function* scenario73(fixture) { - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, inventorySlug, @@ -4502,22 +4037,10 @@ it.live( expect(worker.includes('// @ontos-outbox-worker-owner inventory.stock')).toBe(true); expect(worker.includes('// @ontos-outbox-worker-producer inventory.stock')).toBe(true); expect(worker.includes("from '@app/inventory-stock/outbox/orders-created'")).toBe(true); - const registry = yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/src/workers/index.ts', - ); - const hostLayer = yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/src/worker-host/layer.ts', - ); - const hostMain = yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/src/worker-host/main.ts', - ); - const hostScript = yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/scripts/outbox-worker.ts', - ); + const registry = yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/src/workers/index.ts'); + const hostLayer = yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/src/worker-host/layer.ts'); + const hostMain = yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/src/worker-host/main.ts'); + const hostScript = yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/scripts/outbox-worker.ts'); expect(registry.includes(workerRegistryEntry)).toBe(true); expect(hostLayer.includes('OutboxWorkerInfrastructureLive')).toBe(true); expect(hostMain.includes('startInventoryStockOutboxWorker();')).toBe(true); @@ -4527,31 +4050,16 @@ it.live( expect(registration.includes(workerRegistryEntry)).toBe(true); expect(yield* readFixtureFile(fixture.root, inventoryManifestFile)).toBe(manifestBefore); expect(yield* readFixtureFile(fixture.root, inventoryTsconfigFile)).toBe(tsconfigBefore); - const ownerPackage = yield* decodeFixturePackage( - yield* readFixtureFile(fixture.root, inventoryPackageFile), - ); + const ownerPackage = yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile)); expect(ownerPackage.dependencies['@app/core-runtime']).toBe(workspaceVersion); expect(ownerPackage.dependencies[inventoryPackageName]).toBe(undefined); expect(ownerPackage.exports['./outbox/orders-created']).toBe(generatedOutboxContractPath); for (const script of ['dev:worker', 'worker:start']) { expect(ownerPackage.scripts[script]).toBe(workerStartScript); } - yield* assertScaffoldRefused( - fixture, - scaffoldCommand.outboxWorker, - args, - /refusing to overwrite/u, - ); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - 'request-rebuild', - ]); - const registrationAfterAction = yield* readFixtureFile( - fixture.root, - inventoryRegistrationFile, - ); + yield* assertScaffoldRefused(fixture, scaffoldCommand.outboxWorker, args, /refusing to overwrite/u); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', 'request-rebuild']); + const registrationAfterAction = yield* readFixtureFile(fixture.root, inventoryRegistrationFile); expect(registrationAfterAction.includes('requestRebuildAction,')).toBe(true); expect(registrationAfterAction.includes(workerRegistryEntry)).toBe(true); yield* write( @@ -4600,12 +4108,7 @@ it.live( /published producer Outbox contract is missing/u, ); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, inventorySlug, @@ -4619,10 +4122,7 @@ it.live( yield* Effect.promise(() => writeFile( contractPath, - validContract.replace( - '// @ontos-outbox-producer inventory.stock', - '// @ontos-outbox-producer billing', - ), + validContract.replace('// @ontos-outbox-producer inventory.stock', '// @ontos-outbox-producer billing'), 'utf-8', ), ); @@ -4662,35 +4162,21 @@ it.live( inventorySlug, ]); - expect( - yield* readFixtureFile( - fixture.root, - 'packages/core-runtime/src/policies/tenant-active.policy.ts', - ), - ).toBe(`import { Effect } from 'effect'; + expect(yield* readFixtureFile(fixture.root, 'packages/core-runtime/src/policies/tenant-active.policy.ts')) + .toBe(`import { Effect } from 'effect'; import { defineGlobalPolicy, denyPolicy } from '../actions/policy.ts'; export const tenantActivePolicy = defineGlobalPolicy({ - evaluate: () => - Effect.fail( - denyPolicy('policy_not_implemented', 'The Tenant Active Policy is not implemented'), - ), + evaluate: () => Effect.fail(denyPolicy('policy_not_implemented', 'The Tenant Active Policy is not implemented')), policyKey: 'global.tenant-active.v1', }); `); - expect( - yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/src/policies/stock-available.policy.ts', - ), - ).toBe(`import { Effect } from 'effect'; + expect(yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/src/policies/stock-available.policy.ts')) + .toBe(`import { Effect } from 'effect'; import { defineMicroverticalPolicy, denyPolicy } from '@app/core-runtime'; export const stockAvailablePolicy = defineMicroverticalPolicy({ - evaluate: () => - Effect.fail( - denyPolicy('policy_not_implemented', 'The Stock Available Policy is not implemented'), - ), + evaluate: () => Effect.fail(denyPolicy('policy_not_implemented', 'The Stock Available Policy is not implemented')), owningModuleKey: 'inventory.stock', policyKey: 'inventory.stock.stock-available.v1', }); @@ -4708,8 +4194,9 @@ export { tenantActivePolicy } from './policies/tenant-active.policy.ts'; `); expect(coreIndex).not.toMatch(/stockAvailablePolicy/u); expect( - (yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile))) - .dependencies['@app/core-runtime'], + (yield* decodeFixturePackage(yield* readFixtureFile(fixture.root, inventoryPackageFile))).dependencies[ + '@app/core-runtime' + ], ).toBe(workspaceVersion); yield* assertScaffoldRefused( fixture, @@ -4738,11 +4225,7 @@ it.live( const shellBefore = yield* readFixtureFile(fixture.root, shellSentinelFile); const englishLocalePath = path.join(fixture.root, inventoryEnglishLocaleFile); yield* Effect.promise(() => - writeFile( - englishLocalePath, - '{\r\n "inventory": {"existing":"en-preserved"}\r\n}', - 'utf-8', - ), + writeFile(englishLocalePath, '{\r\n "inventory": {"existing":"en-preserved"}\r\n}', 'utf-8'), ); const refreshes: string[] = []; yield* run( @@ -4797,13 +4280,11 @@ export default PurchaseOrdersPage; expect(manifest).toMatch(/inventory\.stock\.page\.purchase-orders/u); expect(manifest).toMatch(/routePath: '\/inventory-stock\/purchase-orders'/u); expect(registration).toMatch(/page-purchase-orders/u); - expect(federation).toMatch( - /'\.\/PagePurchaseOrders': '\.\/src\/federation\/page-purchase-orders\.tsx'/u, - ); + expect(federation).toMatch(/'\.\/PagePurchaseOrders': '\.\/src\/federation\/page-purchase-orders\.tsx'/u); expect(federatedPage).toMatch(/ import\('inventoryStock\/PagePurchaseOrders'\)/u, + /appId: 'inventory-stock',\s*componentKey: 'inventory\.stock\.page-purchase-orders',\s*load: \(\) => import\('inventoryStock\/PagePurchaseOrders'\)/u, ); expect( yield* readFixtureFile( @@ -4862,10 +4343,7 @@ export { routeMeta }; const englishContent = yield* Effect.promise(() => readFile(englishLocalePath, 'utf-8')); const english = yield* decodeInventoryLocale(englishContent); const czech = yield* decodeInventoryLocale( - yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/locales/cs/inventory.json', - ), + yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/locales/cs/inventory.json'), ); expect(english.inventory.existing).toBe('en-preserved'); expect(englishContent).toMatch(/"inventory": \{"existing":"en-preserved", "pages":/u); @@ -4891,15 +4369,8 @@ it.live( Effect.fn(function* scenario80() { yield* withFixture( Effect.fn(function* scenario81(fixture) { - yield* run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - 'hr', - '--page', - 'people', - ]); - yield* Effect.promise(() => - stat(path.join(fixture.root, 'verticals/hr/src/routes/[lang]/hr/people/page.tsx')), - ); + yield* run(fixture, scaffoldCommand.microverticalPage, [scaffoldFlag.vertical, 'hr', '--page', 'people']); + yield* Effect.promise(() => stat(path.join(fixture.root, 'verticals/hr/src/routes/[lang]/hr/people/page.tsx'))); expect(yield* readFixtureFile(fixture.root, 'verticals/hr/vertical.manifest.ts')).toMatch( /routePath: '\/hr\/people'/u, ); @@ -4920,7 +4391,9 @@ it.live( 'customers', ]); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@modern-js'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@modern-js'), { + recursive: true, + }), ); yield* Effect.all( ['react', 'react-dom'].map( @@ -4993,17 +4466,11 @@ process.stdout.write(renderToStaticMarkup()); const bundlePath = path.join(fixture.root, 'render-generated-page.cjs'); const bundle = spawnSync( esbuildPath, - [ - runnerPath, - '--bundle', - '--format=cjs', - '--jsx=automatic', - '--platform=node', - `--outfile=${bundlePath}`, - ], + [runnerPath, '--bundle', '--format=cjs', '--jsx=automatic', '--platform=node', `--outfile=${bundlePath}`], { cwd: fixture.root, encoding: 'utf-8' }, ); - expect(bundle.status, bundle.stderr || bundle.error?.message).toBe(0); + expect(bundle.error).toBeUndefined(); + expect(bundle.status, bundle.stderr).toBe(0); const renderLanguage = (language: 'cs' | 'en') => spawnSync(process.execPath, [bundlePath], { cwd: fixture.root, @@ -5114,10 +4581,7 @@ it.live( const manifest = yield* readFixtureFile(fixture.root, inventoryManifestFile); expect(manifest).toMatch(/routePath: '\/purchasing\/orders'/u); expect( - yield* readFixtureFile( - fixture.root, - 'apps/shell-super-app/src/routes/[lang]/purchasing/orders/page.data.ts', - ), + yield* readFixtureFile(fixture.root, 'apps/shell-super-app/src/routes/[lang]/purchasing/orders/page.data.ts'), ).toMatch(/entrypointKey: 'inventory\.stock\.page\.purchase-orders'/u); const beforeRerun = yield* snapshotTree(fixture.root); yield* run(fixture, scaffoldCommand.microverticalPage, [ @@ -5166,9 +4630,7 @@ it.live( '/orders', ]); yield* Effect.promise(() => stat(path.join(fixture.root, inventoryOrdersRouteFile))); - expect(yield* readFixtureFile(fixture.root, inventoryManifestFile)).toMatch( - /routePath: '\/orders'/u, - ); + expect(yield* readFixtureFile(fixture.root, inventoryManifestFile)).toMatch(/routePath: '\/orders'/u); }), ); @@ -5230,8 +4692,7 @@ it.live( ]; yield* run(fixture, scaffoldCommand.microverticalPage, generatorArguments); - const ownerRoute = - 'verticals/inventory-stock/src/routes/[lang]/contacts/customers/[id]/edit'; + const ownerRoute = 'verticals/inventory-stock/src/routes/[lang]/contacts/customers/[id]/edit'; const shellRoute = 'apps/shell-super-app/src/routes/[lang]/contacts/customers/[id]/edit'; const page = yield* readFixtureFile(fixture.root, `${ownerRoute}/page.tsx`); const ownerMetadata = yield* readFixtureFile(fixture.root, `${ownerRoute}/route.meta.ts`); @@ -5247,12 +4708,8 @@ it.live( const shellClients = yield* readFixtureFile(fixture.root, shellVerticalClientsFile); expect(page).toMatch(/export const CustomerEditPageRouteParams = Schema\.Struct/u); - expect(page).toMatch( - /id: Schema\.String\.pipe\(Schema\.brand\('CustomerEditPageIdRouteParameter'\)\)/u, - ); - expect(page).toMatch( - /export type CustomerEditPageRouteParams = typeof CustomerEditPageRouteParams\.Type/u, - ); + expect(page).toMatch(/id: Schema\.String\.pipe\(Schema\.brand\('CustomerEditPageIdRouteParameter'\)\)/u); + expect(page).toMatch(/export type CustomerEditPageRouteParams = typeof CustomerEditPageRouteParams\.Type/u); expect(page).toMatch(/Schema\.toStandardSchemaV1\(\s*CustomerEditPageRouteParams,?\s*\)/u); expect(page).toMatch(/CustomerEditPage = \(\{ routeParams \}/u); expect(page).toMatch(/void routeParams;/u); @@ -5270,18 +4727,11 @@ it.live( expect(shellClients).toMatch(/inventory\.stock\.page-customer-edit/u); expect(shellLoader).toMatch(/selectRouteParams/u); expect(shellLoader).toMatch(/const routeParameterNames = \['id'\] as const;/u); - expect(shellLoader).toMatch( - /routeParams: selectRouteParams\(params, routeParameterNames\)/u, - ); - expect(yield* readFixtureFile(fixture.root, inventoryEnglishLocaleFile)).toMatch( + expect(shellLoader).toMatch(/routeParams: selectRouteParams\(params, routeParameterNames\)/u); + expect(yield* readFixtureFile(fixture.root, inventoryEnglishLocaleFile)).toMatch(/"customerEdit"/u); + expect(yield* readFixtureFile(fixture.root, 'verticals/inventory-stock/locales/cs/inventory.json')).toMatch( /"customerEdit"/u, ); - expect( - yield* readFixtureFile( - fixture.root, - 'verticals/inventory-stock/locales/cs/inventory.json', - ), - ).toMatch(/"customerEdit"/u); const afterFirstRun = yield* snapshotTree(fixture.root); yield* run(fixture, scaffoldCommand.microverticalPage, generatorArguments); @@ -5305,10 +4755,8 @@ it.live( yield* withFixture( Effect.fn(function* scenario98(fixture) { - const ownerRoute = - 'verticals/inventory-stock/src/routes/[lang]/contacts/customers/[id]/contacts/[contactId]'; - const shellRoute = - 'apps/shell-super-app/src/routes/[lang]/contacts/customers/[id]/contacts/[contactId]'; + const ownerRoute = 'verticals/inventory-stock/src/routes/[lang]/contacts/customers/[id]/contacts/[contactId]'; + const shellRoute = 'apps/shell-super-app/src/routes/[lang]/contacts/customers/[id]/contacts/[contactId]'; yield* run(fixture, scaffoldCommand.microverticalPage, generatorArguments); @@ -5319,29 +4767,19 @@ it.live( const manifest = yield* readFixtureFile(fixture.root, inventoryManifestFile); expect(page).toMatch(/export const ContactDetailPageRouteParams = Schema\.Struct/u); - expect(page).toMatch( - /id: Schema\.String\.pipe\(Schema\.brand\('ContactDetailPageIdRouteParameter'\)\)/u, - ); + expect(page).toMatch(/id: Schema\.String\.pipe\(Schema\.brand\('ContactDetailPageIdRouteParameter'\)\)/u); expect(page).toMatch( /contactId: Schema\.String\.pipe\(Schema\.brand\('ContactDetailPageContactIdRouteParameter'\)\)/u, ); - expect(page).toMatch( - /export type ContactDetailPageRouteParams = typeof ContactDetailPageRouteParams\.Type/u, - ); + expect(page).toMatch(/export type ContactDetailPageRouteParams = typeof ContactDetailPageRouteParams\.Type/u); expect(page).toMatch(/Schema\.toStandardSchemaV1\(\s*ContactDetailPageRouteParams,?\s*\)/u); - expect(ownerMetadata).toMatch( - /canonicalPath: '\/contacts\/customers\/:id\/contacts\/:contactId'/u, - ); - expect(shellMetadata).toMatch( - /canonicalPath: '\/contacts\/customers\/:id\/contacts\/:contactId'/u, - ); + expect(ownerMetadata).toMatch(/canonicalPath: '\/contacts\/customers\/:id\/contacts\/:contactId'/u); + expect(shellMetadata).toMatch(/canonicalPath: '\/contacts\/customers\/:id\/contacts\/:contactId'/u); expect(manifest).toMatch(/routePath: '\/contacts\/customers\/:id\/contacts\/:contactId'/u); expect(manifest).toMatch(/inventory\.stock\.page\.contact-detail/u); expect(manifest).not.toMatch(/inventory\.stock\.navigation\.contact-detail/u); expect(shellLoader).toMatch(/const routeParameterNames = \['id', 'contactId'\] as const;/u); - expect(shellLoader).toMatch( - /routeParams: selectRouteParams\(params, routeParameterNames\)/u, - ); + expect(shellLoader).toMatch(/routeParams: selectRouteParams\(params, routeParameterNames\)/u); yield* Effect.promise(() => stat(path.join(fixture.root, ownerRoute))); yield* Effect.promise(() => stat(path.join(fixture.root, shellRoute))); @@ -5390,14 +4828,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - fixtureName.customerEditPage, - '--url', - url, - ], + [scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', url], /--url/u, ); }), @@ -5450,15 +4881,8 @@ it.live( 'verticals/inventory-stock/src/routes/[lang]/inventory/customers/[id]/edit/page.tsx', ); const pageSource = yield* Effect.promise(() => readFile(pagePath, 'utf-8')); - yield* Effect.promise(() => - writeFile(pagePath, `${pageSource}\n// developer edit\n`, 'utf-8'), - ); - yield* assertScaffoldRefused( - fixture, - scaffoldCommand.microverticalPage, - generatorArguments, - /collides/u, - ); + yield* Effect.promise(() => writeFile(pagePath, `${pageSource}\n// developer edit\n`, 'utf-8')); + yield* assertScaffoldRefused(fixture, scaffoldCommand.microverticalPage, generatorArguments, /collides/u); }), ), withFixture( @@ -5471,14 +4895,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - fixtureName.customerEditPage, - '--url', - customerEditUrl, - ], + [scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', customerEditUrl], /collides with nested content/u, ); }), @@ -5519,13 +4936,9 @@ it.live( '/shared/customers/:id/edit', ]); yield* Effect.promise(() => - rm( - path.join( - fixture.root, - 'apps/shell-super-app/src/routes/[lang]/shared/customers/[id]/edit', - ), - { recursive: true }, - ), + rm(path.join(fixture.root, 'apps/shell-super-app/src/routes/[lang]/shared/customers/[id]/edit'), { + recursive: true, + }), ); yield* assertScaffoldRefused( fixture, @@ -5578,10 +4991,7 @@ it.live( yield* Effect.promise(() => stat( - path.join( - fixture.root, - 'apps/shell-super-app/src/routes/[lang]/inventory/customers/[id]/edit/page.tsx', - ), + path.join(fixture.root, 'apps/shell-super-app/src/routes/[lang]/inventory/customers/[id]/edit/page.tsx'), ), ); }), @@ -5604,14 +5014,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'customers', - '--url', - '/modules/customers', - ], + [scaffoldFlag.vertical, inventorySlug, '--page', 'customers', '--url', '/modules/customers'], /collides with dynamic route segment \[moduleId\]/u, ); }), @@ -5626,14 +5029,7 @@ it.live( yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'customers', - '--url', - '/login/customers', - ], + [scaffoldFlag.vertical, inventorySlug, '--page', 'customers', '--url', '/login/customers'], /reserved route prefix \/login/u, ); }), @@ -5649,24 +5045,14 @@ it.live( '/shared/customers', ]); yield* Effect.promise(() => - rm( - path.join(fixture.root, 'apps/shell-super-app/src/routes/[lang]/shared/customers'), - { - recursive: true, - }, - ), + rm(path.join(fixture.root, 'apps/shell-super-app/src/routes/[lang]/shared/customers'), { + recursive: true, + }), ); yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, - [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'customer-list', - '--url', - '/shared/customers', - ], + [scaffoldFlag.vertical, inventorySlug, '--page', 'customer-list', '--url', '/shared/customers'], /already registered by billing/u, ); }), @@ -5695,12 +5081,7 @@ it.live( 'order', ]); yield* Effect.promise(() => - stat( - path.join( - fixture.root, - 'verticals/inventory-stock/src/routes/[lang]/inventory-stock/order/page.tsx', - ), - ), + stat(path.join(fixture.root, 'verticals/inventory-stock/src/routes/[lang]/inventory-stock/order/page.tsx')), ); }), ); @@ -5742,9 +5123,7 @@ it.live( yield* run(fixture, scaffoldCommand.microverticalPage, generatorArguments); const manifestPath = path.join(fixture.root, inventoryManifestFile); const manifest = yield* Effect.promise(() => readFile(manifestPath, 'utf-8')); - yield* Effect.promise(() => - writeFile(manifestPath, manifest.replace('order: 100', 'order: 101'), 'utf-8'), - ); + yield* Effect.promise(() => writeFile(manifestPath, manifest.replace('order: 100', 'order: 101'), 'utf-8')); yield* assertScaffoldRefused( fixture, scaffoldCommand.microverticalPage, @@ -5787,10 +5166,7 @@ it.live( yield* Effect.promise(() => writeFile( federationPath, - federation.replace( - "'./src/federation/page-orders.tsx'", - "'./src/federation/page-other.tsx'", - ), + federation.replace("'./src/federation/page-orders.tsx'", "'./src/federation/page-other.tsx'"), 'utf-8', ), ); @@ -5830,14 +5206,7 @@ it.live( Effect.fn(function* mergedScenario78() { yield* withFixture( Effect.fn(function* mergedScenario77(fixture) { - const generatorArguments = [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'orders', - '--url', - '/orders', - ]; + const generatorArguments = [scaffoldFlag.vertical, inventorySlug, '--page', 'orders', '--url', '/orders']; yield* run(fixture, scaffoldCommand.microverticalPage, generatorArguments); yield* write( fixture.root, @@ -5896,13 +5265,8 @@ export const loader = ({ request }: ShellPageLoaderArguments) => yield* Effect.all( ['cs', 'en'].map( Effect.fn(function* mergedScenario76(locale) { - const localePath = path.join( - fixture.root, - `verticals/inventory-stock/locales/${locale}/inventory.json`, - ); - const catalog = yield* decodeInventoryLocale( - yield* Effect.promise(() => readFile(localePath, 'utf-8')), - ); + const localePath = path.join(fixture.root, `verticals/inventory-stock/locales/${locale}/inventory.json`); + const catalog = yield* decodeInventoryLocale(yield* Effect.promise(() => readFile(localePath, 'utf-8'))); const ordersPage = locale === 'cs' ? { @@ -5945,15 +5309,16 @@ it.live( yield* withFixture( Effect.fn(function* scenario125(fixture) { const packagePath = path.join(fixture.root, inventoryPackageFile); - const packageJson = yield* decodeFixturePackage( - yield* Effect.promise(() => readFile(packagePath, 'utf-8')), - ); + const packageJson = yield* decodeFixturePackage(yield* Effect.promise(() => readFile(packagePath, 'utf-8'))); yield* Effect.promise(() => writeFile( packagePath, json({ ...packageJson, - exports: { ...packageJson.exports, './locales/de': './locales/de/inventory.json' }, + exports: { + ...packageJson.exports, + './locales/de': './locales/de/inventory.json', + }, }), 'utf-8', ), @@ -5980,12 +5345,7 @@ it.live( yield* withFixture( Effect.fn(function* scenario127(fixture) { yield* Effect.promise(() => - rm( - path.join( - fixture.root, - 'verticals/inventory-stock/src/routes/ultramodern-route-head.tsx', - ), - ), + rm(path.join(fixture.root, 'verticals/inventory-stock/src/routes/ultramodern-route-head.tsx')), ); yield* assertScaffoldRefused( fixture, @@ -6029,19 +5389,18 @@ it.live( ], { routeRefresh: () => - Effect.fail(new ScaffoldingError({ message: 'route refresh fixture failure' })), + Effect.fail( + new ScaffoldingError({ + message: 'route refresh fixture failure', + }), + ), workspaceRoot: fixture.root, }, ).pipe(Effect.provide(NodeServices.layer)), (error) => expect(String(error)).toMatch(/route refresh fixture failure/u), ); yield* Effect.promise(() => - stat( - path.join( - fixture.root, - 'verticals/inventory-stock/src/routes/[lang]/inventory-stock/orders/page.tsx', - ), - ), + stat(path.join(fixture.root, 'verticals/inventory-stock/src/routes/[lang]/inventory-stock/orders/page.tsx')), ); const afterRefreshFailure = yield* snapshotTree(fixture.root); const refreshes: string[] = []; @@ -6060,15 +5419,10 @@ it.live( }), ); -const runCombinedScenario = ( - fixture: Fixture, -): Effect.Effect>, unknown> => +const runCombinedScenario = (fixture: Fixture): Effect.Effect>, unknown> => Effect.gen(function* scenario130() { yield* addInventoryItemResourceType(fixture); - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); yield* run(fixture, scaffoldCommand.externalHttpAdapter, [ scaffoldFlag.vertical, inventorySlug, @@ -6085,12 +5439,7 @@ const runCombinedScenario = ( '--action', 'change-tenant-state', ]); - yield* run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]); + yield* run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action]); yield* run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, inventorySlug, @@ -6154,9 +5503,7 @@ it.live( const second = yield* createFixture(); yield* Effect.gen(function* useResource3() { const billingBefore = Object.fromEntries( - Object.entries(yield* snapshotTree(first.root)).filter(([file]) => - file.startsWith('verticals/billing/'), - ), + Object.entries(yield* snapshotTree(first.root)).filter(([file]) => file.startsWith('verticals/billing/')), ); const shellBefore = yield* readFixtureFile(first.root, shellSentinelFile); const topologyBefore = yield* readFixtureFile(first.root, topologyFile); @@ -6269,16 +5616,24 @@ it.live( fixtureName.ordersCreated, ]); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@authzed'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@authzed'), { + recursive: true, + }), ); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@effect'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@effect'), { + recursive: true, + }), ); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@modern-js'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@modern-js'), { + recursive: true, + }), ); yield* Effect.promise(() => - mkdir(path.join(fixture.root, 'node_modules', '@types'), { recursive: true }), + mkdir(path.join(fixture.root, 'node_modules', '@types'), { + recursive: true, + }), ); // Every generated-runtime dependency is linked from the real workspace so the fixture // typechecks and runs against the same modules the shipped verticals resolve. @@ -6286,66 +5641,30 @@ it.live( ( [ ['packages/core-runtime/node_modules/effect', effectNodeModulePath, 'dir'], - [ - 'packages/core-runtime/node_modules/@effect/sql-pg', - 'node_modules/@effect/sql-pg', - 'dir', - ], - [ - 'packages/core-runtime/node_modules/@effect/platform-node', - 'node_modules/@effect/platform-node', - 'dir', - ], + ['packages/core-runtime/node_modules/@effect/sql-pg', 'node_modules/@effect/sql-pg', 'dir'], + ['packages/core-runtime/node_modules/@effect/platform-node', 'node_modules/@effect/platform-node', 'dir'], ['apps/shell-super-app/node_modules/jose', 'node_modules/jose', 'dir'], ['packages/core-runtime/node_modules/drizzle-orm', 'node_modules/drizzle-orm', 'dir'], ['packages/core-runtime/node_modules/dotenv', 'node_modules/dotenv', 'dir'], ['packages/core-runtime/node_modules/pg', 'node_modules/pg', 'dir'], - [ - 'packages/core-runtime/node_modules/@authzed/authzed-node', - 'node_modules/@authzed/authzed-node', - 'dir', - ], + ['packages/core-runtime/node_modules/@authzed/authzed-node', 'node_modules/@authzed/authzed-node', 'dir'], [ 'apps/shell-super-app/node_modules/@modern-js/plugin-i18n', 'node_modules/@modern-js/plugin-i18n', 'dir', ], - [ - 'apps/shell-super-app/node_modules/@modern-js/plugin-bff', - pluginBffNodeModulePath, - 'dir', - ], - [ - 'apps/shell-super-app/node_modules/@types/react', - 'node_modules/@types/react', - 'dir', - ], + ['apps/shell-super-app/node_modules/@modern-js/plugin-bff', pluginBffNodeModulePath, 'dir'], + ['apps/shell-super-app/node_modules/@types/react', 'node_modules/@types/react', 'dir'], ['packages/core-runtime/node_modules/@types/pg', 'node_modules/@types/pg', 'dir'], ['node_modules/@types/node', 'node_modules/@types/node', 'dir'], ['packages/core-runtime/src/actions', 'packages/core-runtime/src/actions', 'dir'], ['packages/core-runtime/src/db', 'packages/core-runtime/src/db', 'dir'], - [ - 'packages/core-runtime/src/operations', - 'packages/core-runtime/src/operations', - 'dir', - ], + ['packages/core-runtime/src/operations', 'packages/core-runtime/src/operations', 'dir'], ['packages/core-runtime/src/database', 'packages/core-runtime/src/database', 'dir'], - [ - 'packages/core-runtime/src/environment', - 'packages/core-runtime/src/environment', - 'dir', - ], - [ - 'packages/core-runtime/src/permissions', - 'packages/core-runtime/src/permissions', - 'dir', - ], + ['packages/core-runtime/src/environment', 'packages/core-runtime/src/environment', 'dir'], + ['packages/core-runtime/src/permissions', 'packages/core-runtime/src/permissions', 'dir'], ['packages/core-runtime/src/auth', 'packages/core-runtime/src/auth', 'dir'], - [ - 'packages/core-runtime/src/authorization', - 'packages/core-runtime/src/authorization', - 'dir', - ], + ['packages/core-runtime/src/authorization', 'packages/core-runtime/src/authorization', 'dir'], [ 'packages/core-runtime/src/modules/module-entrypoint.ts', 'packages/core-runtime/src/modules/module-entrypoint.ts', @@ -6353,9 +5672,7 @@ it.live( ], ] as const ).map(([source, target, kind]) => - Effect.promise(() => - symlink(path.join(appRoot, source), path.join(fixture.root, target), kind), - ), + Effect.promise(() => symlink(path.join(appRoot, source), path.join(fixture.root, target), kind)), ), { concurrency: 'unbounded' }, ); @@ -6401,25 +5718,16 @@ it.live( path.join(appRoot, 'packages/core-runtime/src/actions/runtime-wiring.ts'), ], '@app/core-runtime/auth/gateway-assertion-redemption': [ - path.join( - appRoot, - 'packages/core-runtime/src/auth/gateway-assertion-redemption.ts', - ), + path.join(appRoot, 'packages/core-runtime/src/auth/gateway-assertion-redemption.ts'), ], '@app/core-runtime/http/action-runner': [ - path.join( - appRoot, - 'packages/core-runtime/src/http/http-instrumentation-seam.ts', - ), + path.join(appRoot, 'packages/core-runtime/src/http/http-instrumentation-seam.ts'), ], '@app/core-runtime/http/governed-read': [ path.join(appRoot, 'packages/core-runtime/src/http/governed-read.ts'), ], '@app/core-runtime/http/principal-authentication': [ - path.join( - appRoot, - 'packages/core-runtime/src/http/principal-authentication.ts', - ), + path.join(appRoot, 'packages/core-runtime/src/http/principal-authentication.ts'), ], '@app/core-runtime/outbox/worker': [ path.join(appRoot, 'packages/core-runtime/src/outbox/worker-entrypoint.ts'), @@ -6427,12 +5735,8 @@ it.live( '@app/gateway-principal-verifier/server': [ path.join(appRoot, 'packages/gateway-principal-verifier/src/server.ts'), ], - '@app/inventory-stock/outbox/*': [ - './verticals/inventory-stock/shared/outbox/*.ts', - ], - '@app/shared-contracts': [ - path.join(appRoot, 'packages/shared-contracts/src/index.ts'), - ], + '@app/inventory-stock/outbox/*': ['./verticals/inventory-stock/shared/outbox/*.ts'], + '@app/shared-contracts': [path.join(appRoot, 'packages/shared-contracts/src/index.ts')], '@app/shared-contracts/client-runtime': [ path.join(appRoot, 'packages/shared-contracts/src/client-runtime.ts'), ], @@ -6499,11 +5803,7 @@ ${nestedEntry} ${neighborEntry} ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; expect( - readGeneratedSlotEntries( - source, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(source, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toEqual([nestedEntry, neighborEntry]); const next = insertSortedSlot( source, @@ -6513,11 +5813,7 @@ ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; (entry) => entry === nestedEntry || entry === neighborEntry || entry === addedEntry, ); expect( - readGeneratedSlotEntries( - next, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(next, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toEqual([nestedEntry, neighborEntry, addedEntry]); }); @@ -6533,11 +5829,7 @@ ${protectedEntry} .addHttpApi(SecondApi) ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; expect( - readGeneratedSlotEntries( - source, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(source, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toEqual([protectedEntry, '.addHttpApi(SecondApi)']); }); } @@ -6550,11 +5842,7 @@ ${statement} ${tailEntries.join('\n')} ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; expect( - readGeneratedSlotEntries( - source, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(source, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toEqual([statement, ...tailEntries]); }); @@ -6564,11 +5852,7 @@ it('generated fluent slots preserve nonfluent multiline statement continuations' ${entries.join('\n')} ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; expect( - readGeneratedSlotEntries( - source, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(source, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toEqual(entries); }); @@ -6585,11 +5869,7 @@ for (const incompleteEntry of [ ${incompleteEntry} ${GOVERNED_HTTP_API_ADDITION_SLOT_END}`; expect(() => - readGeneratedSlotEntries( - source, - GOVERNED_HTTP_API_ADDITION_SLOT_START, - GOVERNED_HTTP_API_ADDITION_SLOT_END, - ), + readGeneratedSlotEntries(source, GOVERNED_HTTP_API_ADDITION_SLOT_START, GOVERNED_HTTP_API_ADDITION_SLOT_END), ).toThrow(/generated owner slot contains unsupported developer content/u); }); } @@ -6629,9 +5909,7 @@ for (const [start, end] of [ const rootPath = path.join(fixture.root, inventoryHandlerRootFile); const source = yield* Effect.promise(() => readFile(rootPath, 'utf-8')); expect(source.includes(start)).toBeTruthy(); - yield* Effect.promise(() => - writeFile(rootPath, source.replace(start, '').replace(end, ''), 'utf-8'), - ); + yield* Effect.promise(() => writeFile(rootPath, source.replace(start, '').replace(end, ''), 'utf-8')); yield* run(fixture, scaffoldCommand.moduleApi, [ scaffoldFlag.vertical, inventorySlug, @@ -6651,10 +5929,7 @@ it.live( Effect.fn(function* mergedScenario98() { yield* withFixture( Effect.fn(function* mergedScenario97(fixture) { - yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]); + yield* run(fixture, scaffoldCommand.microverticalActionBoundary, [scaffoldFlag.vertical, inventorySlug]); const serverPath = path.join(fixture.root, inventoryActionPrincipalFile); const source = yield* Effect.promise(() => readFile(serverPath, 'utf-8')); yield* Effect.promise(() => @@ -6678,9 +5953,7 @@ it.live( it.live( 'typed injected governed runtime stays bound to the exported owner composition', Effect.fn(function* mergedScenario99() { - const shared = yield* Effect.promise(() => - readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8'), - ); + const shared = yield* Effect.promise(() => readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8')); const handler = yield* Effect.promise(() => readFile(path.join(appRoot, 'verticals/party-registry/api/index.ts'), 'utf-8'), ); @@ -6688,10 +5961,7 @@ it.live( expect( hasValidGovernedHttpCompositionRoot( shared, - handler.replace( - 'readRuntime: Layer.Layer - readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8'), - ); + const shared = yield* Effect.promise(() => readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8')); const handler = yield* Effect.promise(() => readFile(path.join(appRoot, 'verticals/party-registry/api/index.ts'), 'utf-8'), ); @@ -6733,9 +5998,7 @@ it.live( ['handlers: resolvedApiHandlersLive,', 'handlers: unrelatedHandlers,'], ] as const) { expect(handler.includes(before)).toBeTruthy(); - expect(hasValidGovernedHttpCompositionRoot(shared, handler.replace(before, after))).toBe( - false, - ); + expect(hasValidGovernedHttpCompositionRoot(shared, handler.replace(before, after))).toBe(false); } }), ); diff --git a/app/scripts/setup-agent-reference-repos.mts b/app/scripts/setup-agent-reference-repos.mts index 145f4599e..a7f70ff69 100644 --- a/app/scripts/setup-agent-reference-repos.mts +++ b/app/scripts/setup-agent-reference-repos.mts @@ -1,24 +1,6 @@ #!/usr/bin/env node -import { - NodeChildProcessSpawner, - NodeFileSystem, - NodePath, - NodeRuntime, - NodeStdio, - NodeTerminal, -} from '@effect/platform-node'; -import { - Config, - Context, - DateTime, - Effect, - FileSystem, - Layer, - Option, - Path, - Schema, - Stream, -} from 'effect'; +import { NodeRuntime, NodeServices } from '@effect/platform-node'; +import { Config, Context, DateTime, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; import { ChildProcess } from 'effect/unstable/process'; @@ -30,11 +12,10 @@ const ReferenceRepositorySchema = Schema.Struct({ id: Schema.String, name: Schema.String, path: Schema.String, - readOnly: Schema.optional(Schema.Boolean), + readOnly: Schema.optionalKey(Schema.Boolean), ref: Schema.String, url: Schema.String, }); - const ReferenceRepositoryConfigSchema = Schema.Struct({ defaultEnabled: Schema.Boolean, installDir: Schema.Literal('repos'), @@ -42,32 +23,28 @@ const ReferenceRepositoryConfigSchema = Schema.Struct({ schemaVersion: Schema.Literal(1), strategy: Schema.Literal(REPOSITORY_STRATEGY), }); - const InstalledRepositorySchema = Schema.Struct({ - commit: Schema.optional(Schema.String), + commit: Schema.optionalKey(Schema.String), id: Schema.String, - installedAt: Schema.optional(Schema.DateTimeUtc), + installedAt: Schema.optionalKey(Schema.DateTimeUtcFromString), name: Schema.String, path: Schema.String, readOnly: Schema.Boolean, ref: Schema.String, - schemaVersion: Schema.optional(Schema.Literal(1)), + schemaVersion: Schema.optionalKey(Schema.Literal(1)), status: Schema.Literals(['installed', 'present']), strategy: Schema.Literal(REPOSITORY_STRATEGY), url: Schema.String, }); - const InstalledManifestSchema = Schema.Struct({ - generatedAt: Schema.DateTimeUtc, + generatedAt: Schema.DateTimeUtcFromString, installDir: Schema.Literal('repos'), repositories: Schema.Array(InstalledRepositorySchema), schemaVersion: Schema.Literal(1), strategy: Schema.Literal(REPOSITORY_STRATEGY), }); - const ReferenceRepositoryConfigJsonSchema = Schema.fromJsonString(ReferenceRepositoryConfigSchema); const InstalledManifestJsonSchema = Schema.fromJsonString(InstalledManifestSchema, { space: 2 }); - type ReferenceRepository = typeof ReferenceRepositorySchema.Type; type InstalledRepository = typeof InstalledRepositorySchema.Type; @@ -75,13 +52,11 @@ class AgentReferenceRepoSetupError extends Schema.TaggedError new AgentReferenceRepoSetupError({ reason }); const truthy = (value: string): boolean => /^(?:1|true|yes|on)$/iu.test(value); const falsy = (value: string): boolean => /^(?:0|false|no|off)$/iu.test(value); const environmentValue = (name: string) => Config.string(name).pipe(Config.withDefault('')); -const identityValue = (value: string, fallback: string): string => - value.length > 0 ? value : fallback; - +const identityValue = (value: string, fallback: string): string => (value.length > 0 ? value : fallback); const SetupEnvironment = Config.all({ agentRepos: environmentValue('ULTRAMODERN_AGENT_REPOS'), authorEmail: environmentValue('GIT_AUTHOR_EMAIL'), @@ -92,28 +67,23 @@ const SetupEnvironment = Config.all({ required: environmentValue('ULTRAMODERN_AGENT_REPOS_REQUIRED'), skipAgentRepos: environmentValue('ULTRAMODERN_SKIP_AGENT_REPOS'), }); - interface RuntimeSettings { readonly gitIdentity: Readonly>; readonly refresh: boolean; readonly required: boolean; readonly skipRequested: boolean; } - +const RuntimeConfiguration = Context.Service( + 'scripts/setup-agent-reference-repos/RuntimeConfiguration', +); const loadRuntimeSettings = Effect.fn('loadRuntimeSettings')(function* loadRuntimeSettingsEffect() { const environment = yield* SetupEnvironment; return { gitIdentity: { GIT_AUTHOR_EMAIL: identityValue(environment.authorEmail, 'ultramodern-agent-refs@local'), GIT_AUTHOR_NAME: identityValue(environment.authorName, 'UltraModern Agent Reference Setup'), - GIT_COMMITTER_EMAIL: identityValue( - environment.committerEmail, - 'ultramodern-agent-refs@local', - ), - GIT_COMMITTER_NAME: identityValue( - environment.committerName, - 'UltraModern Agent Reference Setup', - ), + GIT_COMMITTER_EMAIL: identityValue(environment.committerEmail, 'ultramodern-agent-refs@local'), + GIT_COMMITTER_NAME: identityValue(environment.committerName, 'UltraModern Agent Reference Setup'), }, refresh: truthy(environment.refresh), required: truthy(environment.required), @@ -121,12 +91,6 @@ const loadRuntimeSettings = Effect.fn('loadRuntimeSettings')(function* loadRunti } satisfies RuntimeSettings; }); -const RuntimeConfiguration = Context.Service( - 'scripts/setup-agent-reference-repos/RuntimeConfiguration', -); - -const setupError = (reason: string) => new AgentReferenceRepoSetupError({ reason }); - const commandFailure = ( command: string, commandArguments: readonly string[], @@ -136,13 +100,6 @@ const commandFailure = ( const detailSuffix = detail.length > 0 ? `: ${detail}` : ''; return setupError(`${invocation} failed${detailSuffix}`); }; - -interface CommandResult { - readonly status: number; - readonly stderr: string; - readonly stdout: string; -} - const executeCommand = Effect.fn('executeCommand')(function* executeCommandEffect( command: string, commandArguments: readonly string[], @@ -157,7 +114,6 @@ const executeCommand = Effect.fn('executeCommand')(function* executeCommandEffec stdin: 'ignore', stdout: 'pipe', }); - return yield* Effect.scoped( Effect.gen(function* collectCommandResultEffect() { const handle = yield* invocation; @@ -169,14 +125,13 @@ const executeCommand = Effect.fn('executeCommand')(function* executeCommandEffec ], { concurrency: 'unbounded' }, ); - return { status, stderr: stderr.trim(), stdout: stdout.trim() } satisfies CommandResult; + return { status, stderr: stderr.trim(), stdout: stdout.trim() }; }), ).pipe( Effect.timeout(timeoutMilliseconds), Effect.mapError((error) => commandFailure(command, commandArguments, String(error))), ); }); - const runCommand = Effect.fn('runCommand')(function* runCommandEffect( command: string, commandArguments: readonly string[], @@ -188,69 +143,41 @@ const runCommand = Effect.fn('runCommand')(function* runCommandEffect( } return result.stdout; }); - -const readConfig = Effect.fn('readConfig')(function* readConfigEffect(configPath: string) { - const fileSystem = yield* FileSystem.FileSystem; - const contents = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.mapError(() => setupError(`Unable to read ${configPath}`))); - return yield* Schema.decodeUnknownEffect(ReferenceRepositoryConfigJsonSchema)(contents).pipe( - Effect.mapError(() => - setupError(`Invalid reference repository configuration at ${configPath}`), - ), - ); -}); - -const assertSafeRepoPath = Effect.fn('assertSafeRepoPath')(function* assertSafeRepoPathEffect( - relativePath: string, -) { +const assertSafeRepoPath = Effect.fn('assertSafeRepoPath')(function* assertSafeRepoPathEffect(relativePath: string) { const path = yield* Path.Path; if ( relativePath.length === 0 || path.isAbsolute(relativePath) || relativePath.split(/[\\/]+/u).includes('..') || - !relativePath.startsWith('repos/') + !relativePath.startsWith('repos/') || + path.resolve(relativePath) === path.resolve('repos') ) { return yield* setupError(`Unsafe reference repository path: ${relativePath}`); } return yield* Effect.void; }); - const hasGit = Effect.fn('hasGit')(function* hasGitEffect() { const result = yield* executeCommand('git', ['--version'], 30_000); return result.status === 0; }); - const hasGitSubtree = Effect.fn('hasGitSubtree')(function* hasGitSubtreeEffect() { const result = yield* executeCommand('git', ['subtree', '-h'], 30_000); - return ( - (result.status === 0 || result.status === 129) && result.stdout.includes('usage: git subtree') - ); + return (result.status === 0 || result.status === 129) && result.stdout.includes('usage: git subtree'); }); - const isGitWorkTree = Effect.fn('isGitWorkTree')(function* isGitWorkTreeEffect() { const result = yield* executeCommand('git', ['rev-parse', '--is-inside-work-tree'], 30_000); return result.status === 0 && result.stdout === 'true'; }); - const hasCommits = Effect.fn('hasCommits')(function* hasCommitsEffect() { const result = yield* executeCommand('git', ['rev-parse', '--verify', 'HEAD'], 30_000); return result.status === 0; }); - -const porcelainStatus = Effect.fn('porcelainStatus')(function* porcelainStatusEffect() { - return yield* runCommand('git', ['status', '--porcelain'], 30_000); -}); - -const commitInstallerChanges = Effect.fn('commitInstallerChanges')( - function* commitInstallerChangesEffect(message: string) { - return yield* runCommand('git', ['commit', '--no-verify', '-m', message], 120_000); - }, -); - -const ensureGitRepository = Effect.fn('ensureGitRepository')(function* ensureGitRepositoryEffect( - checkOnly: boolean, +const commitInstallerChanges = Effect.fn('commitInstallerChanges')(function* commitInstallerChangesEffect( + message: string, ) { + return yield* runCommand('git', ['commit', '-m', message], 120_000); +}); +const ensureGitRepository = Effect.fn('ensureGitRepository')(function* ensureGitRepositoryEffect(checkOnly: boolean) { if (!(yield* isGitWorkTree())) { if (checkOnly) { return yield* setupError('workspace is not a git repository'); @@ -258,20 +185,16 @@ const ensureGitRepository = Effect.fn('ensureGitRepository')(function* ensureGit yield* Effect.logInfo(`${LOG_PREFIX} initializing git repository for agent reference subtrees`); yield* runCommand('git', ['init'], 30_000); } - if (!(yield* hasCommits())) { if (checkOnly) { return yield* setupError('workspace has no initial git commit'); } - yield* Effect.logInfo( - `${LOG_PREFIX} creating initial workspace commit before adding reference subtrees`, - ); + yield* Effect.logInfo(`${LOG_PREFIX} creating initial workspace commit before adding reference subtrees`); yield* runCommand('git', ['add', '-A'], 30_000); yield* commitInstallerChanges('Initialize UltraModern workspace'); return yield* Effect.void; } - - const status = yield* porcelainStatus(); + const status = yield* runCommand('git', ['status', '--porcelain'], 30_000); if (status.length > 0) { return yield* setupError( 'workspace has uncommitted changes; commit or stash them before installing reference subtrees', @@ -279,15 +202,8 @@ const ensureGitRepository = Effect.fn('ensureGitRepository')(function* ensureGit } return yield* Effect.void; }); - -const remoteCommit = Effect.fn('remoteCommit')(function* remoteCommitEffect( - repository: ReferenceRepository, -) { - const branchOutput = yield* runCommand( - 'git', - ['ls-remote', repository.url, `refs/heads/${repository.ref}`], - 120_000, - ); +const remoteCommit = Effect.fn('remoteCommit')(function* remoteCommitEffect(repository: ReferenceRepository) { + const branchOutput = yield* runCommand('git', ['ls-remote', repository.url, `refs/heads/${repository.ref}`], 120_000); const output = branchOutput.length > 0 ? branchOutput @@ -298,7 +214,6 @@ const remoteCommit = Effect.fn('remoteCommit')(function* remoteCommitEffect( } return commit; }); - const subtreeCommitExists = Effect.fn('subtreeCommitExists')(function* subtreeCommitExistsEffect( repository: ReferenceRepository, ) { @@ -309,26 +224,23 @@ const subtreeCommitExists = Effect.fn('subtreeCommitExists')(function* subtreeCo ); return result.status === 0 && result.stdout.length > 0; }); - -const installedManifestEntry = Effect.fn('installedManifestEntry')( - function* installedManifestEntryEffect(manifestPath: string, repository: ReferenceRepository) { - const fileSystem = yield* FileSystem.FileSystem; - if (!(yield* fileSystem.exists(manifestPath))) { - return Option.none(); - } - const repositories = yield* fileSystem.readFileString(manifestPath).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(InstalledManifestJsonSchema)), - Effect.map((manifest) => manifest.repositories), - Effect.option, - ); - return repositories.pipe( - Option.flatMap((entries) => - Option.fromUndefinedOr(entries.find((entry) => entry.id === repository.id)), - ), - ); - }, -); - +const installedManifestEntry = Effect.fn('installedManifestEntry')(function* installedManifestEntryEffect( + manifestPath: string, + repository: ReferenceRepository, +) { + const fileSystem = yield* FileSystem.FileSystem; + if (!(yield* fileSystem.exists(manifestPath))) { + return Option.none(); + } + const repositories = yield* fileSystem.readFileString(manifestPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(InstalledManifestJsonSchema)), + Effect.map((manifest) => manifest.repositories), + Effect.option, + ); + return repositories.pipe( + Option.flatMap((entries) => Option.fromUndefinedOr(entries.find((entry) => entry.id === repository.id))), + ); +}); const assertSubtreePresent = Effect.fn('assertSubtreePresent')(function* assertSubtreePresentEffect( manifestPath: string, repository: ReferenceRepository, @@ -336,14 +248,11 @@ const assertSubtreePresent = Effect.fn('assertSubtreePresent')(function* assertS yield* assertSafeRepoPath(repository.path); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetPath = path.join(WORKSPACE_ROOT, repository.path); - if (!(yield* fileSystem.exists(targetPath))) { + if (!(yield* fileSystem.exists(path.join(WORKSPACE_ROOT, repository.path)))) { return yield* setupError(`${repository.path} is missing`); } if (!(yield* subtreeCommitExists(repository))) { - return yield* setupError( - `${repository.path} is present but has no git-subtree commit evidence`, - ); + return yield* setupError(`${repository.path} is present but has no git-subtree commit evidence`); } const installedEntry = yield* installedManifestEntry(manifestPath, repository); return Option.getOrElse(installedEntry, (): InstalledRepository => ({ @@ -357,7 +266,6 @@ const assertSubtreePresent = Effect.fn('assertSubtreePresent')(function* assertS url: repository.url, })); }); - const addSubtree = Effect.fn('addSubtree')(function* addSubtreeEffect( manifestPath: string, repository: ReferenceRepository, @@ -366,9 +274,7 @@ const addSubtree = Effect.fn('addSubtree')(function* addSubtreeEffect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const settings = yield* RuntimeConfiguration; - const targetPath = path.join(WORKSPACE_ROOT, repository.path); - const existing = yield* fileSystem.exists(targetPath); - + const existing = yield* fileSystem.exists(path.join(WORKSPACE_ROOT, repository.path)); if (existing && !settings.refresh) { return yield* assertSubtreePresent(manifestPath, repository); } @@ -377,11 +283,8 @@ const addSubtree = Effect.fn('addSubtree')(function* addSubtreeEffect( `${repository.path} already exists; refresh for subtree references is intentionally manual`, ); } - const commit = yield* remoteCommit(repository); - yield* Effect.logInfo( - `${LOG_PREFIX} adding ${repository.name} as git subtree at ${repository.path} (${commit})`, - ); + yield* Effect.logInfo(`${LOG_PREFIX} adding ${repository.name} as git subtree at ${repository.path} (${commit})`); yield* runCommand('git', ['fetch', '--depth', '1', repository.url, repository.ref], 300_000); yield* runCommand( 'git', @@ -412,7 +315,6 @@ const addSubtree = Effect.fn('addSubtree')(function* addSubtreeEffect( url: repository.url, } satisfies InstalledRepository; }); - const writeManifest = Effect.fn('writeManifest')(function* writeManifestEffect( manifestPath: string, entries: readonly InstalledRepository[], @@ -427,44 +329,43 @@ const writeManifest = Effect.fn('writeManifest')(function* writeManifestEffect( schemaVersion: 1, strategy: REPOSITORY_STRATEGY, }).pipe(Effect.mapError(() => setupError('Unable to encode the agent reference manifest'))); - yield* fileSystem - .makeDirectory(path.dirname(manifestPath), { recursive: true }) - .pipe(Effect.mapError(() => setupError(`Unable to create the directory for ${manifestPath}`))); - yield* fileSystem - .writeFileString(manifestPath, `${contents}\n`) - .pipe(Effect.mapError(() => setupError(`Unable to write ${manifestPath}`))); + yield* fileSystem.makeDirectory(path.dirname(manifestPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(manifestPath, `${contents}\n`); }); - -const commitManifestIfChanged = Effect.fn('commitManifestIfChanged')( - function* commitManifestIfChangedEffect(manifestPath: string) { - const status = yield* runCommand('git', ['status', '--porcelain', '--', manifestPath], 30_000); - if (status.length === 0) { - return yield* Effect.void; - } - yield* runCommand('git', ['add', manifestPath], 30_000); - yield* commitInstallerChanges('Record agent reference repo manifest'); +const commitManifestIfChanged = Effect.fn('commitManifestIfChanged')(function* commitManifestIfChangedEffect( + manifestPath: string, +) { + const status = yield* runCommand('git', ['status', '--porcelain', '--', manifestPath], 30_000); + if (status.length === 0) { return yield* Effect.void; - }, -); - + } + yield* runCommand('git', ['add', manifestPath], 30_000); + yield* commitInstallerChanges('Record agent reference repo manifest'); + return yield* Effect.void; +}); const runSetup = Effect.fn('runSetup')(function* runSetupEffect(checkOnly: boolean) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const settings = yield* RuntimeConfiguration; const configPath = path.join(WORKSPACE_ROOT, '.agents', 'agent-reference-repos.json'); const manifestPath = path.join(WORKSPACE_ROOT, '.modernjs', 'agent-reference-repos.json'); - if (!(yield* fileSystem.exists(configPath))) { return yield* setupError('Missing .agents/agent-reference-repos.json'); } - const config = yield* readConfig(configPath); - const enabled = config.defaultEnabled && !settings.skipRequested; - if (!enabled) { - yield* Effect.logInfo( - `${LOG_PREFIX} setup skipped; set ULTRAMODERN_SKIP_AGENT_REPOS=0 to enable it again`, - ); + const config = yield* fileSystem.readFileString(configPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ReferenceRepositoryConfigJsonSchema)), + Effect.mapError(() => setupError(`Invalid reference repository configuration at ${configPath}`)), + ); + if (!config.defaultEnabled || settings.skipRequested) { + yield* Effect.logInfo(`${LOG_PREFIX} setup skipped; set ULTRAMODERN_SKIP_AGENT_REPOS=0 to enable it again`); return yield* Effect.void; } + // Validate all destinations before any Git mutation, including initial commits. + yield* Effect.forEach(config.repositories, (repository) => assertSafeRepoPath(repository.path), { + discard: true, + }); if (!(yield* hasGit())) { return yield* setupError('git is required to install agent reference repositories'); } @@ -472,13 +373,9 @@ const runSetup = Effect.fn('runSetup')(function* runSetupEffect(checkOnly: boole return yield* setupError('git subtree is required to install agent reference repositories'); } yield* ensureGitRepository(checkOnly); - const entries = yield* Effect.forEach( config.repositories, - (repository) => - checkOnly - ? assertSubtreePresent(manifestPath, repository) - : addSubtree(manifestPath, repository), + (repository) => (checkOnly ? assertSubtreePresent(manifestPath, repository) : addSubtree(manifestPath, repository)), { concurrency: 1 }, ); if (!checkOnly) { @@ -487,7 +384,6 @@ const runSetup = Effect.fn('runSetup')(function* runSetupEffect(checkOnly: boole } return yield* Effect.void; }); - const reportSetupFailure = (checkOnly: boolean) => (error: AgentReferenceRepoSetupError) => Effect.gen(function* reportSetupFailureEffect() { const settings = yield* RuntimeConfiguration; @@ -498,29 +394,17 @@ const reportSetupFailure = (checkOnly: boolean) => (error: AgentReferenceRepoSet yield* Effect.logWarning(`${LOG_PREFIX} ${error.reason}`); return yield* Effect.void; }); - const setupCommand = Command.make( 'setup-agent-reference-repos', - { checkOnly: Flag.boolean('check') }, + { checkOnly: Flag.boolean('check').pipe(Flag.withDefault(false)) }, ({ checkOnly }) => runSetup(checkOnly).pipe( + Effect.mapError((cause) => (Schema.is(AgentReferenceRepoSetupError)(cause) ? cause : setupError(String(cause)))), Effect.catchTag('AgentReferenceRepoSetupError', reportSetupFailure(checkOnly)), ), ); - -const corePlatformLayer = Layer.merge(NodeFileSystem.layer, NodePath.layer); -const childProcessLayer = NodeChildProcessSpawner.layer.pipe(Layer.provide(corePlatformLayer)); -const runtimeConfigurationLayer = Layer.effect(RuntimeConfiguration, loadRuntimeSettings()); -const applicationLayer = Layer.mergeAll( - corePlatformLayer, - childProcessLayer, - NodeStdio.layer, - NodeTerminal.layer, - runtimeConfigurationLayer, -); - +const applicationLayer = Layer.merge(NodeServices.layer, Layer.effect(RuntimeConfiguration, loadRuntimeSettings())); const executableLayer = Layer.effectDiscard(Command.run(setupCommand, { version: '1.0.0' })).pipe( Layer.provide(applicationLayer), ); - NodeRuntime.runMain(Effect.scoped(Layer.build(executableLayer))); diff --git a/app/scripts/shared/core-node-services.mts b/app/scripts/shared/core-node-services.mts index 1d4bbb564..5ddd81dc8 100644 --- a/app/scripts/shared/core-node-services.mts +++ b/app/scripts/shared/core-node-services.mts @@ -1,11 +1,10 @@ import { createRequire } from 'node:module'; + import { Layer, Result, Schema } from 'effect'; import type { Command } from 'effect/unstable/cli'; export const loadCoreNodeServices = () => { - const loadFromCoreRuntime = createRequire( - new URL('../../packages/core-runtime/package.json', import.meta.url), - ); + const loadFromCoreRuntime = createRequire(new URL('../../packages/core-runtime/package.json', import.meta.url)); const nodePlatform: unknown = loadFromCoreRuntime('@effect/platform-node'); const AnyLayerSchema = Schema.declare(Layer.isLayer); const NodeServicesLayerSchema = Schema.declare>( @@ -14,8 +13,6 @@ export const loadCoreNodeServices = () => { const NodePlatformSchema = Schema.Struct({ NodeServices: Schema.Struct({ layer: NodeServicesLayerSchema }), }); - const { NodeServices } = Result.getOrThrow( - Schema.decodeUnknownResult(NodePlatformSchema)(nodePlatform), - ); + const { NodeServices } = Result.getOrThrow(Schema.decodeUnknownResult(NodePlatformSchema)(nodePlatform)); return NodeServices; }; diff --git a/app/scripts/shared/ultramodern-command.mts b/app/scripts/shared/ultramodern-command.mts index 1bf128c64..6f1f3e679 100644 --- a/app/scripts/shared/ultramodern-command.mts +++ b/app/scripts/shared/ultramodern-command.mts @@ -29,12 +29,12 @@ export const resolveUltramodernInvocation = (options: CommandOptions) => ); const forwardedArgs = yield* stdio.args; const args = ['ultramodern', options.command, ...forwardedArgs]; - const nodeExecutable = options.nodeExecutable ?? 'node'; + const nodeExecutable = options.nodeExecutable ?? process.execPath; const launch = Option.match(createBin, { onNone: () => ({ args, - executable: 'modern-js-create', - target: 'modern-js-create from PATH', + executable: 'ultramodern-create', + target: 'ultramodern-create from PATH', }), onSome: (bin) => ({ args: [bin, ...args], @@ -62,21 +62,13 @@ export const resolveUltramodernInvocation = (options: CommandOptions) => }; }); -export const launchUltramodern = ( - invocation: Effect.Success>>, -) => +export const launchUltramodern = (invocation: Effect.Success>>) => Effect.gen(function* launchUltramodernEffect() { const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return Number( - yield* processSpawner - .exitCode(invocation.command) - .pipe(Effect.mapError(invocation.launchFailure)), - ); + return Number(yield* processSpawner.exitCode(invocation.command).pipe(Effect.mapError(invocation.launchFailure))); }); -export const runUltramodernScript = ( - options: CommandOptions, -) => +export const runUltramodernScript = (options: CommandOptions) => resolveUltramodernInvocation(options).pipe( Effect.flatMap(launchUltramodern), Effect.tapError(({ reason }) => Console.error(reason)), diff --git a/app/scripts/shared/ultramodern-launch.mts b/app/scripts/shared/ultramodern-launch.mts index fd48f5647..c2f06f2a3 100644 --- a/app/scripts/shared/ultramodern-launch.mts +++ b/app/scripts/shared/ultramodern-launch.mts @@ -10,9 +10,9 @@ export const ultramodernLaunch = ( const launch = Option.match(createBin, { onNone: () => ({ args: ultramodernArgs, - executable: 'modern-js-create', + executable: 'ultramodern-create', shell: pathSeparator === '\\', - target: 'modern-js-create from PATH', + target: 'ultramodern-create from PATH', }), onSome: (bin) => ({ args: [bin, ...ultramodernArgs], diff --git a/app/scripts/shared/ultramodern-wrapper-source.mts b/app/scripts/shared/ultramodern-wrapper-source.mts index 551a8ec01..6c692dbe0 100644 --- a/app/scripts/shared/ultramodern-wrapper-source.mts +++ b/app/scripts/shared/ultramodern-wrapper-source.mts @@ -7,10 +7,7 @@ const withoutComments = (source: string): string => { } let text = source; for (const comment of parsed.comments) { - text = - text.slice(0, comment.start) + - ' '.repeat(comment.end - comment.start) + - text.slice(comment.end); + text = text.slice(0, comment.start) + ' '.repeat(comment.end - comment.start) + text.slice(comment.end); } return text; }; @@ -18,7 +15,7 @@ const withoutComments = (source: string): string => { const hasSharedUltramodernDispatch = (source: string): boolean => source.includes("Config.string('ULTRAMODERN_CREATE_BIN')") && source.includes("['ultramodern', options.command, ...forwardedArgs]") && - source.includes("executable: 'modern-js-create'") && + source.includes("executable: 'ultramodern-create'") && source.includes('ChildProcess.make(launch.executable, launch.args,') && source.includes('resolveUltramodernInvocation(options).pipe(') && source.includes('Effect.flatMap(launchUltramodern)'); @@ -31,9 +28,11 @@ export const hasUltramodernSkillsDispatch = (source: string, implementation: str wrapper.includes("['skills', 'check',") && wrapper.includes("['skills', 'install',") && wrapper.includes("['ultramodern', ...skillArgs]") && - wrapper.includes('ultramodernLaunch(createBin, ultramodernArgs, workspaceRoot, path.sep)') && + /ultramodernLaunch\(\s*createBin\s*,\s*ultramodernArgs\s*,\s*workspaceRoot\s*,\s*path\.sep\s*,?\s*\)/u.test( + wrapper, + ) && wrapper.includes("Config.string('ULTRAMODERN_CREATE_BIN')") && - runner.includes("executable: 'modern-js-create'") && + runner.includes("executable: 'ultramodern-create'") && runner.includes('ChildProcess.make(launch.executable, launch.args,') ); }; @@ -48,7 +47,12 @@ export const hasUltramodernDispatch = ( return false; } const wrapper = withoutComments(source); - if (wrapper.includes(`['ultramodern', '${command}', ...forwardedArgs]`)) { + if ( + new RegExp( + `\\[\\s*['"]ultramodern['"]\\s*,\\s*['"]${command}['"]\\s*,\\s*\\.\\.\\.forwardedArgs\\s*,?\\s*\\]`, + 'u', + ).test(wrapper) + ) { return true; } if (implementation === undefined) { diff --git a/app/scripts/tests/api-only-tooling.test.mts b/app/scripts/tests/api-only-tooling.test.mts index 2003e78cc..e201eb6a6 100644 --- a/app/scripts/tests/api-only-tooling.test.mts +++ b/app/scripts/tests/api-only-tooling.test.mts @@ -1,54 +1,50 @@ -import type { - emitFrameworkMicroVerticalReleaseEnvelope, - emitNodeStagedReleaseEnvelope, - verifyBuildOutputReleaseEnvelope, - verifyNodeReleaseEnvelopeStaging, -} from '@modern-js/app-tools/release-envelope'; -import type { defineEffectBff } from '@modern-js/plugin-bff/effect-edge'; - -import { Cause, Effect, Predicate, Schema } from 'effect'; - -import { afterEach, expect, it, rs } from 'effect-rstest'; - import { execFileSync, spawnSync } from 'node:child_process'; - import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'; - -import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; - +import { mkdtemp, mkdir, readFile, realpath, symlink, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; - import os from 'node:os'; - import path from 'node:path'; - import { fileURLToPath, pathToFileURL } from 'node:url'; - import { promisify } from 'node:util'; +import type { + emitFrameworkMicroVerticalReleaseEnvelope, + emitNodeStagedReleaseEnvelope, + verifyBuildOutputReleaseEnvelope, + verifyNodeReleaseEnvelopeStaging, +} from '@modern-js/app-tools-extensions/release-envelope/framework-output'; +import type { defineEffectBff } from '@modern-js/plugin-bff/effect-edge'; +import { Cause, Effect, Predicate, Schema } from 'effect'; +import { describe, afterEach, expect, it, rs } from 'effect-rstest'; import { build as bundleSource, transform } from 'esbuild'; - import { format } from 'oxfmt'; import { MicroVerticalReadinessSchema } from '../../packages/shared-contracts/src/microvertical-api-baseline.ts'; - +import { hasValidGovernedHttpCompositionRoot } from '../generated-governed-http-boundary.mts'; +import { + hasGeneratedOperationGatewayContract, + hasGeneratedOperationPrincipalContract, +} from '../generated-module-api-boundary.mts'; import { configuredMicroVerticalApiStem, microVerticalApiBaselineViolation as microVerticalApiBaselineViolationForFile, } from '../microvertical-api-baseline-boundary.mts'; - import type { MicroVerticalApiBaselineExpectation } from '../microvertical-api-baseline-boundary.mts'; - +import { moduleFederationBridgeViolation } from '../module-federation-bridge-boundary.mts'; import { strictEffectRuntimeTopologyViolation } from '../ultramodern-api-boundary-rules.mts'; const EXPECTED_PROOF_VALUE = 'Expected a defined proof value'; const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const modernConfigFile = 'modern.config.ts'; +const shoppingDirectory = 'verticals/shopping'; +const ultramodernConfigFile = '.modernjs/ultramodern.json'; + const partyId = 'party-registry'; const partyDirectory = 'verticals/party-registry'; - +const partySharedApiPath = `${partyDirectory}/shared/api.ts`; const generatedFixtureId = 'inventory-stock'; const generatedApiPrefix = '/inventory-stock-api'; @@ -109,13 +105,10 @@ const apiBundlePath = 'api/index.js'; const routesManifestFile = 'routes-manifest.json'; const mfManifestFile = 'mf-manifest.json'; - -const generatedProofScope = 'generated-proof'; - const generatedClientContractImport = '../../shared/api.ts'; const generatedSharedApiModule = 'api/shared'; - +const generatedProofScope = 'generated-proof'; const generatedSharedContractsPackage = '@generated-proof/shared-contracts'; const effectClientPackage = '@modern-js/plugin-bff/effect-client'; @@ -136,8 +129,7 @@ const warehouseItemsApiStem = 'warehouse-items'; const warehouseApiPrefix = '/warehouse-api'; -const partyReadinessMetadataLine = - " readinessPath: '/party-registry-api/party-registry/readiness',"; +const partyReadinessMetadataLine = " readinessPath: '/party-registry-api/party-registry/readiness',"; const microVerticalApiBaselineViolation = Effect.fn(function* inspectMicroVerticalBaseline( stem: string, @@ -166,10 +158,9 @@ const unexpectedTopologyImport = (specifier: string): never => { throw new Error(`Unexpected strict-topology import: ${specifier}`); }; -const generatorRoot = await realpath(path.join(workspaceRoot, 'node_modules/@modern-js/create')); +const generatorRoot = await realpath(path.join(workspaceRoot, 'node_modules/@modern-js/ultramodern-create')); const require = createRequire(import.meta.url); - const publishedGeneratorModulePath = (moduleFormat: string) => (name: string): string => @@ -200,8 +191,14 @@ const BuildArtifactSchema = Schema.Struct({ kind: Schema.String, schemaVersion: Schema.Number, surfaces: Schema.Struct({ - api: Schema.Struct({ ...IdentitySchema.fields, surface: Schema.Literal('api') }), - ui: Schema.Struct({ ...IdentitySchema.fields, surface: Schema.Literal('ui') }), + api: Schema.Struct({ + ...IdentitySchema.fields, + surface: Schema.Literal('api'), + }), + ui: Schema.Struct({ + ...IdentitySchema.fields, + surface: Schema.Literal('ui'), + }), }), }); @@ -274,15 +271,16 @@ interface ApiGeneratorFixture { readonly exposes?: Readonly>; readonly id: string; } - -type CreateSharedApi = (scope: string, app: ApiGeneratorFixture) => string; - -type CreateApiClient = (app: WorkspaceAppFixture, contractImportPath: string) => string; +interface ApiGeneratorOptions { + readonly scope: string; +} +type CreateSharedApi = (app: ApiGeneratorFixture, options: ApiGeneratorOptions) => string; +type CreateApiClient = (app: WorkspaceAppFixture, contractImportPath: string, options: ApiGeneratorOptions) => string; type CreateApiServiceEntry = ( - scope: string, app: ApiGeneratorFixture, contractImportPath: string, + options: ApiGeneratorOptions, ) => string; interface GeneratedWorkspaceScriptArtifact { @@ -301,9 +299,7 @@ type GeneratedReadiness = typeof MicroVerticalReadinessSchema.Type; type GeneratedReadinessEffect = Effect.Effect; -type GetGeneratedReadiness = (options?: { - readonly baseUrl?: string | URL; -}) => GeneratedReadinessEffect; +type GetGeneratedReadiness = (options?: { readonly baseUrl?: string | URL }) => GeneratedReadinessEffect; type ValidateModuleFederationTypes = (input: { readonly appDirs: readonly string[]; @@ -346,9 +342,7 @@ interface RspackConfiguration { type RspackFactory = (configuration: RspackConfiguration) => CompilerFixture; -type DefinePluginConstructor = new ( - definitions: Readonly>, -) => RspackPluginFixture; +type DefinePluginConstructor = new (definitions: Readonly>) => RspackPluginFixture; type RspackModuleFixture = RspackFactory & { readonly DefinePlugin: DefinePluginConstructor; @@ -362,9 +356,7 @@ interface CompilerStatsFixture { interface CompilerFixture { readonly close: (onComplete: (error?: Error | null) => void) => void; - readonly run: ( - onComplete: (error: Error | null, stats?: CompilerStatsFixture | null) => void, - ) => void; + readonly run: (onComplete: (error: Error | null, stats?: CompilerStatsFixture | null) => void) => void; } const callable = void>() => @@ -379,13 +371,10 @@ const ReleaseEnvelopeSchema = Schema.Struct({ }); const ReleaseFrameworkModuleSchema = Schema.Struct({ - emitFrameworkMicroVerticalReleaseEnvelope: - callable(), + emitFrameworkMicroVerticalReleaseEnvelope: callable(), emitNodeStagedReleaseEnvelope: callable(), - verifyBuildOutputReleaseEnvelope: - callable(), - verifyNodeReleaseEnvelopeStaging: - callable(), + verifyBuildOutputReleaseEnvelope: callable(), + verifyNodeReleaseEnvelopeStaging: callable(), }); const WorkspaceAppFixtureSchema = Schema.Struct({ @@ -420,7 +409,9 @@ const StrictEffectApiBoundaryRuleModuleSchema = Schema.Struct({ createStrictEffectApiBoundariesRule: callable(), }); -const ComponentModuleSchema = Schema.Struct({ createLayout: callable() }); +const ComponentModuleSchema = Schema.Struct({ + createLayout: callable(), +}); const FederationConfigModuleSchema = Schema.Struct({ createAppModernConfig: callable(), @@ -431,9 +422,13 @@ const BuildModuleGeneratorSchema = Schema.Struct({ createUltramodernBuildModule: callable(), }); -const SharedApiGeneratorSchema = Schema.Struct({ createSharedApi: callable() }); +const SharedApiGeneratorSchema = Schema.Struct({ + createSharedApi: callable(), +}); -const ApiClientGeneratorSchema = Schema.Struct({ createApiClient: callable() }); +const ApiClientGeneratorSchema = Schema.Struct({ + createApiClient: callable(), +}); const ApiServiceGeneratorSchema = Schema.Struct({ createApiServiceEntry: callable(), @@ -444,7 +439,9 @@ const WorkspaceScriptsGeneratorSchema = Schema.Struct({ }); const GeneratedApiRuntimeModuleSchema = Schema.Struct({ - default: Schema.Struct({ createHandler: callable() }), + default: Schema.Struct({ + createHandler: callable(), + }), }); const CloudflareEvidenceSchema = Schema.Struct({ @@ -455,7 +452,9 @@ const ModuleFederationValidationModuleSchema = Schema.Struct({ validateModuleFederationTypes: callable(), }); -const ModuleFederationValidationResultSchema = Schema.Struct({ hostOnlyAppCount: Schema.Number }); +const ModuleFederationValidationResultSchema = Schema.Struct({ + hostOnlyAppCount: Schema.Number, +}); const ModuleFederationInspectionModuleSchema = Schema.Struct({ inspectModuleFederationConfigSource: callable(), @@ -477,7 +476,9 @@ const CompilerStatsFixtureSchema = Schema.Struct({ toString: callable(), }); -const CompiledReaderSchema = Schema.Struct({ allowedOrigins: Schema.Array(Schema.String) }); +const CompiledReaderSchema = Schema.Struct({ + allowedOrigins: Schema.Array(Schema.String), +}); const PackageJsonSchema = Schema.Struct({ scripts: Schema.Record(Schema.String, Schema.String), @@ -487,10 +488,7 @@ const TopologySchema = Schema.Struct({ verticals: Schema.Array( Schema.Struct({ backendFederation: Schema.Struct({ - exposes: Schema.Record( - Schema.String, - Schema.Struct({ contract: Schema.String, openapi: Schema.String }), - ), + exposes: Schema.Record(Schema.String, Schema.Struct({ contract: Schema.String, openapi: Schema.String })), }), cloudflare: Schema.Struct({ routes: Schema.Struct({ @@ -525,13 +523,10 @@ const loadReleaseFramework = (modulePath: string): Effect.Effect import(pathToFileURL(modulePath).href)); const framework = yield* Schema.decodeUnknownEffect(ReleaseFrameworkModuleSchema)(source); - const emitFrameworkMicroVerticalReleaseEnvelope = - framework.emitFrameworkMicroVerticalReleaseEnvelope.bind(source); + const emitFrameworkMicroVerticalReleaseEnvelope = framework.emitFrameworkMicroVerticalReleaseEnvelope.bind(source); const emitNodeStagedReleaseEnvelope = framework.emitNodeStagedReleaseEnvelope.bind(source); - const verifyBuildOutputReleaseEnvelope = - framework.verifyBuildOutputReleaseEnvelope.bind(source); - const verifyNodeReleaseEnvelopeStaging = - framework.verifyNodeReleaseEnvelopeStaging.bind(source); + const verifyBuildOutputReleaseEnvelope = framework.verifyBuildOutputReleaseEnvelope.bind(source); + const verifyNodeReleaseEnvelopeStaging = framework.verifyNodeReleaseEnvelopeStaging.bind(source); return { emitFrameworkMicroVerticalReleaseEnvelope, emitNodeStagedReleaseEnvelope, @@ -556,27 +551,22 @@ const writeJson = ( value: Value, ): Effect.Effect => Effect.gen(function* scenario3() { - yield* Effect.promise(() => - mkdir(path.dirname(path.join(root, logicalPath)), { recursive: true }), - ); + yield* Effect.promise(() => mkdir(path.dirname(path.join(root, logicalPath)), { recursive: true })); yield* Effect.promise(() => writeFile(path.join(root, logicalPath), JSON.stringify(value))); }); -const writeText = ( - root: string, - logicalPath: string, - value: string, -): Effect.Effect => +const writeText = (root: string, logicalPath: string, value: string): Effect.Effect => Effect.gen(function* scenario4() { - yield* Effect.promise(() => - mkdir(path.dirname(path.join(root, logicalPath)), { recursive: true }), - ); + yield* Effect.promise(() => mkdir(path.dirname(path.join(root, logicalPath)), { recursive: true })); yield* Effect.promise(() => writeFile(path.join(root, logicalPath), value)); }); const runNode = ( argumentsList: readonly string[], - options: { readonly cwd?: string; readonly env?: Readonly> } = {}, + options: { + readonly cwd?: string; + readonly env?: Readonly>; + } = {}, ): string => execFileSync(process.execPath, argumentsList, { cwd: options.cwd, @@ -584,9 +574,12 @@ const runNode = ( env: options.env, } satisfies ExecFileSyncOptionsWithStringEncoding); -const releaseFrameworkRoot = path.join( - workspaceRoot, - 'verticals/party-registry/node_modules/@modern-js/app-tools/dist', +const appToolsRequire = createRequire( + await realpath(path.join(workspaceRoot, 'verticals/party-registry/node_modules/@modern-js/app-tools/package.json')), +); +const releaseFrameworkRoot = path.resolve( + path.dirname(appToolsRequire.resolve('@modern-js/app-tools-extensions/release-envelope/framework-output')), + '../..', ); it.live( @@ -595,27 +588,22 @@ it.live( const apiServiceModule: unknown = yield* Effect.promise( () => import(pathToFileURL(path.join(generatorRoot, generatedApiServiceModule)).href), ); - const apiServiceGenerator = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorModuleSchema)( - apiServiceModule, - ); + const apiServiceGenerator = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorModuleSchema)(apiServiceModule); const packageModule: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/package-json.js'), - ).href - ), - ); - const packageGenerator = yield* Schema.decodeUnknownEffect(PackageGeneratorModuleSchema)( - packageModule, + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/package-json.js')).href), ); + const packageGenerator = yield* Schema.decodeUnknownEffect(PackageGeneratorModuleSchema)(packageModule); const source = apiServiceGenerator.createApiServiceEntry( - 'fixture', { - api: { consumedBy: [], prefix: generatedApiPrefix, stem: generatedFixtureId }, + api: { + consumedBy: [], + prefix: generatedApiPrefix, + stem: generatedFixtureId, + }, id: generatedFixtureId, }, generatedSharedApiImport, + { scope: 'fixture' }, ); expect(source).toMatch( @@ -631,11 +619,9 @@ it.live( 'fixture contracts', { modernPackageVersion: '3.8.2', strategy: 'install' }, ); - expect(sharedContractsPackage.exports['./server/effect-bff-runtime']).toBe( - './src/effect-bff-runtime.ts', - ); + expect(sharedContractsPackage.exports['./server/effect-bff-runtime']).toBe('./src/effect-bff-runtime.ts'); expect(sharedContractsPackage.dependencies['@modern-js/plugin-bff']).toBe('3.8.2'); - expect(sharedContractsPackage.dependencies.effect).toBe('4.0.0-beta.107'); + expect(sharedContractsPackage.dependencies.effect).toBe('4.0.0-rc.112'); expect( yield* Effect.promise(() => readFile(path.join(generatorRoot, 'templates/packages/effect-bff-runtime.ts'), 'utf-8'), @@ -643,19 +629,36 @@ it.live( ).toMatch(/export const assembleEffectBffRuntime/u); expect( yield* Effect.promise(() => - readFile( - path.join( - generatorRoot, - 'templates/workspace-scripts/check-ultramodern-api-boundaries.mts', - ), - 'utf-8', - ), + readFile(path.join(generatorRoot, 'templates/workspace-scripts/check-ultramodern-api-boundaries.mts'), 'utf-8'), ), ).toMatch(/strictEffectRuntimeTopologyViolation/u); }), ); +const expressionRuntimeFixture = ` +import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; +import { HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; +import { fixtureApi } from '../shared/api.ts'; +const groupLayer = HttpApiBuilder.group(fixtureApi, 'fixture', handlers => handlers.handle('reachable', () => undefined)); +const handlers = Layer.mergeAll(groupLayer); +export const makeRuntime = () => assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); +const apiRuntime = makeRuntime(); +export default apiRuntime; +`; + const adversarialStrictRuntimeSources = [ + ...( + [ + ['handlers: handlers });', 'handlers: handlers }) && fakeRuntime;'], + [ + 'const apiRuntime = makeRuntime();', + 'function dead() { const apiRuntime = makeRuntime(); } const apiRuntime = fakeRuntime;', + ], + ['export default apiRuntime;', 'export default fakeRuntime;'], + ['() => assembleEffectBffRuntime', '(assembleEffectBffRuntime) => assembleEffectBffRuntime'], + ] as const + ).map(([before, after]) => expressionRuntimeFixture.replace(before, after)), + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; @@ -977,6 +980,7 @@ const adversarialStrictRuntimeSources = [ ] as const; const validAdversarialStrictRuntimeSources = [ + expressionRuntimeFixture, ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; @@ -1117,9 +1121,7 @@ it('static API validation proves the imported helper call topology', () => { source: aggregateModule, }; }; - expect(strictEffectRuntimeTopologyViolation(importedHandlers, resolveImportedHandlers)).toBe( - undefined, - ); + expect(strictEffectRuntimeTopologyViolation(importedHandlers, resolveImportedHandlers)).toBe(undefined); const foreignGroupModule = groupModule.replace( `from '${generatedSharedApiImport}'`, "from '../../foreign/shared/api.ts'", @@ -1155,9 +1157,9 @@ it('static API validation proves the imported helper call topology', () => { source: aggregateModule, }; }; - expect( - strictEffectRuntimeTopologyViolation(importedHandlers, resolveForeignHandlers) ?? '', - ).toMatch(/explicitly composed Layer/u); + expect(strictEffectRuntimeTopologyViolation(importedHandlers, resolveForeignHandlers) ?? '').toMatch( + /explicitly composed Layer/u, + ); expect( strictEffectRuntimeTopologyViolation(` import { assembleEffectBffRuntime as assemble } from '@fixture/shared-contracts/server/effect-bff-runtime'; @@ -1174,10 +1176,7 @@ it('static API validation proves the imported helper call topology', () => { `), ).toBe(undefined); for (const [index, source] of validAdversarialStrictRuntimeSources.entries()) { - expect( - strictEffectRuntimeTopologyViolation(source), - `valid adversarial source ${index + 1}`, - ).toBe(undefined); + expect(strictEffectRuntimeTopologyViolation(source), `valid adversarial source ${index + 1}`).toBe(undefined); } for (const source of adversarialStrictRuntimeSources) { expect(strictEffectRuntimeTopologyViolation(source)).not.toBe(undefined); @@ -1476,7 +1475,7 @@ const strictBoundaryReports = ( const reportsAssemblyViolation = (messages: readonly string[]): boolean => messages.some((message) => - /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( + /server-only shared Effect BFF assembly helper|explicitly composed handler Layer|Generated API entries must export defineEffectBff|Generated API entries must implement handlers through HttpApiBuilder/u.test( message, ), ); @@ -1495,19 +1494,20 @@ it.live( const apiServiceSource: unknown = yield* Effect.promise( () => import(pathToFileURL(path.join(generatorRoot, generatedApiServiceModule)).href), ); - const apiServiceGenerator = Schema.decodeUnknownSync(ApiServiceGeneratorModuleSchema)( - apiServiceSource, - ); + const apiServiceGenerator = Schema.decodeUnknownSync(ApiServiceGeneratorModuleSchema)(apiServiceSource); const generatedSource = apiServiceGenerator.createApiServiceEntry( - 'app', { - api: { consumedBy: [], prefix: generatedApiPrefix, stem: generatedFixtureId }, + api: { + consumedBy: [], + prefix: generatedApiPrefix, + stem: generatedFixtureId, + }, id: generatedFixtureId, }, generatedSharedApiImport, + { scope: 'app' }, ); const generatedRpcSource = apiServiceGenerator.createApiServiceEntry( - 'app', { api: { consumedBy: [], @@ -1518,6 +1518,7 @@ it.live( id: generatedFixtureId, }, generatedSharedRpcImport, + { scope: 'app' }, ); const generatedRpcContractSource = ` import { RpcGroup } from 'effect/unstable/rpc'; @@ -1554,9 +1555,7 @@ it.live( const fixtureApiEntryPath = path.join(fixtureRoot, apiIndexFile); yield* Effect.promise(() => mkdir(path.join(fixtureRoot, 'api'), { recursive: true })); yield* Effect.promise(() => mkdir(path.join(fixtureRoot, 'shared'), { recursive: true })); - yield* Effect.promise(() => - writeFile(path.join(fixtureRoot, sharedApiFile), governedApiModuleSource), - ); + yield* Effect.promise(() => writeFile(path.join(fixtureRoot, sharedApiFile), governedApiModuleSource)); yield* Effect.promise(() => writeFile( path.join(fixtureRoot, 'shared/rpc.ts'), @@ -1584,9 +1583,7 @@ it.live( const foreignRoot = path.join(lintRoot, 'verticals/foreign'); yield* Effect.promise(() => mkdir(path.join(foreignRoot, 'shared'), { recursive: true })); yield* Effect.promise(() => mkdir(path.join(foreignRoot, 'api'), { recursive: true })); - yield* Effect.promise(() => - writeFile(path.join(foreignRoot, sharedApiFile), `${fixtureApiModuleSource}\n`), - ); + yield* Effect.promise(() => writeFile(path.join(foreignRoot, sharedApiFile), `${fixtureApiModuleSource}\n`)); yield* Effect.promise(() => writeFile( path.join(foreignRoot, 'api/group.ts'), @@ -1602,18 +1599,15 @@ it.live( ), ); const generatedRoot = path.join(lintRoot, 'verticals/inventory-stock'); + yield* Effect.promise(() => mkdir(path.join(generatedRoot, 'api'), { recursive: true })); yield* Effect.promise(() => mkdir(path.join(generatedRoot, 'shared'), { recursive: true })); yield* Effect.promise(() => writeFile(path.join(generatedRoot, sharedApiFile), 'export const inventoryStockApi = {};\n'), ); - yield* Effect.promise(() => - writeFile(path.join(generatedRoot, 'shared/rpc.ts'), generatedRpcContractSource), - ); + yield* Effect.promise(() => writeFile(path.join(generatedRoot, 'shared/rpc.ts'), generatedRpcContractSource)); const invalidSources = [ ...adversarialStrictRuntimeSources, - ...governedLayerAliasMutations.map(([before, after]) => - governedLayerAliasFixture.replace(before, after), - ), + ...governedLayerAliasMutations.map(([before, after]) => governedLayerAliasFixture.replace(before, after)), ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; @@ -1775,7 +1769,8 @@ it.live( }; }), ), - { concurrency: 'unbounded' }, + // CJS require(ESM) cannot race the same Babel module's async import. + { concurrency: 1 }, ); for (const { module, moduleFormat } of modules) { for (const source of invalidSources) { @@ -1796,11 +1791,7 @@ it.live( reportsAssemblyViolation(strictBoundaryReports(module, fixtureApiEntryPath, legacySource)), `${moduleFormat} accepted a legacy runtime root without the shared assembly helper`, ).toBe(true); - const generatedMessages = strictBoundaryReports( - module, - path.join(generatedRoot, apiIndexFile), - generatedSource, - ); + const generatedMessages = strictBoundaryReports(module, path.join(generatedRoot, apiIndexFile), generatedSource); expect( reportsAssemblyViolation(generatedMessages), `${moduleFormat} rejected exact generated helper output: ${generatedMessages.join(' | ')}`, @@ -1835,36 +1826,31 @@ it.live( const apiServiceSource: unknown = yield* Effect.promise( () => import(pathToFileURL(path.join(generatorRoot, generatedApiServiceModule)).href), ); - const apiServiceGenerator = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorModuleSchema)( - apiServiceSource, - ); + const apiServiceGenerator = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorModuleSchema)(apiServiceSource); const sharedApiSource: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/shared.js'), - ).href - ), - ); - const sharedApiGenerator = yield* Schema.decodeUnknownEffect(SharedApiGeneratorModuleSchema)( - sharedApiSource, + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/shared.js')).href), ); + const sharedApiGenerator = yield* Schema.decodeUnknownEffect(SharedApiGeneratorModuleSchema)(sharedApiSource); const descriptor = { - api: { consumedBy: [], prefix: generatedApiPrefix, stem: generatedFixtureId }, + api: { + consumedBy: [], + prefix: generatedApiPrefix, + stem: generatedFixtureId, + }, id: generatedFixtureId, } as const; const fixture = yield* Effect.acquireRelease( - Effect.promise(() => - mkdtemp(path.join(workspaceRoot, 'verticals/party-registry/.generated-runtime-')), - ), + Effect.promise(() => mkdtemp(path.join(workspaceRoot, 'verticals/party-registry/.generated-runtime-'))), (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); yield* writeText( fixture, apiIndexFile, - apiServiceGenerator.createApiServiceEntry('app', descriptor, generatedSharedApiImport), + apiServiceGenerator.createApiServiceEntry(descriptor, generatedSharedApiImport, { + scope: 'app', + }), ); - yield* writeText(fixture, sharedApiFile, sharedApiGenerator.createSharedApi('app', descriptor)); + yield* writeText(fixture, sharedApiFile, sharedApiGenerator.createSharedApi(descriptor, { scope: 'app' })); yield* writeText( fixture, buildMarkerFile, @@ -1910,16 +1896,10 @@ it.live( }), ); -const releaseFixture = Effect.fn(function* scenario5( - moduleFormat: 'cjs' | 'esm' | 'esm-node' = 'esm-node', -) { +const releaseFixture = Effect.fn(function* scenario5(moduleFormat: 'cjs' | 'esm' | 'esm-node' = 'esm-node') { const extension = moduleFormat === 'cjs' ? 'js' : 'mjs'; const releaseFramework = yield* loadReleaseFramework( - path.join( - releaseFrameworkRoot, - moduleFormat, - `ultramodern-release-envelope/framework-output.${extension}`, - ), + path.join(releaseFrameworkRoot, moduleFormat, `release-envelope/framework-output.${extension}`), ); const root = yield* Effect.acquireRelease( Effect.promise(() => mkdtemp(path.join(os.tmpdir(), 'ontos-empty-producer-'))), @@ -1946,10 +1926,7 @@ const releaseFixture = Effect.fn(function* scenario5( }, remotes: [], }; - const putJson = ( - logicalPath: string, - value: Value, - ): Effect.Effect => + const putJson = (logicalPath: string, value: Value): Effect.Effect => Effect.gen(function* scenario6() { return yield* writeJson(root, logicalPath, value); }); @@ -1967,7 +1944,9 @@ const releaseFixture = Effect.fn(function* scenario5( yield* putJson(mfManifestFile, manifest); yield* putJson(routesManifestFile, { routeAssets: { - index: { assets: [`https://assets.example.test/app/${compiledUiAssetPath}`] }, + index: { + assets: [`https://assets.example.test/app/${compiledUiAssetPath}`], + }, }, }); yield* putJson('route.json', { routes: [{ bundle: ssrBundlePath }] }); @@ -1992,7 +1971,15 @@ const releaseFixture = Effect.fn(function* scenario5( ), ); }); - return { artifact, emit, framework: releaseFramework, manifest, putJson, putText, root }; + return { + artifact, + emit, + framework: releaseFramework, + manifest, + putJson, + putText, + root, + }; }); it.live( @@ -2007,8 +1994,7 @@ it.live( expect(envelope.surfaces.ssr).toEqual([ssrBundlePath]); expect(envelope.surfaces.apiBackend).toEqual([apiBundlePath]); yield* Effect.promise( - async () => - await fixture.framework.verifyBuildOutputReleaseEnvelope(fixture.root, 'node'), + async () => await fixture.framework.verifyBuildOutputReleaseEnvelope(fixture.root, 'node'), ); const staged = yield* Schema.decodeUnknownEffect(ReleaseEnvelopeSchema)( yield* Effect.promise( @@ -2037,8 +2023,7 @@ it.live( Effect.sandbox( Effect.fn(function* scenario12() { return yield* Effect.promise( - async () => - await fixture.framework.verifyBuildOutputReleaseEnvelope(fixture.root, 'node'), + async () => await fixture.framework.verifyBuildOutputReleaseEnvelope(fixture.root, 'node'), ); })(), ), @@ -2101,11 +2086,20 @@ it.live( const invalidManifests = [ { ...baseline.manifest, exposes: [{ name: './Page' }] }, { ...baseline.manifest, remotes: [{ name: 'shell' }] }, - { metaData: baseline.manifest.metaData, remotes: baseline.manifest.remotes }, - { exposes: baseline.manifest.exposes, metaData: baseline.manifest.metaData }, + { + metaData: baseline.manifest.metaData, + remotes: baseline.manifest.remotes, + }, + { + exposes: baseline.manifest.exposes, + metaData: baseline.manifest.metaData, + }, { ...baseline.manifest, - metaData: { ...baseline.manifest.metaData, remoteEntry: { name: '', path: '' } }, + metaData: { + ...baseline.manifest.metaData, + remoteEntry: { name: '', path: '' }, + }, }, ]; yield* Effect.all( @@ -2147,14 +2141,20 @@ it.live( const fixture = yield* releaseFixture(); yield* fixture.putJson('backend-mf-manifest.json', { backendFederation: { - deliveryUnit: { ...fixture.artifact.deliveryUnit, sourceRevision: 'b'.repeat(40) }, + deliveryUnit: { + ...fixture.artifact.deliveryUnit, + sourceRevision: 'b'.repeat(40), + }, }, }); const failureCause6 = yield* Effect.flip(Effect.sandbox(fixture.emit())); expect(String(Cause.squash(failureCause6))).toMatch(/must match/u); const workspaceArtifact = { ...fixture.artifact, - deliveryUnit: { ...fixture.artifact.deliveryUnit, sourceRevision: 'workspace' }, + deliveryUnit: { + ...fixture.artifact.deliveryUnit, + sourceRevision: 'workspace', + }, surfaces: { api: { ...fixture.artifact.surfaces.api, sourceRevision: 'workspace' }, ui: { ...fixture.artifact.surfaces.ui, sourceRevision: 'workspace' }, @@ -2166,22 +2166,28 @@ it.live( }), ); -const GlobalVarsSchema = Schema.Struct({ ULTRAMODERN_SHELL_ORIGIN: Schema.String }); +const GlobalVarsSchema = Schema.Struct({ + ULTRAMODERN_SHELL_ORIGIN: Schema.String, +}); const evaluatePartyBuildGlobalVars = Effect.fn(function* scenario20(shellOrigin: string) { - const temporaryRoot = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), 'ontos-party-config-')), - ); + const temporaryRoot = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), 'ontos-party-config-'))); return yield* Effect.gen(function* useResource1() { const harnessPath = path.join(temporaryRoot, 'read-config.mjs'); const configSource = yield* Effect.promise(() => readFile(path.join(workspaceRoot, 'verticals/party-registry/modern.config.ts'), 'utf-8'), ); - const effectModuleUrl = pathToFileURL( - require.resolve('effect', { paths: [workspaceRoot] }), - ).href; + const effectModuleUrl = pathToFileURL(require.resolve('effect', { paths: [workspaceRoot] })).href; const { code } = yield* Effect.promise(() => - transform(configSource, { format: 'cjs', loader: 'ts' }), + transform(configSource, { + define: { + 'import.meta.url': JSON.stringify( + pathToFileURL(path.join(workspaceRoot, 'verticals/party-registry/modern.config.ts')).href, + ), + }, + format: 'cjs', + loader: 'ts', + }), ); yield* Effect.promise(() => writeFile( @@ -2189,9 +2195,12 @@ const evaluatePartyBuildGlobalVars = Effect.fn(function* scenario20(shellOrigin: `import * as effect from ${JSON.stringify(effectModuleUrl)}; import * as sharedBuild from ${JSON.stringify(pathToFileURL(path.join(workspaceRoot, 'packages/shared-contracts/tooling/modern-config.ts')).href)}; import { runInNewContext } from 'node:vm'; +import * as nodeUrl from 'node:url'; +import * as nodePath from 'node:path'; const framework = { ...sharedBuild, appTools: () => ({}), + ultramodernReleaseEnvelopePlugin: () => ({}), bffPlugin: () => ({}), createRequire: () => () => ({}), defineConfig: configuration => configuration, @@ -2207,7 +2216,8 @@ const module = { exports: {} }; runInNewContext(${JSON.stringify(code)}, { exports: module.exports, module, - require: specifier => specifier === 'effect' ? effect : framework, + URL, + require: specifier => specifier === 'effect' ? effect : specifier === 'node:url' ? nodeUrl : specifier === 'node:path' ? nodePath : framework, }); process.stdout.write(JSON.stringify(module.exports.default.source.globalVars)); `, @@ -2215,9 +2225,7 @@ process.stdout.write(JSON.stringify(module.exports.default.source.globalVars)); ); const output = runNode([harnessPath]); return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GlobalVarsSchema))(output); - }).pipe( - Effect.ensuring(Effect.promise(() => rm(temporaryRoot, { force: true, recursive: true }))), - ); + }).pipe(Effect.ensuring(Effect.promise(() => rm(temporaryRoot, { force: true, recursive: true })))); }); it.live( @@ -2235,21 +2243,19 @@ it.live( const shellOrigin = 'https://operations.example.test'; const globalVars = yield* evaluatePartyBuildGlobalVars(shellOrigin); const partyRoot = path.join(workspaceRoot, partyDirectory); - const source = yield* Effect.promise(() => - readFile(path.join(partyRoot, 'api/index.ts'), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(partyRoot, 'api/index.ts'), 'utf-8')); const reader = - /(?declare const ULTRAMODERN_SHELL_ORIGIN[\s\S]+?const shellOrigin = readShellOrigin\(\);)/u.exec( - source, - )?.groups?.reader; + /(?declare const ULTRAMODERN_SHELL_ORIGIN[\s\S]+?const shellOrigin = readShellOrigin\(\);)/u.exec(source) + ?.groups?.reader; expect(reader, 'compile the actual API origin-reader boundary').not.toBe(undefined); - const appToolsPath = require.resolve('@modern-js/app-tools/config', { paths: [partyRoot] }); - const rsbuildPath = require.resolve('@rsbuild/core', { paths: [appToolsPath] }); - const rspackModule: unknown = require( - require.resolve('@rspack/core', { paths: [rsbuildPath] }), - ); - const rspackFixture = - yield* Schema.decodeUnknownEffect(RspackModuleFixtureSchema)(rspackModule); + const appToolsPath = require.resolve('@modern-js/app-tools/config', { + paths: [partyRoot], + }); + const rsbuildPath = require.resolve('@rsbuild/core', { + paths: [appToolsPath], + }); + const rspackModule: unknown = require(require.resolve('@rspack/core', { paths: [rsbuildPath] })); + const rspackFixture = yield* Schema.decodeUnknownEffect(RspackModuleFixtureSchema)(rspackModule); const temporaryRoot = yield* Effect.promise(() => mkdtemp(path.join(partyRoot, 'node_modules/.ontos-compiled-cors-')), ); @@ -2262,9 +2268,7 @@ it.live( ), ); const definePlugin = new rspackFixture.DefinePlugin( - Object.fromEntries( - Object.entries(globalVars).map(([key, value]) => [key, JSON.stringify(value)]), - ), + Object.fromEntries(Object.entries(globalVars).map(([key, value]) => [key, JSON.stringify(value)])), ); const createCompiler = rspackFixture.rspack.bind(rspackModule); const compilerSource = createCompiler({ @@ -2282,7 +2286,11 @@ it.live( }, ], }, - output: { filename: 'reader.cjs', library: { type: 'commonjs2' }, path: temporaryRoot }, + output: { + filename: 'reader.cjs', + library: { type: 'commonjs2' }, + path: temporaryRoot, + }, plugins: [definePlugin], target: 'node', }); @@ -2303,35 +2311,126 @@ it.live( toString: statsSource.toString, }); const hasErrors = stats.hasErrors.bind(statsSource)(); - const errorText = stats.toString.bind(statsSource)({ all: false, errors: true }); + const errorText = stats.toString.bind(statsSource)({ + all: false, + errors: true, + }); expect(hasErrors, errorText).toBe(false); }).pipe(Effect.ensuring(Effect.promise(() => closeCompiler()))); const compiledReaderModule: unknown = require(path.join(temporaryRoot, 'reader.cjs')); - const compiledReader = - yield* Schema.decodeUnknownEffect(CompiledReaderSchema)(compiledReaderModule); + const compiledReader = yield* Schema.decodeUnknownEffect(CompiledReaderSchema)(compiledReaderModule); expect([...compiledReader.allowedOrigins]).toEqual([shellOrigin]); - }).pipe( - Effect.ensuring(Effect.promise(() => rm(temporaryRoot, { force: true, recursive: true }))), - ); + }).pipe(Effect.ensuring(Effect.promise(() => rm(temporaryRoot, { force: true, recursive: true })))); }), ); const normalizedGeneratedSource = Effect.fn(function* scenario23(fileName: string, source: string) { - const result = yield* Effect.promise(() => - format(fileName, source, { singleQuote: true, sortImports: true }), - ); + const result = yield* Effect.promise(() => format(fileName, source, { singleQuote: true, sortImports: true })); expect(result.errors).toEqual([]); return result.code.replaceAll(/^\s*\n/gmu, ''); }); +const ScaffoldSemanticKindSchema = Schema.Literals(['backend', 'layout', 'service']); +type ScaffoldSemanticKind = typeof ScaffoldSemanticKindSchema.Type; +const scaffoldSemanticKinds = new Map([ + ['backend-federation.config.ts', 'backend'], + ['src/routes/layout.tsx', 'layout'], +]); + +// Evaluate only controlled scaffolds with inert dependency adapters in a separate Node process. +// No application server, deployment, real plugin or environment file is loaded by this harness. +const evaluateScaffoldSemantics = Effect.fn(function* evaluateScaffoldSemantics( + source: string, + kind: ScaffoldSemanticKind, +) { + const scratchRoot = path.join(workspaceRoot, '.scratch'); + yield* Effect.promise(() => mkdir(scratchRoot, { recursive: true })); + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(scratchRoot, 'scaffold-semantics-'))), + (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), + ); + { + const effectUrl = pathToFileURL(require.resolve('effect')).href; + const { code } = yield* Effect.promise(() => + transform(source, { + define: { + 'import.meta.url': JSON.stringify('file:///fixture/config.ts'), + }, + format: 'cjs', + jsxFactory: 'element', + loader: kind === 'layout' ? 'tsx' : 'ts', + }), + ); + const harnessPath = path.join(fixture, 'evaluate.mjs'); + yield* Effect.promise(() => + writeFile( + harnessPath, + ` +import * as effect from ${JSON.stringify(effectUrl)}; +import { runInNewContext } from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const kind = ${JSON.stringify(kind)}; +const pluginNames = ['appTools', 'bffPlugin', 'i18nPlugin', 'tanstackRouterPlugin', 'moduleFederationPlugin', 'pluginTailwindcss', 'ultramodernReleaseEnvelopePlugin']; +const framework = { + ...Object.fromEntries(pluginNames.map(name => [name, () => ({ name })])), + builtinModules: [], createRequire: () => name => ({ version: name === 'effect/package.json' ? '4.0.0-rc.112' : '3.9.0-ultramodern.2' }), + defineConfig: config => config, presetUltramodern: config => config, + createModuleFederationConfig: config => config, + getBuildConfigEnvironment: () => undefined, ultramodernLocalisedUrls: {}, +}; +const module = { exports: {} }; +const spans = []; +const contexts = Object.fromEntries(['readiness', 'list', 'get', 'create'].map(name => [name, { method: 'GET', operationId: name, routePath: '/' + name, source: 'server', traceId: 'trace-' + name }])); +const fixtureEffect = { succeed: () => ({ pipe: (...steps) => steps.reduce((value, step) => step(value), {}) }), withSpan: (name, options) => value => { spans.push({ name, attributes: options.attributes }); return value; } }; +const fixtureHandlers = { handle: (_name, callback) => { callback({ query: {}, params: { id: 'starter-inventory-stock' }, payload: { title: 'Fixture' } }); return fixtureHandlers; } }; +runInNewContext(${JSON.stringify(code)}, { + module, exports: module.exports, URL, + element: (type, props, ...children) => ({ type, props, children }), + require: specifier => { + if (kind === 'service') { + if (specifier.endsWith('/server/effect-bff-runtime')) return { assembleEffectBffRuntime: value => value }; + if (specifier === '@modern-js/plugin-bff/effect-edge') return { Effect: fixtureEffect, Layer: { mergeAll: (...layers) => layers }, HttpApiBuilder: { group: (_api, _group, configure) => configure(fixtureHandlers) } }; + if (specifier === '../shared/api.ts') return { inventoryStockApi: {}, inventoryStockOperationContexts: contexts }; + if (specifier === '../shared/ultramodern-build.ts') return { ultramodernApiMarker: {} }; + } + if (specifier === 'effect') return effect; + if (specifier === 'effect/Schema') return effect.Schema; + if (specifier === 'node:url') return { fileURLToPath }; + if (specifier === 'node:path') return path; + if (specifier === './package.json') return { dependencies: { '@module-federation/runtime': '2.9.0' } }; + if (specifier === '@modern-js/plugin-tanstack/runtime') return { Outlet: 'Outlet' }; + if (specifier === './index.css') return {}; + return framework; + }, +}); +let evidence = module.exports; +if (kind === 'layout') evidence = evidence.default(); +if (kind === 'backend') evidence = evidence.default; +if (kind === 'service') evidence = spans; +process.stdout.write(JSON.stringify(evidence)); +`, + ), + ); + return runNode([harnessPath]); + } +}); + const evaluatedInfrastructureSource = Effect.fn(function* mergedScenario1( fileName: string, source: string, cloudflare: boolean, + injection: Readonly>, + contractOnly = false, ) { const partyRoot = path.join(workspaceRoot, partyDirectory); const result = yield* Effect.promise(() => bundleSource({ + alias: { + '@modern-js/runtime-extensions/build-identity': createRequire(path.join(partyRoot, fileName)).resolve( + '@modern-js/runtime-extensions/build-identity', + ), + }, bundle: true, define: { 'import.meta.resolve': '__resolve', @@ -2356,9 +2455,7 @@ const evaluatedInfrastructureSource = Effect.fn(function* mergedScenario1( } const effectUrl = pathToFileURL(require.resolve('effect')).href; const buildIdentityUrl = pathToFileURL( - createRequire(path.join(partyRoot, fileName)).resolve( - '@app/shared-contracts/ultramodern-build', - ), + createRequire(path.join(partyRoot, fileName)).resolve('@app/shared-contracts/ultramodern-build'), ).href; return runNode([ '--input-type=module', @@ -2379,6 +2476,7 @@ const environment = { const plugin = name => options => ({ name, options }); const framework = { appTools: plugin('appTools'), bffPlugin: plugin('bff'), i18nPlugin: plugin('i18n'), + ultramodernReleaseEnvelopePlugin: plugin('ultramodernReleaseEnvelopePlugin'), moduleFederationPlugin: plugin('moduleFederation'), pluginTailwindcss: plugin('tailwind'), tanstackRouterPlugin: plugin('tanstack'), withZephyr: plugin('zephyr'), defineConfig: value => value, presetUltramodern: (value, identity) => ({ ...value, identity }), @@ -2390,7 +2488,7 @@ const moduleShim = { ...nodeModule, createRequire: () => Object.assign(() => ({} const module = { exports: {} }; runInNewContext(${JSON.stringify(code)}, { exports: module.exports, module, URL, - ULTRAMODERN_BUILD_MARKER: 'injected-build', ULTRAMODERN_SOURCE_REVISION: 'injected-revision', + ...${JSON.stringify(injection)}, __resolve: name => 'file:///dependencies/' + name, require: name => ({ effect, '@app/shared-contracts/ultramodern-build': buildIdentity, 'node:module': moduleShim, 'node:path': nodePath, 'node:url': nodeUrl }[name] ?? framework), }); @@ -2427,7 +2525,25 @@ const normalize = (_key, value) => { if (Object.prototype.toString.call(value) === '[object RegExp]') return String(value); return value; }; -process.stdout.write(JSON.stringify({ exported: module.exports, observations }, normalize)); +const evidence = ${contractOnly} && configuration ? { + bff: configuration.bff, + builderPlugins: configuration.builderPlugins, + deploy: configuration.deploy, + identity: configuration.identity, + html: configuration.html, + output: configuration.output, + buildCache: configuration.performance?.buildCache, + plugins: configuration.plugins.map(entry => entry.name), + devAssetPrefix: configuration.dev?.assetPrefix, + devHeaders: configuration.dev?.server?.headers ?? configuration.tools?.devServer?.headers, + port: configuration.server?.port, + alias: configuration.source?.alias, + siteUrl: configuration.source?.globalVars?.ULTRAMODERN_SITE_URL, + mainEntryName: configuration.source?.mainEntryName, + chain: observations.chain, + pluginsBehavior: observations.plugins, +} : { exported: module.exports, observations }; +process.stdout.write(JSON.stringify(evidence, normalize)); `, ]); }); @@ -2435,6 +2551,10 @@ process.stdout.write(JSON.stringify({ exported: module.exports, observations }, it.live( 'all published scaffold formats retain Party infrastructure behavior and source parity', Effect.fn(function* mergedScenario4() { + const canonicalModule: unknown = yield* Effect.promise( + () => import(pathToFileURL(publishedGeneratorModulePath('esm-node')('module-federation/config')).href), + ); + const canonical = Schema.decodeUnknownSync(FederationConfigModuleSchema)(canonicalModule); yield* Effect.all( ['esm', 'esm-node', 'cjs'].map( Effect.fn(function* mergedScenario3(moduleFormat) { @@ -2443,43 +2563,31 @@ it.live( moduleFormat === 'cjs' ? require(descriptorPath) : yield* Effect.promise(() => import(pathToFileURL(descriptorPath).href)); - const descriptorModule = - Schema.decodeUnknownSync(DescriptorModuleSchema)(descriptorSource); + const descriptorModule = Schema.decodeUnknownSync(DescriptorModuleSchema)(descriptorSource); const componentPath = publishedGeneratorModulePath(moduleFormat)('demo-components'); const componentSource: unknown = moduleFormat === 'cjs' ? require(componentPath) : yield* Effect.promise(() => import(pathToFileURL(componentPath).href)); const componentModule = Schema.decodeUnknownSync(ComponentModuleSchema)(componentSource); - const federationPath = publishedGeneratorModulePath(moduleFormat)( - 'module-federation/config', - ); + const federationPath = publishedGeneratorModulePath(moduleFormat)('module-federation/config'); const federationSource: unknown = moduleFormat === 'cjs' ? require(federationPath) : yield* Effect.promise(() => import(pathToFileURL(federationPath).href)); - const federationModule = Schema.decodeUnknownSync(FederationConfigModuleSchema)( - federationSource, - ); - const buildModulePath = publishedGeneratorModulePath(moduleFormat)( - 'module-federation/reexport-module', - ); + const federationModule = Schema.decodeUnknownSync(FederationConfigModuleSchema)(federationSource); + const buildModulePath = publishedGeneratorModulePath(moduleFormat)('module-federation/reexport-module'); const buildModuleSource: unknown = moduleFormat === 'cjs' ? require(buildModulePath) : yield* Effect.promise(() => import(pathToFileURL(buildModulePath).href)); - const buildModule = Schema.decodeUnknownSync(BuildModuleGeneratorSchema)( - buildModuleSource, - ); - const createVerticalDescriptor = - descriptorModule.createVerticalDescriptor.bind(descriptorSource); + const buildModule = Schema.decodeUnknownSync(BuildModuleGeneratorSchema)(buildModuleSource); + const createVerticalDescriptor = descriptorModule.createVerticalDescriptor.bind(descriptorSource); const createLayout = componentModule.createLayout.bind(componentSource); - const createAppModernConfig = - federationModule.createAppModernConfig.bind(federationSource); + const createAppModernConfig = federationModule.createAppModernConfig.bind(federationSource); const createBackendModuleFederationConfig = federationModule.createBackendModuleFederationConfig.bind(federationSource); - const createUltramodernBuildModule = - buildModule.createUltramodernBuildModule.bind(buildModuleSource); + const createUltramodernBuildModule = buildModule.createUltramodernBuildModule.bind(buildModuleSource); const descriptor: unknown = createVerticalDescriptor(partyId, 4102); Schema.asserts(WorkspaceAppFixtureSchema, descriptor); const app = { ...descriptor, exposes: {} }; @@ -2487,12 +2595,8 @@ it.live( 'backend-federation.config.ts': Schema.decodeUnknownSync(Schema.String)( createBackendModuleFederationConfig(app), ), - [buildMarkerFile]: Schema.decodeUnknownSync(Schema.String)( - createUltramodernBuildModule('app', app), - ), - 'modern.config.ts': Schema.decodeUnknownSync(Schema.String)( - createAppModernConfig('app', app), - ), + [buildMarkerFile]: Schema.decodeUnknownSync(Schema.String)(createUltramodernBuildModule('app', app)), + [modernConfigFile]: Schema.decodeUnknownSync(Schema.String)(createAppModernConfig('app', app)), 'src/routes/layout.tsx': Schema.decodeUnknownSync(Schema.String)(createLayout(app.id)), }; yield* Effect.all( @@ -2501,32 +2605,73 @@ it.live( const actual = yield* Effect.promise(() => readFile(path.join(workspaceRoot, partyDirectory, fileName), 'utf-8'), ); - if (fileName === 'modern.config.ts' || fileName === 'shared/ultramodern-build.ts') { - yield* Effect.all( - [false, true].map( - Effect.fn(function* mergedScenario1(cloudflare) { - const [expected, evaluated] = yield* Effect.all( - [ - evaluatedInfrastructureSource(fileName, source, cloudflare), - evaluatedInfrastructureSource(fileName, actual, cloudflare), - ], - { concurrency: 'unbounded' }, + if (fileName === modernConfigFile || fileName === 'shared/ultramodern-build.ts') { + const injections: Readonly>[] = [ + {}, + { + ULTRAMODERN_BUILD_MARKER: 'executed-build', + ULTRAMODERN_SOURCE_REVISION: 'a'.repeat(40), + }, + ]; + const evaluations: { + cloudflare: boolean; + injection: Readonly>; + }[] = []; + for (const cloudflare of [false, true]) { + for (const injection of injections) { + evaluations.push({ cloudflare, injection }); + } + } + yield* Effect.forEach( + evaluations, + Effect.fn(function* evaluateInfrastructureInjection({ cloudflare, injection }) { + if (fileName === modernConfigFile) { + const canonicalSource = Schema.decodeUnknownSync(Schema.String)( + canonical.createAppModernConfig('app', app), ); - const decode = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); expect( - decode(expected), - `${moduleFormat}: ${fileName} must preserve evaluated configuration, build identity and plugin behavior`, - ).toEqual(decode(evaluated)); - }), - ), + yield* evaluatedInfrastructureSource(fileName, source, cloudflare, injection), + `${moduleFormat} must retain complete published config behavior`, + ).toBe(yield* evaluatedInfrastructureSource(fileName, canonicalSource, cloudflare, injection)); + } + const [expected, evaluated] = yield* Effect.all( + [ + evaluatedInfrastructureSource( + fileName, + source, + cloudflare, + injection, + fileName === modernConfigFile, + ), + evaluatedInfrastructureSource( + fileName, + actual, + cloudflare, + injection, + fileName === modernConfigFile, + ), + ], + { concurrency: 'unbounded' }, + ); + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); + expect( + decode(expected), + `${moduleFormat}: ${fileName} must preserve evaluated configuration, build identity and plugin behavior`, + ).toEqual(decode(evaluated)); + }), { concurrency: 'unbounded' }, ); return; } + const kind = scaffoldSemanticKinds.get(fileName); + expect(kind, `Unknown scaffold ${fileName}`).toBeDefined(); + if (kind === undefined) { + throw new Error(`Unknown scaffold ${fileName}`); + } expect( - yield* normalizedGeneratedSource(fileName, source), - `${moduleFormat}: ${fileName} must match the controlled scaffold`, - ).toBe(yield* normalizedGeneratedSource(fileName, actual)); + yield* evaluateScaffoldSemantics(source, kind), + `${moduleFormat}: ${fileName} must preserve typed runtime, ownership and release gates`, + ).toBe(yield* evaluateScaffoldSemantics(actual, kind)); }), ), { concurrency: 'unbounded' }, @@ -2545,12 +2690,9 @@ it.live( ['esm', 'esm-node', 'cjs'].map( Effect.fn(function* governanceScenario9(moduleFormat) { const descriptorPath = publishedGeneratorModulePath(moduleFormat)('descriptors'); - const sharedApiPath = - publishedGeneratorModulePath(moduleFormat)(generatedSharedApiModule); + const sharedApiPath = publishedGeneratorModulePath(moduleFormat)(generatedSharedApiModule); const clientPath = publishedGeneratorModulePath(moduleFormat)('api/client'); - const servicePath = publishedGeneratorModulePath(moduleFormat)( - generatedServiceModuleName, - ); + const servicePath = publishedGeneratorModulePath(moduleFormat)(generatedServiceModuleName); const descriptorSource: unknown = moduleFormat === 'cjs' ? require(descriptorPath) @@ -2567,27 +2709,25 @@ it.live( moduleFormat === 'cjs' ? require(servicePath) : yield* Effect.promise(() => import(pathToFileURL(servicePath).href)); - const descriptorModule = - yield* Schema.decodeUnknownEffect(DescriptorModuleSchema)(descriptorSource); - const sharedApiModule = - yield* Schema.decodeUnknownEffect(SharedApiGeneratorSchema)(sharedApiSource); - const clientModule = - yield* Schema.decodeUnknownEffect(ApiClientGeneratorSchema)(clientSource); - const serviceModule = - yield* Schema.decodeUnknownEffect(ApiServiceGeneratorSchema)(serviceSource); + const descriptorModule = yield* Schema.decodeUnknownEffect(DescriptorModuleSchema)(descriptorSource); + const sharedApiModule = yield* Schema.decodeUnknownEffect(SharedApiGeneratorSchema)(sharedApiSource); + const clientModule = yield* Schema.decodeUnknownEffect(ApiClientGeneratorSchema)(clientSource); + const serviceModule = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorSchema)(serviceSource); const descriptor = yield* Schema.decodeUnknownEffect(WorkspaceAppFixtureSchema, { onExcessProperty: 'preserve', })(descriptorModule.createVerticalDescriptor(inventoryStockId, 4103), { onExcessProperty: 'preserve', }); const app = { ...descriptor, exposes: {} }; - const contract = sharedApiModule.createSharedApi(generatedProofScope, app); - const client = clientModule.createApiClient(app, generatedClientContractImport); - const service = serviceModule.createApiServiceEntry( - generatedProofScope, - app, - generatedSharedApiImport, - ); + const contract = sharedApiModule.createSharedApi(app, { + scope: generatedProofScope, + }); + const client = clientModule.createApiClient(app, generatedClientContractImport, { + scope: generatedProofScope, + }); + const service = serviceModule.createApiServiceEntry(app, generatedSharedApiImport, { + scope: generatedProofScope, + }); expect(contract, moduleFormat).toMatch(/MicroVerticalBuildMarkerSchema/u); expect(contract, moduleFormat).toMatch(/MicroVerticalReadinessSchema/u); @@ -2596,9 +2736,28 @@ it.live( expect(contract, moduleFormat).not.toMatch(/export interface OperationContext/u); expect(client, moduleFormat).toMatch(/client\.foundation\.readiness\(\{\}\)/u); expect(client, moduleFormat).not.toMatch(/client\.inventoryStock\.readiness/u); - expect(service, moduleFormat).toMatch(/microVerticalOperationAttributes/u); - expect(service, moduleFormat).toMatch(/@generated-proof\/shared-contracts/u); - expect(service, moduleFormat).not.toMatch(/const operationAttributes/u); + const spans = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + attributes: Schema.Record(Schema.String, Schema.String), + name: Schema.String, + }), + ), + ), + )(yield* evaluateScaffoldSemantics(service, 'service')); + expect(spans).toEqual( + ['readiness', 'list', 'get', 'create'].map((name) => ({ + attributes: { + 'modernjs.operation.id': name, + 'modernjs.operation.method': 'GET', + 'modernjs.operation.route': `/${name}`, + 'modernjs.operation.source': 'server', + 'modernjs.trace.id': `trace-${name}`, + }, + name: `ultramodern.api.inventoryStock.${name}`, + })), + ); const generatedBaselineExpectation = { additionalPaths: {}, apiPrefix: `/${inventoryStockId}-api`, @@ -2609,21 +2768,20 @@ it.live( sharedContractsPackage: generatedSharedContractsPackage, } as const; expect( - yield* microVerticalApiBaselineViolation( - inventoryStockId, - contract, - generatedBaselineExpectation, - ), + yield* microVerticalApiBaselineViolation(inventoryStockId, contract, generatedBaselineExpectation), ).toBe(undefined); const customStemApp = { ...app, - api: { ...app.api, prefix: warehouseApiPrefix, stem: warehouseItemsApiStem }, + api: { + ...app.api, + prefix: warehouseApiPrefix, + stem: warehouseItemsApiStem, + }, }; - const customStemContract = sharedApiModule.createSharedApi( - generatedProofScope, - customStemApp, - ); + const customStemContract = sharedApiModule.createSharedApi(customStemApp, { + scope: generatedProofScope, + }); expect( yield* microVerticalApiBaselineViolation(warehouseItemsApiStem, customStemContract, { ...generatedBaselineExpectation, @@ -2638,14 +2796,16 @@ it.live( })(descriptorModule.createVerticalDescriptor(checkoutId, 4105), { onExcessProperty: 'preserve', }); - const checkoutContract = sharedApiModule.createSharedApi(generatedProofScope, { - ...checkoutDescriptor, - exposes: {}, - }); - const checkoutClient = clientModule.createApiClient( - checkoutDescriptor, - generatedClientContractImport, + const checkoutContract = sharedApiModule.createSharedApi( + { + ...checkoutDescriptor, + exposes: {}, + }, + { scope: generatedProofScope }, ); + const checkoutClient = clientModule.createApiClient(checkoutDescriptor, generatedClientContractImport, { + scope: generatedProofScope, + }); expect( yield* microVerticalApiBaselineViolation(checkoutId, checkoutContract, { additionalPaths: { @@ -2680,12 +2840,23 @@ it.live( 'all published scaffold formats emit the executable AST baseline validator', Effect.fn(function* mergedScenario2() { const proofRoot = yield* makeProofRoot('generated-baseline-validator-proof'); - const expectedHelper = yield* Effect.promise(() => - readFile( - path.join(workspaceRoot, 'scripts/microvertical-api-baseline-boundary.mts'), - 'utf-8', - ), + const canonicalModule: unknown = yield* Effect.promise( + () => + import( + pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/workspace-scripts.js')).href + ), ); + const canonicalGenerator = Schema.decodeUnknownSync(WorkspaceScriptsGeneratorSchema)(canonicalModule); + const expectedHelper = canonicalGenerator + .migratedWorkspaceScriptArtifacts({ + hasBackendSurface: true, + shellOnly: false, + }) + .find(({ relativePath }) => relativePath === 'scripts/microvertical-api-baseline-boundary.mts')?.content; + expect(expectedHelper).toBeDefined(); + if (expectedHelper === undefined) { + throw new Error(EXPECTED_PROOF_VALUE); + } yield* Effect.all( ['esm', 'esm-node', 'cjs'].map( @@ -2705,12 +2876,9 @@ it.live( shellOnly: false, }); const helper = artifacts.find( - ({ relativePath }) => - relativePath === 'scripts/microvertical-api-baseline-boundary.mts', - ); - const checker = artifacts.find( - ({ relativePath }) => relativePath === apiBoundaryCheckerPath, + ({ relativePath }) => relativePath === 'scripts/microvertical-api-baseline-boundary.mts', ); + const checker = artifacts.find(({ relativePath }) => relativePath === apiBoundaryCheckerPath); expect(helper, `${moduleFormat} must emit the AST baseline helper`).toBeTruthy(); if (!helper) { throw new Error(EXPECTED_PROOF_VALUE); @@ -2719,89 +2887,102 @@ it.live( if (!checker) { throw new Error(EXPECTED_PROOF_VALUE); } - expect(helper.content, `${moduleFormat} must emit byte-exact baseline source`).toBe( - expectedHelper, - ); + expect(helper.content, `${moduleFormat} must emit byte-exact baseline source`).toBe(expectedHelper); expect( - yield* normalizedGeneratedSource( - 'microvertical-api-baseline-boundary.mts', - helper.content, - ), + yield* normalizedGeneratedSource('microvertical-api-baseline-boundary.mts', helper.content), `${moduleFormat} must emit the exact repository validator`, - ).toBe( - yield* normalizedGeneratedSource( - 'microvertical-api-baseline-boundary.mts', - expectedHelper, - ), - ); + ).toBe(yield* normalizedGeneratedSource('microvertical-api-baseline-boundary.mts', expectedHelper)); const formatRoot = path.join(proofRoot, moduleFormat); yield* writeText(formatRoot, helper.relativePath, helper.content); yield* writeText(formatRoot, checker.relativePath, checker.content); expect(checker.content).toMatch(/microVerticalApiBaselineViolation/u); - expect( - runNode([path.join(formatRoot, checker.relativePath)], { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - }), - moduleFormat, - ).toMatch(/UltraModern API boundary check passed/u); const descriptorSource: unknown = moduleFormat === 'cjs' ? require(publishedGeneratorModulePath(moduleFormat)('descriptors')) : yield* Effect.promise( - () => - import( - pathToFileURL(publishedGeneratorModulePath(moduleFormat)('descriptors')).href - ), + () => import(pathToFileURL(publishedGeneratorModulePath(moduleFormat)('descriptors')).href), ); const sharedApiSource: unknown = moduleFormat === 'cjs' ? require(publishedGeneratorModulePath(moduleFormat)(generatedSharedApiModule)) : yield* Effect.promise( () => - import( - pathToFileURL( - publishedGeneratorModulePath(moduleFormat)(generatedSharedApiModule), - ).href - ), + import(pathToFileURL(publishedGeneratorModulePath(moduleFormat)(generatedSharedApiModule)).href), ); - const descriptorModule = - Schema.decodeUnknownSync(DescriptorModuleSchema)(descriptorSource); - const sharedApiModule = - Schema.decodeUnknownSync(SharedApiGeneratorSchema)(sharedApiSource); + const descriptorModule = Schema.decodeUnknownSync(DescriptorModuleSchema)(descriptorSource); + const sharedApiModule = Schema.decodeUnknownSync(SharedApiGeneratorSchema)(sharedApiSource); const checkoutDescriptor = descriptorModule.createVerticalDescriptor('shopping', 4105); const checkoutStemDescriptor = { ...checkoutDescriptor, api: { prefix: checkoutApiPrefix, stem: checkoutId }, exposes: {}, }; - const servicePath = publishedGeneratorModulePath(moduleFormat)( - generatedServiceModuleName, - ); + const servicePath = publishedGeneratorModulePath(moduleFormat)(generatedServiceModuleName); const serviceSource: unknown = moduleFormat === 'cjs' ? require(servicePath) : yield* Effect.promise(() => import(pathToFileURL(servicePath).href)); const serviceModule = Schema.decodeUnknownSync(ApiServiceGeneratorSchema)(serviceSource); const checkoutWorkspace = path.join(formatRoot, 'checkout-workspace'); + yield* writeJson(checkoutWorkspace, ultramodernConfigFile, { + topology: { + apps: [ + { + ...checkoutStemDescriptor, + kind: 'vertical', + package: '@generated-proof/shopping', + path: shoppingDirectory, + }, + { + id: 'shell-super-app', + kind: 'shell', + path: 'apps/shell-super-app', + }, + ], + }, + }); + yield* writeText( + checkoutWorkspace, + 'packages/shared-contracts/src/microvertical-api-baseline.ts', + yield* Effect.promise(() => + readFile(path.join(generatorRoot, 'templates/packages/microvertical-api-baseline.ts'), 'utf-8'), + ), + ); + yield* writeText( checkoutWorkspace, 'verticals/shopping/shared/api.ts', - sharedApiModule.createSharedApi(generatedProofScope, checkoutStemDescriptor), + sharedApiModule.createSharedApi(checkoutStemDescriptor, { + scope: generatedProofScope, + }), ); yield* writeText( checkoutWorkspace, 'verticals/shopping/api/index.ts', - serviceModule.createApiServiceEntry( - generatedProofScope, - checkoutStemDescriptor, - generatedSharedApiImport, - ), + serviceModule.createApiServiceEntry(checkoutStemDescriptor, generatedSharedApiImport, { + scope: generatedProofScope, + }), ); + const clientModule: unknown = yield* Effect.promise( + () => import(pathToFileURL(publishedGeneratorModulePath(moduleFormat)('api/client')).href), + ); + const clientGenerator = Schema.decodeUnknownSync(ApiClientGeneratorSchema)(clientModule); yield* writeText( checkoutWorkspace, 'verticals/shopping/src/api/checkout-client.ts', - 'export const checkoutClient = true;\n', + clientGenerator.createApiClient(checkoutStemDescriptor, '../../shared/api', { + scope: generatedProofScope, + }), + ); + const fixtureScope = path.join(checkoutWorkspace, 'node_modules', '@generated-proof'); + yield* Effect.promise(() => mkdir(fixtureScope, { recursive: true })); + yield* Effect.promise(() => + symlink( + path.join(checkoutWorkspace, 'packages/shared-contracts'), + path.join(fixtureScope, 'shared-contracts'), + 'dir', + ), ); yield* writeText( checkoutWorkspace, @@ -2821,6 +3002,9 @@ it.live( }, }); yield* writeJson(checkoutWorkspace, 'packages/shared-contracts/package.json', { + exports: { + './microvertical-api-baseline': './src/microvertical-api-baseline.ts', + }, name: generatedSharedContractsPackage, }); yield* writeJson(checkoutWorkspace, topologyReferencePath, { @@ -2828,35 +3012,30 @@ it.live( { api: { basePath: '/checkout-api/checkout', - bff: { prefix: checkoutApiPrefix, strictEffectApproach: true }, + bff: { + prefix: checkoutApiPrefix, + strictEffectApproach: true, + }, readiness: { endpoint: '/checkout/readiness' }, runtime: 'effect', serverEntry: 'verticals/shopping/api/index.ts', }, id: 'shopping', - path: 'verticals/shopping', + path: shoppingDirectory, }, ], }); yield* writeText( checkoutWorkspace, 'apps/shell-super-app/src/api/vertical-clients.ts', - 'export const verticalClients = {};\n', + "export { getCheckoutReadiness } from '@generated-proof/shopping/api/client';\n", ); const invalidApiSource = `import { Schema } from 'effect'; export const response = new Response('generated'); export const responseSchema = Schema.Unknown; `; - yield* writeText( - checkoutWorkspace, - 'verticals/shopping/dist-cloudflare/api/index.js', - invalidApiSource, - ); - yield* writeText( - checkoutWorkspace, - 'apps/shell-super-app/dist-cloudflare/api/index.js', - invalidApiSource, - ); + yield* writeText(checkoutWorkspace, 'verticals/shopping/dist-cloudflare/api/index.js', invalidApiSource); + yield* writeText(checkoutWorkspace, 'apps/shell-super-app/dist-cloudflare/api/index.js', invalidApiSource); expect( runNode([path.join(formatRoot, checker.relativePath)], { env: { ULTRAMODERN_WORKSPACE_ROOT: checkoutWorkspace }, @@ -2865,14 +3044,10 @@ export const responseSchema = Schema.Unknown; ).toMatch(/UltraModern API boundary check passed/u); const authoredApiPath = 'verticals/shopping/api/invalid.ts'; yield* writeText(checkoutWorkspace, authoredApiPath, invalidApiSource); - const authoredResult = spawnSync( - process.execPath, - [path.join(formatRoot, checker.relativePath)], - { - encoding: 'utf-8', - env: { ULTRAMODERN_WORKSPACE_ROOT: checkoutWorkspace }, - }, - ); + const authoredResult = spawnSync(process.execPath, [path.join(formatRoot, checker.relativePath)], { + encoding: 'utf-8', + env: { ULTRAMODERN_WORKSPACE_ROOT: checkoutWorkspace }, + }); expect(authoredResult.status, moduleFormat).toBe(1); expect(authoredResult.stderr, moduleFormat).toMatch( /verticals\/shopping\/api\/invalid\.ts: API modules must not hand-build Response objects/u, @@ -2892,52 +3067,26 @@ it.live( 'two generated MicroVertical root contracts execute invariant readiness endpoints', Effect.fn(function* governanceScenario12() { const proofRoot = yield* Effect.acquireRelease( - Effect.promise(() => - mkdtemp(path.join(workspaceRoot, `verticals/${partyId}/.generated-api-baseline-`)), - ), + Effect.promise(() => mkdtemp(path.join(workspaceRoot, `verticals/${partyId}/.generated-api-baseline-`))), (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); const descriptorSource: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/descriptors.js'), - ).href - ), + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/descriptors.js')).href), ); const sharedApiSource: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/shared.js'), - ).href - ), + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/shared.js')).href), ); const apiServiceSource: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/service.js'), - ).href - ), + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/service.js')).href), ); const apiClientSource: unknown = yield* Effect.promise( - () => - import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/client.js'), - ).href - ), + () => import(pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/api/client.js')).href), ); - const descriptorModule = - yield* Schema.decodeUnknownEffect(DescriptorModuleSchema)(descriptorSource); - const sharedApiModule = - yield* Schema.decodeUnknownEffect(SharedApiGeneratorSchema)(sharedApiSource); - const apiServiceModule = - yield* Schema.decodeUnknownEffect(ApiServiceGeneratorSchema)(apiServiceSource); - const apiClientModule = - yield* Schema.decodeUnknownEffect(ApiClientGeneratorSchema)(apiClientSource); + const descriptorModule = yield* Schema.decodeUnknownEffect(DescriptorModuleSchema)(descriptorSource); + const sharedApiModule = yield* Schema.decodeUnknownEffect(SharedApiGeneratorSchema)(sharedApiSource); + const apiServiceModule = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorSchema)(apiServiceSource); + const apiClientModule = yield* Schema.decodeUnknownEffect(ApiClientGeneratorSchema)(apiClientSource); const fixtures = [ { id: inventoryStockId, @@ -2973,15 +3122,18 @@ it.live( expect(descriptor.api).toBeTruthy(); const generatedDescriptor = { ...descriptor, - api: { ...descriptor.api, prefix: fixture.prefix, stem: fixture.stem }, + api: { + ...descriptor.api, + prefix: fixture.prefix, + stem: fixture.stem, + }, exposes: {}, }; - const contract = sharedApiModule.createSharedApi('app', generatedDescriptor); + const contract = sharedApiModule.createSharedApi(generatedDescriptor, { scope: 'app' }); const basePath = `${fixture.prefix}/${fixture.stem}`; expect( yield* microVerticalApiBaselineViolation(fixture.stem, contract, { - additionalPaths: - fixture.stem === checkoutId ? { checkoutCartPath: `${basePath}/cart` } : {}, + additionalPaths: fixture.stem === checkoutId ? { checkoutCartPath: `${basePath}/cart` } : {}, apiPrefix: fixture.prefix, basePath, effectClientPackage, @@ -3013,17 +3165,17 @@ it.live( yield* writeText( ownerRoot, apiIndexFile, - apiServiceModule.createApiServiceEntry( - 'app', - generatedDescriptor, - generatedSharedApiImport, - ), + apiServiceModule.createApiServiceEntry(generatedDescriptor, generatedSharedApiImport, { + scope: 'app', + }), ); const clientEntryPath = `src/api/${fixture.id}-client.ts`; yield* writeText( ownerRoot, clientEntryPath, - apiClientModule.createApiClient(generatedDescriptor, generatedClientContractImport), + apiClientModule.createApiClient(generatedDescriptor, generatedClientContractImport, { + scope: 'app', + }), ); yield* writeJson(ownerRoot, 'package.json', { type: 'module' }); yield* writeJson(ownerRoot, tsconfigFile, { @@ -3032,7 +3184,7 @@ it.live( module: 'NodeNext', moduleResolution: 'NodeNext', noEmit: true, - skipLibCheck: true, + skipLibCheck: false, strict: true, target: 'ES2023', }, @@ -3040,7 +3192,7 @@ it.live( }); runNode( [ - path.join(workspaceRoot, 'node_modules/@typescript/native-preview/bin/tsc'), + path.join(path.dirname(require.resolve('@typescript/native-preview/package.json')), 'bin/tsgo'), '-p', ownerRoot, ], @@ -3052,12 +3204,12 @@ it.live( const generatedClientSource: unknown = yield* Effect.promise( () => import(pathToFileURL(path.join(ownerRoot, clientEntryPath)).href), ); - const generatedModule = yield* Schema.decodeUnknownEffect( - GeneratedApiRuntimeModuleSchema, - )(generatedModuleSource); - const generatedClient = yield* Schema.decodeUnknownEffect( - Schema.Record(Schema.String, Schema.Unknown), - )(generatedClientSource); + const generatedModule = yield* Schema.decodeUnknownEffect(GeneratedApiRuntimeModuleSchema)( + generatedModuleSource, + ); + const generatedClient = yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown))( + generatedClientSource, + ); const getReadiness = yield* Schema.decodeUnknownEffect(callable())( generatedClient[fixture.readinessExport], ); @@ -3129,20 +3281,18 @@ it.live( Effect.fn(function* mergedScenario1() { const proofRoot = yield* makeProofRoot('generated-baseline-template-proof'); const templateSource = yield* Effect.promise(() => - readFile(path.join(generatorRoot, 'templates/packages/shared-contracts-index.ts'), 'utf-8'), + readFile(path.join(generatorRoot, 'templates/packages/microvertical-api-baseline.ts'), 'utf-8'), ); - const baselineEnd = templateSource.indexOf( - 'export type UltramodernPublicSitemapChangeFrequency', - ); - expect(baselineEnd).not.toBe(-1); - const generatedSource = templateSource.slice(0, baselineEnd); + const generatedSource = templateSource; + expect(generatedSource).toMatch(/export const MicroVerticalReadinessSchema/u); yield* writeText(proofRoot, generatedBaselineTemplateEntry, generatedSource); yield* writeJson(proofRoot, tsconfigFile, { compilerOptions: { + lib: ['ES2023', 'DOM', 'ESNext.Disposable'], module: 'NodeNext', moduleResolution: 'NodeNext', noEmit: true, - skipLibCheck: true, + skipLibCheck: false, strict: true, target: 'ES2022', }, @@ -3154,7 +3304,7 @@ it.live( }); runNode( [ - path.join(workspaceRoot, 'node_modules/@typescript/native-preview/bin/tsc'), + path.join(path.dirname(require.resolve('@typescript/native-preview/package.json')), 'bin/tsgo'), '-p', proofRoot, ], @@ -3170,11 +3320,7 @@ it.live( readFile(path.join(workspaceRoot, `verticals/${partyId}/shared/api.ts`), 'utf-8'), ); expect(yield* microVerticalApiBaselineViolation(partyId, contract)).toBe(undefined); - for (const declaration of [ - 'import type ', - 'import type/* comment */ ', - 'import /* comment */ ', - ]) { + for (const declaration of ['import type ', 'import type/* comment */ ', 'import /* comment */ ']) { const mutated = contract.replace( 'import {\n MicroVerticalBuildMarkerSchema,', `${declaration}{\n MicroVerticalBuildMarkerSchema,`, @@ -3203,14 +3349,10 @@ it.live( yield* Effect.promise(() => import(pathToFileURL(generatorModulePath('descriptors')).href)), ); const shared = yield* Schema.decodeUnknownEffect(SharedApiGeneratorSchema)( - yield* Effect.promise( - () => import(pathToFileURL(generatorModulePath(generatedSharedApiModule)).href), - ), + yield* Effect.promise(() => import(pathToFileURL(generatorModulePath(generatedSharedApiModule)).href)), ); const service = yield* Schema.decodeUnknownEffect(ApiServiceGeneratorSchema)( - yield* Effect.promise( - () => import(pathToFileURL(generatorModulePath(generatedServiceModuleName)).href), - ), + yield* Effect.promise(() => import(pathToFileURL(generatorModulePath(generatedServiceModuleName)).href)), ); const app = { ...descriptors.createVerticalDescriptor(inventoryStockId, 4103), @@ -3219,7 +3361,7 @@ it.live( }; const ownerPath = `verticals/${inventoryStockId}`; const readinessContract = shared - .createSharedApi('app', app) + .createSharedApi(app, { scope: 'app' }) .replace( /(?\.addHttpApi\(warehouseItemsFoundationApi\))[\s\S]*?(?=export const warehouseItemsOperationContexts)/u, '$;\n\n', @@ -3232,13 +3374,11 @@ it.live( yield* writeText( fixture, `${ownerPath}/api/index.ts`, - service.createApiServiceEntry('app', app, generatedSharedApiImport), - ); - yield* writeText( - fixture, - `${ownerPath}/src/api/warehouse-client.ts`, - 'export const client = true;\n', + service.createApiServiceEntry(app, generatedSharedApiImport, { + scope: 'app', + }), ); + yield* writeText(fixture, `${ownerPath}/src/api/warehouse-client.ts`, 'export const client = true;\n'); yield* writeJson(fixture, `${ownerPath}/package.json`, { exports: {} }); const api = { basePath: '/warehouse-api/warehouse-items', @@ -3255,7 +3395,10 @@ it.live( }); expect(check()).toMatch(/UltraModern API boundary check passed/u); const cases = [ - { expected: /topology must declare this MicroVertical owner/u, verticals: [] }, + { + expected: /topology must declare this MicroVertical owner/u, + verticals: [], + }, { expected: /topology must declare api\.basePath/u, verticals: [ @@ -3276,15 +3419,13 @@ it.live( }, ]; for (const entry of cases) { - yield* writeJson(fixture, topologyReferencePath, { verticals: entry.verticals }); - const result = spawnSync( - process.execPath, - [path.join(workspaceRoot, apiBoundaryCheckerPath)], - { - encoding: 'utf-8', - env: { ULTRAMODERN_WORKSPACE_ROOT: fixture }, - }, - ); + yield* writeJson(fixture, topologyReferencePath, { + verticals: entry.verticals, + }); + const result = spawnSync(process.execPath, [path.join(workspaceRoot, apiBoundaryCheckerPath)], { + encoding: 'utf-8', + env: { ULTRAMODERN_WORKSPACE_ROOT: fixture }, + }); expect(result.status).toBe(1); expect(result.stderr).toMatch(entry.expected); expect(result.stderr).not.toMatch(/exact owner and API path metadata/u); @@ -3333,10 +3474,7 @@ it.live( ], [ 'Effect API primitives imported from a foreign client', - contract.replace( - "from '@modern-js/plugin-bff/effect-client';", - "from '@evil/fake-effect-client';", - ), + contract.replace("from '@modern-js/plugin-bff/effect-client';", "from '@evil/fake-effect-client';"), /import exact Effect API primitives from the framework client package/u, ], [ @@ -3353,10 +3491,7 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu 'renamed readiness endpoint with a decoy API name', contract .replace("HttpApiEndpoint.get('readiness'", "HttpApiEndpoint.get('health'") - .replace( - "HttpApi.make('PartyRegistryFoundationApi')", - `HttpApi.make("${readinessEndpointDecoy}")`, - ), + .replace("HttpApi.make('PartyRegistryFoundationApi')", `HttpApi.make("${readinessEndpointDecoy}")`), /exact readiness endpoint/u, ], [ @@ -3376,10 +3511,8 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu [ 'foundation endpoint discarded by a pipe', contract.replace( - ` ), -);`, - ` ), -).pipe(() => HttpApi.make('DiscardedPartyRegistryFoundationApi'));`, + /(?export const partyRegistryFoundationApi = [\s\S]*?);/u, + "$.pipe(() => HttpApi.make('DiscardedPartyRegistryFoundationApi'));", ), /directly compose its exact readiness endpoint/u, ], @@ -3387,10 +3520,7 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu 'missing foundation composition with a decoy API name', contract .replace(foundationComposition, '') - .replace( - "HttpApi.make('PartyRegistryApi')", - "HttpApi.make('.addHttpApi(partyRegistryFoundationApi)')", - ), + .replace("HttpApi.make('PartyRegistryApi')", "HttpApi.make('.addHttpApi(partyRegistryFoundationApi)')"), /explicitly compose its readiness foundation API/u, ], [ @@ -3501,18 +3631,12 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu ], [ 'renamed operation contexts', - contract.replace( - 'export const partyRegistryOperationContexts =', - 'export const renamedOperationContexts =', - ), + contract.replace('export const partyRegistryOperationContexts =', 'export const renamedOperationContexts ='), /construct every operation with the shared context constructor/u, ], [ 'foreign operation id', - contract.replace( - "operationId: 'PartyRegistryApi:/reads/ares-lookup'", - "operationId: 'WrongApi:unrelated'", - ), + contract.replace("operationId: 'PartyRegistryApi:/reads/ares-lookup'", "operationId: 'WrongApi:unrelated'"), /construct every operation with the shared context constructor/u, ], [ @@ -3552,11 +3676,7 @@ createMicroVerticalOperationContext({ contract.replace(" basePath: '/party-registry-api/party-registry',\n", ''), /exact owner and API path metadata/u, ], - [ - 'missing ownerId', - contract.replace(" ownerId: 'party-registry',\n", ''), - /exact owner and API path metadata/u, - ], + ['missing ownerId', contract.replace(" ownerId: 'party-registry',\n", ''), /exact owner and API path metadata/u], [ 'wrong apiPrefix', contract.replace("apiPrefix: '/party-registry-api'", "apiPrefix: '/evil-api'"), @@ -3564,10 +3684,7 @@ createMicroVerticalOperationContext({ ], [ 'wrong basePath', - contract.replace( - "basePath: '/party-registry-api/party-registry'", - "basePath: '/party-registry-api/evil'", - ), + contract.replace("basePath: '/party-registry-api/party-registry'", "basePath: '/party-registry-api/evil'"), /exact owner and API path metadata/u, ], [ @@ -3587,10 +3704,7 @@ createMicroVerticalOperationContext({ 'coordinated topology drift', contract .replace("apiPrefix: '/party-registry-api'", "apiPrefix: '/evil-api'") - .replace( - "basePath: '/party-registry-api/party-registry'", - "basePath: '/evil-api/party-registry'", - ) + .replace("basePath: '/party-registry-api/party-registry'", "basePath: '/evil-api/party-registry'") .replace( "readinessPath: '/party-registry-api/party-registry/readiness'", "readinessPath: '/evil-api/party-registry/readiness'", @@ -3599,10 +3713,7 @@ createMicroVerticalOperationContext({ ], [ 'forbidden credential metadata', - contract.replace( - partyReadinessMetadataLine, - `${partyReadinessMetadataLine}\n credential: 'secret',`, - ), + contract.replace(partyReadinessMetadataLine, `${partyReadinessMetadataLine}\n credential: 'secret',`), /exact owner and API path metadata/u, ], [ @@ -3630,9 +3741,8 @@ createMicroVerticalOperationContext({ /exact owner and API path metadata/u, ], ] as const) { - expect((yield* microVerticalApiBaselineViolation(partyId, mutated)) ?? '', label).toMatch( - expected, - ); + expect(mutated, `${label} must mutate the fixture`).not.toBe(contract); + expect((yield* microVerticalApiBaselineViolation(partyId, mutated)) ?? '', label).toMatch(expected); } }), ); @@ -3668,9 +3778,7 @@ it.live( ); const cloudflareProofModule: unknown = await import( - pathToFileURL( - path.join(generatorRoot, 'templates/workspace-scripts/ultramodern-cloudflare-proof.mjs'), - ).href + pathToFileURL(path.join(generatorRoot, 'templates/workspace-scripts/ultramodern-cloudflare-proof.mjs')).href ); const validateApp = ( @@ -3683,10 +3791,7 @@ const validateApp = ( Schema.Struct({ validateApp: callable< - ( - fixture: ApiOnlyAppFixture, - publicUrl: string, - ) => Promise + (fixture: ApiOnlyAppFixture, publicUrl: string) => Promise >(), }), )(cloudflareProofModule); @@ -3696,9 +3801,7 @@ const validateApp = ( }); const federationValidationModule: unknown = await import( - pathToFileURL( - path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/mf-validation/validate.js'), - ).href + pathToFileURL(path.join(generatorRoot, 'dist/esm-node/ultramodern-workspace/mf-validation/validate.js')).href ); const federationValidation = Schema.decodeUnknownSync(ModuleFederationValidationModuleSchema)( @@ -3721,7 +3824,10 @@ const publicUrl = 'https://party.example.test'; const buildMarker = 'party-build'; interface ApiOnlyAppFixture { - readonly deliveryUnit: { readonly buildMarker: string; readonly unitId: string }; + readonly deliveryUnit: { + readonly buildMarker: string; + readonly unitId: string; + }; readonly deploy: { readonly cloudflare: { readonly jsonSmokeChecks: readonly object[]; @@ -3778,9 +3884,7 @@ const mockPublicResponses = (failedPath?: string) => { route === mfManifestPath ? { metaData: { publicPath: `${publicUrl}/` } } : { marker: { build: buildMarker }, status: 'ready' }; - return Promise.resolve( - Response.json(body, { headers: { 'access-control-allow-origin': '*' } }), - ); + return Promise.resolve(Response.json(body, { headers: { 'access-control-allow-origin': '*' } })); }); return requested; }; @@ -3798,13 +3902,9 @@ it.live( 'service-binding-api-marker', 'json-smoke-value', ]) { - expect( - evidence.assertions.some((entry) => entry.type === proof && entry.status === 'pass'), - ).toBe(true); + expect(evidence.assertions.some((entry) => entry.type === proof && entry.status === 'pass')).toBe(true); } - expect( - evidence.assertions.some((entry) => entry.type === 'ssr' || entry.type === 'i18n-marker'), - ).toBe(false); + expect(evidence.assertions.some((entry) => entry.type === 'ssr' || entry.type === 'i18n-marker')).toBe(false); }), ); @@ -3818,9 +3918,7 @@ for (const [route, error] of [ `API-only proof still fails closed for ${route}`, Effect.fn(function* scenario30() { mockPublicResponses(route); - const failureCause8 = yield* Effect.flip( - Effect.sandbox(validateApp(apiOnlyApp(), publicUrl)), - ); + const failureCause8 = yield* Effect.flip(Effect.sandbox(validateApp(apiOnlyApp(), publicUrl))); expect(String(Cause.squash(failureCause8))).toMatch(error); }), ); @@ -3861,9 +3959,7 @@ for (const field of ['ssr', 'locale']) { const app = apiOnlyApp(); Object.assign(app.deploy.cloudflare.routes, { [field]: '' }); const failureCause11 = yield* Effect.flip(Effect.sandbox(validateApp(app, publicUrl))); - expect(String(Cause.squash(failureCause11))).toMatch( - /declared .* route must be a root-relative path/u, - ); + expect(String(Cause.squash(failureCause11))).toMatch(/declared .* route must be a root-relative path/u); }), ); } @@ -3877,16 +3973,11 @@ for (const variant of ['cjs', 'esm', 'esm-node']) { () => import( pathToFileURL( - path.join( - generatorRoot, - `dist/${variant}/ultramodern-workspace/mf-validation/inspect.${extension}`, - ), + path.join(generatorRoot, `dist/${variant}/ultramodern-workspace/mf-validation/inspect.${extension}`), ).href ), ); - const inspection = yield* Schema.decodeUnknownEffect(ModuleFederationInspectionModuleSchema)( - inspectionModule, - ); + const inspection = yield* Schema.decodeUnknownEffect(ModuleFederationInspectionModuleSchema)(inspectionModule); const inspectInstalledModuleFederationConfig = inspection.inspectModuleFederationConfigSource.bind(inspectionModule); const inspect = (source: string): typeof ModuleFederationInspectionSchema.Type => { @@ -3897,12 +3988,10 @@ for (const variant of ['cjs', 'esm', 'esm-node']) { ); return Schema.decodeUnknownSync(ModuleFederationInspectionSchema)(output); }; - expect( - inspect('// @ultramodern-mf no-exposes\nexport default { dts: false, exposes: {} };').dts, - ).toEqual({}); - expect(() => - inspect('export default { dts: false, exposes: { "./Page": "./page.tsx" } };'), - ).toThrow(/DTS cannot be disabled for exposed app/u); + expect(inspect('// @ultramodern-mf no-exposes\nexport default { dts: false, exposes: {} };').dts).toEqual({}); + expect(() => inspect('export default { dts: false, exposes: { "./Page": "./page.tsx" } };')).toThrow( + /DTS cannot be disabled for exposed app/u, + ); }), ); } @@ -3918,17 +4007,15 @@ it.live( yield* Effect.promise(() => mkdir(path.join(fixture, appDir), { recursive: true })); const configPath = path.join(fixture, appDir, 'module-federation.config.ts'); const validate = () => - validateModuleFederationTypes({ appDirs: [appDir], workspaceRoot: fixture }); + validateModuleFederationTypes({ + appDirs: [appDir], + workspaceRoot: fixture, + }); yield* Effect.promise(() => - writeFile( - configPath, - '// @ultramodern-mf no-exposes\nexport default { dts: false, exposes: {} };', - ), + writeFile(configPath, '// @ultramodern-mf no-exposes\nexport default { dts: false, exposes: {} };'), ); expect(validate().hostOnlyAppCount).toBe(1); - yield* Effect.promise(() => - writeFile(configPath, 'export default { dts: false, exposes: {} };'), - ); + yield* Effect.promise(() => writeFile(configPath, 'export default { dts: false, exposes: {} };')); expect(validate).toThrow(/without an explicit host-only\/no-exposes declaration/u); yield* Effect.promise(() => writeFile( @@ -3943,10 +4030,7 @@ it.live( it.live( 'Party deployment declares no fake SSR/locale URL while retaining backend contracts', Effect.fn(function* scenario36() { - const topology = yield* readJson( - TopologySchema, - path.join(workspaceRoot, topologyReferencePath), - ); + const topology = yield* readJson(TopologySchema, path.join(workspaceRoot, topologyReferencePath)); const party = topology.verticals.find((entry) => entry.id === partyId); expect(party).toBeDefined(); if (!party) { @@ -3956,29 +4040,20 @@ it.live( expect(party.cloudflare.routes.locale).toBe(undefined); expect(party.cloudflare.routes.mfManifest).toBe(mfManifestPath); expect(party.cloudflare.routes.apiReadiness).toBe(readinessPath); - expect(party.backendFederation.exposes['./effect-api'].contract).toBe( - 'verticals/party-registry/shared/api.ts', - ); - expect(party.backendFederation.exposes['./effect-api'].openapi).toBe( - '/party-registry-api/openapi.json', - ); + expect(party.backendFederation.exposes['./effect-api'].contract).toBe('verticals/party-registry/shared/api.ts'); + expect(party.backendFederation.exposes['./effect-api'].openapi).toBe('/party-registry-api/openapi.json'); }), ); it.live( 'Party Registry is the sole deployment owner for Contacts capabilities', Effect.fn(function* scenario37() { - const topology = yield* readJson( - TopologySchema, - path.join(workspaceRoot, topologyReferencePath), - ); + const topology = yield* readJson(TopologySchema, path.join(workspaceRoot, topologyReferencePath)); const overlay = yield* readJson( OverlaySchema, path.join(workspaceRoot, 'topology/local-overlays/development.json'), ); - const zerops = yield* Effect.promise(() => - readFile(path.join(workspaceRoot, 'zerops.yaml'), 'utf-8'), - ); + const zerops = yield* Effect.promise(() => readFile(path.join(workspaceRoot, 'zerops.yaml'), 'utf-8')); const partySetup = zerops.split(` - setup: '${partyId}'`)[1]?.split(' - setup:')[0]; expect(partySetup).toBeDefined(); if (!partySetup) { @@ -4006,12 +4081,8 @@ it.live( (dir) => Effect.promise(() => rm(dir, { force: true, recursive: true })), ); yield* Effect.promise(() => mkdir(path.join(fixture, '.modernjs'))); - const modernConfig = yield* Effect.promise(() => - readFile(path.join(workspaceRoot, '.modernjs/ultramodern.json')), - ); - yield* Effect.promise(() => - writeFile(path.join(fixture, '.modernjs/ultramodern.json'), modernConfig), - ); + const modernConfig = yield* Effect.promise(() => readFile(path.join(workspaceRoot, ultramodernConfigFile))); + yield* Effect.promise(() => writeFile(path.join(fixture, ultramodernConfigFile), modernConfig)); const build = yield* readJson( BuildArtifactSchema, path.join(workspaceRoot, 'verticals/party-registry/shared/ultramodern-build.json'), @@ -4095,9 +4166,7 @@ it('proves generated Layer bindings and API aliases without accepting unused nei expect(strictEffectRuntimeTopologyViolation(source, resolveImport)).toBe(undefined); for (const [before, after] of governedLayerAliasMutations) { expect(source.includes(before)).toBeTruthy(); - expect( - strictEffectRuntimeTopologyViolation(source.replace(before, after), resolveImport), - ).not.toBe(undefined); + expect(strictEffectRuntimeTopologyViolation(source.replace(before, after), resolveImport)).not.toBe(undefined); } }); @@ -4133,3 +4202,254 @@ type GeneratedHttpHandler = ReturnType['creat afterEach(() => { rs.restoreAllMocks(); }); + +// Consumer adaptation is compared by governed semantics, not generated byte equality. +describe('consumer migration preserves native tooling and governed safety', () => { + const source = (relativePath: string) => + Effect.promise(() => readFile(path.join(workspaceRoot, relativePath), 'utf-8')); + it.live( + 'authenticated cohort and scoped release-age policy remain pinned', + Effect.fn(function* consumerScenario() { + const releaseVersion = '3.9.0-ultramodern.4'; + const cohort = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + aliases: Schema.Record(Schema.String, Schema.String), + packages: Schema.Array( + Schema.Struct({ + sourceName: Schema.String, + targetName: Schema.String, + version: Schema.Literal(releaseVersion), + }), + ), + release: Schema.Struct({ version: Schema.Literal(releaseVersion) }), + source: Schema.Struct({ + commit: Schema.Literal('ef99279246046685f1684c59ca145f2a6a3f9d53'), + }), + }), + ), + )(yield* source('.modernjs/release-cohort.json')); + expect(cohort.aliases['@modern-js/ultramodern-create']).toBe('@bleedingdev/modern-js-ultramodern-create'); + expect(cohort.aliases['@modern-js/create']).toBe(undefined); + expect(new Set(cohort.packages.map((entry) => entry.sourceName)).size).toBe(cohort.packages.length); + for (const entry of cohort.packages) { + expect(cohort.aliases[entry.sourceName]).toBe(entry.targetName); + } + const workspace = yield* source('pnpm-workspace.yaml'); + for (const line of [ + 'minimumReleaseAge: 1440', + 'minimumReleaseAgeStrict: true', + 'minimumReleaseAgeIgnoreMissingTime: false', + ]) { + expect(workspace.split('\n').filter((candidate) => candidate === line).length).toBe(1); + } + const exclusions = /^minimumReleaseAgeExclude:\n(?(?:[ \t]+[^\n]*\n)*)/mu.exec(workspace)?.groups + ?.entries; + expect(exclusions !== undefined && exclusions.length > 0).toBeTruthy(); + if (exclusions === undefined) { + throw new Error('Missing release-age exclusions'); + } + const allowed = new Set(cohort.packages.map((entry) => `${entry.targetName}@${entry.version}`)); + const declared = exclusions + .trim() + .split('\n') + .map((line) => line.trim().replaceAll(/^-\s*['"]?|['"]$/gu, '')); + expect(declared.length > 0).toBeTruthy(); + for (const entry of declared) { + expect( + allowed.has(entry), + `Release-age exception must name an exact authenticated package: ${entry}`, + ).toBeTruthy(); + } + const validator = yield* source('scripts/validate-ultramodern-workspace.mts'); + expect(validator).toMatch(/authenticated release cohort projection/u); + expect(validator.includes(cohort.source.commit)).toBeTruthy(); + expect(validator).not.toMatch(/['"]@modern-js\/create['"]/u); + }), + ); + it.live( + 'current generator handoff preserves arguments and nonzero failures', + Effect.fn(function* consumerScenario() { + const scratchRoot = path.join(workspaceRoot, '.scratch'); + yield* Effect.promise(() => mkdir(scratchRoot, { recursive: true })); + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(scratchRoot, 'consumer-migration-'))), + (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), + ); + { + const executable = path.join(fixture, 'generator.mjs'); + yield* Effect.promise(() => + writeFile( + executable, + `process.stdout.write(JSON.stringify({ args: process.argv.slice(2), root: process.env.ULTRAMODERN_WORKSPACE_ROOT })); process.exitCode = 37;`, + ), + ); + const wrappers = [ + ['migrate-strict-effect.mts', 'migrate-strict-effect'], + ['ultramodern-typecheck.mts', 'typecheck'], + ] as const; + const wrapperSources = yield* Effect.forEach(wrappers, ([file]) => source(`scripts/${file}`), { + concurrency: 1, + }); + const runner = yield* source('scripts/shared/ultramodern-command.mts'); + const commandFailure = yield* source('scripts/ultramodern-command-failure.mts'); + expect(runner).toMatch(/'ultramodern-create'/u); + expect(runner).not.toMatch(/['"]modern-js-create['"]/u); + expect(commandFailure).toMatch(/Schema\.TaggedError/u); + for (const [index, [file, command]] of wrappers.entries()) { + const script = wrapperSources[index] ?? ''; + expect(script).toMatch(/runUltramodernScript/u); + expect(script).not.toMatch(/['"]modern-js-create['"]/u); + expect(script).toMatch(/Effect\.runPromiseExit/u); + const result = spawnSync( + process.execPath, + [path.join(workspaceRoot, 'scripts', file), '--fixture-argument'], + { + cwd: fixture, + encoding: 'utf-8', + env: { + ULTRAMODERN_CREATE_BIN: executable, + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }, + }, + ); + expect(result.status, result.stderr).toBe(37); + expect(JSON.parse(result.stdout)).toEqual({ + args: ['ultramodern', command, '--fixture-argument'], + root: fixture, + }); + const missing = spawnSync(process.execPath, [path.join(workspaceRoot, 'scripts', file)], { + cwd: fixture, + encoding: 'utf-8', + env: { + PATH: fixture, + ULTRAMODERN_CREATE_BIN: '', + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }, + }); + expect(missing.status).toBe(1); + expect(missing.stdout + missing.stderr).toMatch(/Failed to launch ultramodern-create from PATH/u); + } + } + }), + ); + it.live( + 'native route, isolated materialization and workerd adaptations survive', + Effect.fn(function* consumerScenario() { + const files = ['generate-tanstack-routes.mts', 'materialize-zerops-runtime.mjs', 'proof-workerd-ssr.mts']; + const scripts = yield* Effect.forEach(files, (file) => source(`scripts/${file}`), { + concurrency: 1, + }); + for (const [index, file] of files.entries()) { + const script = scripts[index] ?? ''; + expect(script, file).toMatch(/Effect\.gen/u); + expect(script, file).toMatch(/FileSystem/u); + expect(script, file).not.toMatch(/import\s*\{[^}]*spawnSync[^}]*\}\s*from\s*['"]node:child_process/u); + expect(script, file).not.toMatch(/['"]modern-js-create['"]/u); + } + const materializer = yield* source('scripts/materialize-zerops-runtime.mjs'); + expect(materializer).toMatch(/Flag\.boolean\('worker'\)/u); + expect(materializer).toMatch(/appPackage\.name !== packageName/u); + expect(materializer).toMatch(/makeTempDirectoryScoped/u); + expect(materializer).toMatch(/removeIncompatiblePlatformDependencies/u); + expect(materializer).not.toMatch(/--skip-build/u); + const proof = yield* source('scripts/proof-workerd-ssr.mts'); + expect(proof).toMatch(/WorkerdProofError extends Schema\.TaggedError/u); + expect(proof).toMatch(/findReleaseMarkers/u); + expect(proof).toMatch(/not tied to its executed release identity/u); + expect(proof).toMatch(/check\.body \?\? null/u); + expect(proof).toMatch(/check\.expect \?\? null/u); + expect(proof).toMatch(/Exit\.isFailure\(exit\)/u); + }), + ); + it.live( + 'custom Party contracts remain accepted and forged auth remains rejected', + Effect.fn(function* consumerScenario() { + const principal = yield* source('verticals/party-registry/api/auth/action-principal.ts'); + const gateway = yield* source('verticals/party-registry/src/api/action-gateway.ts'); + const sharedApi = yield* source(partySharedApiPath); + const handlerRoot = yield* source('verticals/party-registry/api/index.ts'); + expect(hasGeneratedOperationPrincipalContract(principal)).toBe(true); + expect(hasGeneratedOperationGatewayContract(gateway, partyId)).toBe(true); + expect(hasValidGovernedHttpCompositionRoot(sharedApi, handlerRoot)).toBe(true); + expect(yield* microVerticalApiBaselineViolation(partyId, sharedApi)).toBe(undefined); + for (const [before, after] of [ + [ + 'makeMicroverticalHttpPrincipalAuthentication(verifyOperationPrincipal)', + 'makeMicroverticalHttpPrincipalAuthentication(forgedPrincipal)', + ], + ["'@app/core-runtime/http/principal-authentication'", "'./counterfeit.ts'"], + ]) { + expect(principal.includes(before)).toBeTruthy(); + expect(hasGeneratedOperationPrincipalContract(principal.replace(before, after))).toBe(false); + } + const audience = "ACTION_GATEWAY_AUDIENCE = 'party-registry'"; + expect(gateway.includes(audience)).toBeTruthy(); + expect( + hasGeneratedOperationGatewayContract( + gateway.replace(audience, "ACTION_GATEWAY_AUDIENCE = 'other-owner'"), + partyId, + ), + ).toBe(false); + // Exercise the complete Core/Party server, client, permission and transport negative matrix. + const governed = spawnSync( + process.execPath, + [ + path.join(workspaceRoot, 'node_modules/@rstest/core/bin/rstest.js'), + 'run', + '--project', + 'scripts', + 'scripts/tests/module-entrypoint-boundaries.test.mts', + '--testNamePattern', + 'governed', + ], + { + cwd: workspaceRoot, + encoding: 'utf-8', + env: { PATH: path.dirname(process.execPath) }, + }, + ); + expect(governed.status, governed.stdout + governed.stderr).toBe(0); + expect(governed.stdout).toMatch(/governed servers bind/u); + expect(governed.stdout).toMatch(/rejects generated governed clients/u); + }), + ); + it('manifest-aware bridge accepts TanStack without permitting disguised router capability', () => { + const imported = "import { createModuleFederationConfig as createConfig } from '@module-federation/modern-js-v3';"; + const config = (body: string) => `${imported} export default createConfig(${body});`; + const disabled = '{ bridge: { enableBridgeRouter: false } }'; + const enabled = '{ bridge: { enableBridgeRouter: true } }'; + expect(moduleFederationBridgeViolation(config(disabled), {})).toBe(undefined); + expect( + moduleFederationBridgeViolation( + `${imported} const config = createConfig(${disabled}); export default config;`, + {}, + ), + ).toBe(undefined); + expect( + moduleFederationBridgeViolation(config(enabled), { + dependencies: { 'react-router': '7.18.0' }, + }), + ).toBe(undefined); + expect( + moduleFederationBridgeViolation(config(enabled), { + devDependencies: { 'react-router-dom': '7.18.0' }, + }), + ).toBe(undefined); + for (const candidate of [ + config(enabled), + config('{}'), + config('{ bridge: {} }'), + config('{ bridge: { enableBridgeRouter: Boolean(false) } }'), + config('{ bridge: { enableBridgeRouter: false, ...override } }'), + config('{ bridge: { enableBridgeRouter: false, [key]: true } }'), + config('{ bridge: { enableBridgeRouter: false, enableBridgeRouter: true } }'), + config('{ bridge: { enableBridgeRouter: false }, ...override }'), + config(disabled).replace('import {', 'import type {'), + `${imported} function decoy(createConfig) { return createConfig(${disabled}); } export default otherConfig;`, + `function createConfig(value) { return value; } export default createConfig(${disabled});`, + ]) { + expect(moduleFederationBridgeViolation(candidate, {}), candidate).not.toBe(undefined); + } + }); +}); diff --git a/app/scripts/tests/audit-database-trust-boundaries.test.mts b/app/scripts/tests/audit-database-trust-boundaries.test.mts index 5b75d8240..495d7e981 100644 --- a/app/scripts/tests/audit-database-trust-boundaries.test.mts +++ b/app/scripts/tests/audit-database-trust-boundaries.test.mts @@ -1,9 +1,9 @@ -import { Effect, Cause } from 'effect'; -import { expect, it } from 'effect-rstest'; - import { readFile } from 'node:fs/promises'; +import { Effect, Cause } from 'effect'; +import { expect, it } from 'effect-rstest'; import { Client } from 'pg'; + import { assertDatabaseSessionIdentities, assertSameDatabaseTarget, @@ -178,9 +178,7 @@ it('builds deterministic current-state evidence and identifies the material trus 'contacts.customers', 'core.tenants', ]); - expect( - report.defaultPrivileges.map(({ grantee, schema, source }) => `${source}:${grantee}:${schema}`), - ).toEqual([ + expect(report.defaultPrivileges.map(({ grantee, schema, source }) => `${source}:${grantee}:${schema}`)).toEqual([ 'inherited:analytics_reader:null', 'public:PUBLIC:auth', 'direct:ontos_runtime:contacts', @@ -216,10 +214,7 @@ it('orders audit evidence by code units rather than locale collation', () => { ], }); - expect(report.types.map(({ schema, type }) => `${schema}.${type}`)).toEqual([ - 'zeta.status', - 'ärea.status', - ]); + expect(report.types.map(({ schema, type }) => `${schema}.${type}`)).toEqual(['zeta.status', 'ärea.status']); }); it('totally orders default privileges from distinct creator roles', () => { @@ -245,11 +240,9 @@ it('totally orders default privileges from distinct creator roles', () => { it('extracts typed audit failures from an Effect cause', () => { const reason = 'DATABASE_ADMIN_URL and DATABASE_URL must use distinct roles'; - expect( - getDatabaseTrustBoundaryFailureMessage( - Cause.fail(new DatabaseTrustBoundaryAuditError({ reason })), - ), - ).toBe(reason); + expect(getDatabaseTrustBoundaryFailureMessage(Cause.fail(new DatabaseTrustBoundaryAuditError({ reason })))).toBe( + reason, + ); expect(getDatabaseTrustBoundaryFailureMessage(Cause.die(new Error('driver defect')))).toBe( 'Database trust-boundary audit failed', ); @@ -282,10 +275,7 @@ it('reports privilege escalation paths without embedding credentials or context }, ], role: { ...snapshot.role, bypassRls: true }, - schemas: [ - ...snapshot.schemas, - { create: true, owner: 'empty_owner', schema: 'empty', usage: true }, - ], + schemas: [...snapshot.schemas, { create: true, owner: 'empty_owner', schema: 'empty', usage: true }], }); expect(findingCodes(report)).toEqual([ @@ -401,9 +391,7 @@ it('flags selectable privileged owner-context views but accepts security invoker }; const ownerContextReport = buildDatabaseTrustBoundaryReport(base); - expect(ownerContextReport.findings.map(({ code }) => code)).toEqual([ - 'runtime_role_can_use_privileged_owner_view', - ]); + expect(ownerContextReport.findings.map(({ code }) => code)).toEqual(['runtime_role_can_use_privileged_owner_view']); expect(ownerContextReport.summary.privilegedOwnerViewCount).toBe(1); const writableReport = buildDatabaseTrustBoundaryReport({ @@ -411,7 +399,11 @@ it('flags selectable privileged owner-context views but accepts security invoker tables: [ { ...ownerContextView, - privileges: { ...ownerContextView.privileges, select: false, update: true }, + privileges: { + ...ownerContextView.privileges, + select: false, + update: true, + }, }, ], }); @@ -422,7 +414,11 @@ it('flags selectable privileged owner-context views but accepts security invoker tables: [ { ...ownerContextView, - privileges: { ...ownerContextView.privileges, select: false, update: true }, + privileges: { + ...ownerContextView.privileges, + select: false, + update: true, + }, updatable: false, }, ], @@ -671,12 +667,8 @@ it.live( readFile(new URL('../database-trust-audit/collect-snapshot.mts', import.meta.url), 'utf-8'), ); - expect(source.match(/where membership\.admin_option or membership\.set_option/gu)?.length).toBe( - 3, - ); - expect(source).toMatch( - /candidate\.oid in \(select role_oid from reachable_roles\) as can_set_role/u, - ); + expect(source.match(/where membership\.admin_option or membership\.set_option/gu)?.length).toBe(3); + expect(source).toMatch(/candidate\.oid in \(select role_oid from reachable_roles\) as can_set_role/u); expect(source).not.toMatch( /or pg_has_role\(\$1, grantee\.oid, 'SET'\)\s+or grantee\.oid in \(select role_oid from administrable_roles\)/u, ); @@ -711,10 +703,7 @@ it('treats inherited owner-role authority as effective runtime DDL authority', ( ], }); - expect(findingCodes(report)).toEqual([ - 'runtime_role_can_assume_privileged_role', - 'runtime_role_has_ddl_authority', - ]); + expect(findingCodes(report)).toEqual(['runtime_role_can_assume_privileged_role', 'runtime_role_has_ddl_authority']); }); it('does not inherit cluster attributes without SET ROLE or ADMIN OPTION', () => { @@ -790,7 +779,10 @@ it('rejects evidence collected from different servers or databases', () => { /same PostgreSQL server and database/u, ); expect(() => - assertSameDatabaseTarget(target, { ...target, serverAddress: alternateServerAddress }), + assertSameDatabaseTarget(target, { + ...target, + serverAddress: alternateServerAddress, + }), ).toThrow(/same PostgreSQL server and database/u); expect(() => assertSameDatabaseTarget( diff --git a/app/scripts/tests/authorization-rollout-contract.test.mts b/app/scripts/tests/authorization-rollout-contract.test.mts index 5b38836f8..dfd383503 100644 --- a/app/scripts/tests/authorization-rollout-contract.test.mts +++ b/app/scripts/tests/authorization-rollout-contract.test.mts @@ -52,11 +52,12 @@ it('rollout contract rejects expiry, stale inventory binding, extra fields, and }), ).toThrow(/inactive or expired/u); expect(() => - validateAuthorizationRolloutContract(contract, { ...context, inventoryHash: 'other' }), + validateAuthorizationRolloutContract(contract, { + ...context, + inventoryHash: 'other', + }), ).toThrow(/does not match/u); - expect(() => - validateAuthorizationRolloutContract({ ...contract, arbitrary: true }, context), - ).toThrow(/malformed/u); + expect(() => validateAuthorizationRolloutContract({ ...contract, arbitrary: true }, context)).toThrow(/malformed/u); expect(() => validateAuthorizationRolloutContract( { @@ -68,7 +69,10 @@ it('rollout contract rejects expiry, stale inventory binding, extra fields, and ).toThrow(/duplicates/u); expect(() => validateAuthorizationRolloutContract( - { ...contract, compatibilityEligibleEntrypoints: ['contacts.new-action'] }, + { + ...contract, + compatibilityEligibleEntrypoints: ['contacts.new-action'], + }, context, ), ).toThrow(/unknown entrypoint/u); diff --git a/app/scripts/tests/boundary-source-structure.test.mts b/app/scripts/tests/boundary-source-structure.test.mts index 4780b10fd..880bc34f2 100644 --- a/app/scripts/tests/boundary-source-structure.test.mts +++ b/app/scripts/tests/boundary-source-structure.test.mts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { SyntaxKind } from '@typescript/native/unstable/ast'; +import { expect, it } from 'effect-rstest'; + import { DelimiterDepth, matchingDelimiter, @@ -35,9 +36,7 @@ it('balanced traversal ignores nested separators but preserves source offsets', it('generic parameter commas and arrow returns remain separate lexical concerns', () => { const source = 'value: Map number>, next: number'; - expect(topLevelSeparators(source, ',', 0, source.length, true)).toEqual([ - source.indexOf(', next'), - ]); + expect(topLevelSeparators(source, ',', 0, source.length, true)).toEqual([source.indexOf(', next')]); expect(topLevelSeparators('value < maximum; next > minimum;', ';')).toEqual([15, 31]); const depth = new DelimiterDepth(); depth.update(']'); @@ -55,9 +54,7 @@ it('token rescan retains nested template expressions and excludes regex punctuat }); it('endpoint grammar shares only topology, preserving owner path and endpoint identity', () => { - expect( - hasGeneratedModuleApiContract(endpointApi(stockReadPath), 'StockApi', 'stock', 'stock'), - ).toBe(true); + expect(hasGeneratedModuleApiContract(endpointApi(stockReadPath), 'StockApi', 'stock', 'stock')).toBe(true); expect( hasGeneratedProviderApiContract( endpointApi('/inventory.stock/reports/stock'), @@ -68,21 +65,10 @@ it('endpoint grammar shares only topology, preserving owner path and endpoint id ), ).toBe(true); expect( - hasGeneratedProviderApiContract( - endpointApi(stockReadPath), - 'StockApi', - 'inventory.stock', - 'stock', - 'report', - ), + hasGeneratedProviderApiContract(endpointApi(stockReadPath), 'StockApi', 'inventory.stock', 'stock', 'report'), ).toBe(false); expect( - hasGeneratedModuleApiContract( - endpointApi(stockReadPath, 'UnrelatedEndpoint'), - 'StockApi', - 'stock', - 'stock', - ), + hasGeneratedModuleApiContract(endpointApi(stockReadPath, 'UnrelatedEndpoint'), 'StockApi', 'stock', 'stock'), ).toBe(false); expect( hasGeneratedModuleApiContract( diff --git a/app/scripts/tests/check-authorization-readiness.test.mts b/app/scripts/tests/check-authorization-readiness.test.mts index 799575d41..b763eba3a 100644 --- a/app/scripts/tests/check-authorization-readiness.test.mts +++ b/app/scripts/tests/check-authorization-readiness.test.mts @@ -1,10 +1,7 @@ import { expect, it } from 'effect-rstest'; import type { ProtectedEntrypointInventory } from '../authorization/protected-entrypoint-inventory.mts'; -import { - checkAuthorizationReadiness, - hashAuthorizationEvidence, -} from '../check-authorization-readiness.mts'; +import { checkAuthorizationReadiness, hashAuthorizationEvidence } from '../check-authorization-readiness.mts'; import type { AuthorizationNegativeSmokeEvidence, AuthorizationReadinessInput, @@ -19,7 +16,10 @@ const contactsOwner = 'contacts.core'; const inventory: ProtectedEntrypointInventory = { entries: [ { - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, deployment: 'contacts', entrypointKey: contactsCreateCustomerEntrypoint, owner: contactsOwner, @@ -189,7 +189,10 @@ it('readiness rejects missing relationships, module state, worker ownership, and expect(() => checkAuthorizationReadiness({ ...ready, - observation: { ...ready.observation, replayMigrationHash: 'f'.repeat(64) }, + observation: { + ...ready.observation, + replayMigrationHash: 'f'.repeat(64), + }, }), ).toThrow(/stale/u); }); @@ -204,7 +207,10 @@ it('readiness rejects incorrect issuer/audience topology, short observations, an expect(() => checkAuthorizationReadiness({ ...ready, - observation: { ...ready.observation, gatewayIssuer: 'http://insecure.test' }, + observation: { + ...ready.observation, + gatewayIssuer: 'http://insecure.test', + }, }), ).toThrow(/issuer or audience/u); expect(() => @@ -222,7 +228,10 @@ it('readiness rejects incorrect issuer/audience topology, short observations, an expect(() => checkAuthorizationReadiness({ ...ready, - negativeSmoke: { ...negativeSmoke, scenarios: negativeSmoke.scenarios.slice(1) }, + negativeSmoke: { + ...negativeSmoke, + scenarios: negativeSmoke.scenarios.slice(1), + }, }), ).toThrow(/smoke evidence is incomplete/u); }); diff --git a/app/scripts/tests/code-tools-i18n.test.mts b/app/scripts/tests/code-tools-i18n.test.mts index acbef0b95..e19f9f46f 100644 --- a/app/scripts/tests/code-tools-i18n.test.mts +++ b/app/scripts/tests/code-tools-i18n.test.mts @@ -1,8 +1,10 @@ -import { expect, it } from 'effect-rstest'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; + import { NodeServices } from '@effect/platform-node'; import { Effect, FileSystem, Path, Schema, Stream } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -45,7 +47,9 @@ for (const format of ['cjs', 'esm', 'esm-node']) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const oxlintRoot = path.dirname(packageRequire.resolve('oxlint/package.json')); - const root = yield* fs.makeTempDirectoryScoped({ prefix: 'ontos-code-tools-i18n-' }); + const root = yield* fs.makeTempDirectoryScoped({ + prefix: 'ontos-code-tools-i18n-', + }); const extension = format === 'cjs' ? 'cjs' : 'js'; const dist = path.join(root, 'dist', format); yield* fs.makeDirectory(path.join(root, 'dist'), { recursive: true }); @@ -56,16 +60,20 @@ for (const format of ['cjs', 'esm', 'esm-node']) { ); yield* fs.makeDirectory(path.join(root, 'node_modules')); yield* fs.symlink(oxlintRoot, path.join(root, 'node_modules', 'oxlint')); + yield* fs.makeDirectory(path.join(root, 'node_modules', '@babel')); + for (const dependency of ['parser', 'traverse', 'types']) { + yield* fs.symlink( + path.dirname(packageRequire.resolve(`@babel/${dependency}/package.json`)), + path.join(root, 'node_modules', '@babel', dependency), + ); + } yield* fs.makeDirectory(path.join(root, 'src')); yield* fs.writeFileString(path.join(root, 'src', 'fixture.tsx'), fixture.source); if (fixture.name === malformedPluginCase) { // A deliberately broken, isolated plugin exercises Oxlint's actual crash reporter. const plugin = '{ meta: { name: "ultramodern" }, rules: { "no-manual-locale-copy-branching": { meta: { schema: [] }, create() { return { Program() { throw new Error("deliberate-i18n-plugin-failure"); } }; } } } }'; - yield* fs.writeFileString( - path.join(root, 'src', 'oxlint-plugin.ts'), - `export default ${plugin};`, - ); + yield* fs.writeFileString(path.join(root, 'src', 'oxlint-plugin.ts'), `export default ${plugin};`); } else if (format === 'cjs') { // Oxlint expects the plugin value, not the CJS module's named-export namespace. yield* fs.writeFileString( @@ -103,7 +111,7 @@ printOxlintOutput(result); process.exitCode = result.exitCode;`; }, { concurrency: 'unbounded' }, ); - const output = result.stdout + result.stderr; + const output = stripVTControlCharacters(result.stdout + result.stderr); if (fixture.diagnostic === null) { expect(result.status, output).toBe(0); expect(output).toMatch(cleanOutput); @@ -120,15 +128,12 @@ printOxlintOutput(result); process.exitCode = result.exitCode;`; } } }); - it.live(`code-tools ${format}: ${fixture.name}`, () => - testEffect.pipe(Effect.provide(NodeServices.layer)), - ); + it.live(`code-tools ${format}: ${fixture.name}`, () => testEffect.pipe(Effect.provide(NodeServices.layer))); } } it('clean i18n output accepts only silence or a zero-diagnostic summary', () => { - const summary = - 'Found 0 warnings and 0 errors.\nFinished in 423ms on 2 files with 98 rules using 4 threads.\n'; + const summary = 'Found 0 warnings and 0 errors.\nFinished in 423ms on 2 files with 98 rules using 4 threads.\n'; expect('').toMatch(cleanOutput); expect(summary).toMatch(cleanOutput); for (const output of [ diff --git a/app/scripts/tests/database-access-boundaries.test.mts b/app/scripts/tests/database-access-boundaries.test.mts index a3f9a304e..9cb484709 100644 --- a/app/scripts/tests/database-access-boundaries.test.mts +++ b/app/scripts/tests/database-access-boundaries.test.mts @@ -1,9 +1,11 @@ -import { Effect } from 'effect'; -import { NodeServices } from '@effect/platform-node'; -import { expect, it } from 'effect-rstest'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; + +import { NodeServices } from '@effect/platform-node'; +import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { checkDatabaseAccessBoundaries } from '../check-database-access-boundaries.mts'; it.live( @@ -15,13 +17,10 @@ it.live( (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); const files = { - 'apps/shell/api/routes/private.ts': - "const database = import(\n '@app/core-runtime/db/schema'\n);\n", - 'packages/core-runtime/src/testing/actions.ts': - 'export const makeActionTestHarness = () => undefined;\n', + 'apps/shell/api/routes/private.ts': "const database = import(\n '@app/core-runtime/db/schema'\n);\n", + 'packages/core-runtime/src/testing/actions.ts': 'export const makeActionTestHarness = () => undefined;\n', 'verticals/stock/api/index.ts': "import { Pool } from 'pg';\n", - 'verticals/stock/api/routes/export.ts': - "import { coreDatabaseSchema } from '@app/core-runtime';\n", + 'verticals/stock/api/routes/export.ts': "import { coreDatabaseSchema } from '@app/core-runtime';\n", 'verticals/stock/src/actions/generated-safe.action.ts': "import { defineAction } from '@app/core-runtime/actions/definition';\n", 'verticals/stock/src/actions/package-root.action.ts': @@ -30,16 +29,11 @@ it.live( "import { CoreDatabase } from '@app/core-runtime';\nimport { InventoryPersistence } from '../infrastructure/inventory-persistence.ts';\nconst reserve = Effect.gen(function* testEffect2() { yield* InventoryPersistence; });\n", 'verticals/stock/src/actions/scoped.action.ts': "import { makeScopedServices } from '../services/scoped-services.ts';\n", - 'verticals/stock/src/actions/side-effect.action.ts': - "import '../infrastructure/inventory-persistence.ts';\n", - 'verticals/stock/src/db/billing-leak.ts': - "import { invoices } from '../../../billing/src/db/schema.ts';\n", - 'verticals/stock/src/db/cross-owner.ts': - "import { coreDatabaseSchema } from '@app/core-runtime/db/schema';\n", - 'verticals/stock/src/db/dynamic-core.ts': - "const core = import(\n '@app/core-runtime/db/schema'\n);\n", - 'verticals/stock/src/db/service-factory.ts': - "import { drizzle } from 'drizzle-orm/node-postgres';\n", + 'verticals/stock/src/actions/side-effect.action.ts': "import '../infrastructure/inventory-persistence.ts';\n", + 'verticals/stock/src/db/billing-leak.ts': "import { invoices } from '../../../billing/src/db/schema.ts';\n", + 'verticals/stock/src/db/cross-owner.ts': "import { coreDatabaseSchema } from '@app/core-runtime/db/schema';\n", + 'verticals/stock/src/db/dynamic-core.ts': "const core = import(\n '@app/core-runtime/db/schema'\n);\n", + 'verticals/stock/src/db/service-factory.ts': "import { drizzle } from 'drizzle-orm/node-postgres';\n", 'verticals/stock/src/index.ts': "export { InventoryPersistence } from './infrastructure/inventory-persistence.ts';\n", 'verticals/stock/src/infrastructure/inventory-persistence.ts': @@ -70,9 +64,7 @@ it.live( }), ), ); - const violations = yield* checkDatabaseAccessBoundaries(root).pipe( - Effect.provide(NodeServices.layer), - ); + const violations = yield* checkDatabaseAccessBoundaries(root).pipe(Effect.provide(NodeServices.layer)); expect(violations.map(({ file, line }) => `${file}:${line}`)).toEqual([ 'apps/shell/api/routes/private.ts:1', 'verticals/stock/api/index.ts:1', diff --git a/app/scripts/tests/dependency-declarations.test.mts b/app/scripts/tests/dependency-declarations.test.mts index 0d63457d7..3dca3929f 100644 --- a/app/scripts/tests/dependency-declarations.test.mts +++ b/app/scripts/tests/dependency-declarations.test.mts @@ -1,10 +1,11 @@ -import { expect, it } from 'effect-rstest'; import { spawnSync } from 'node:child_process'; import { appendFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import { Option } from 'effect'; +import { expect, it } from 'effect-rstest'; import { Param } from 'effect/unstable/cli'; const configFilename = 'tsconfig.json'; @@ -12,8 +13,7 @@ const metadataFilename = 'metadata.mts'; const compilerRelativePath = 'node_modules/.bin/tsc'; const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); -const countDiagnostics = (diagnostics: string, pattern: RegExp): number => - [...diagnostics.matchAll(pattern)].length; +const countDiagnostics = (diagnostics: string, pattern: RegExp): number => [...diagnostics.matchAll(pattern)].length; const verifyDrizzleRuntimeFormats = (fixture: string): void => { for (const extension of ['mjs', 'cjs']) { @@ -38,7 +38,9 @@ assert.equal(role.createDb, undefined); assert.equal(role.createRole, undefined) assert.equal(pgRole('undefined', { inherit: undefined }).inherit, undefined); `, ); - const result = spawnSync(process.execPath, [filename], { encoding: 'utf-8' }); + const result = spawnSync(process.execPath, [filename], { + encoding: 'utf-8', + }); expect(result.error).toBeUndefined(); expect(result.status, result.stdout + result.stderr).toBe(0); } @@ -47,11 +49,7 @@ assert.equal(pgRole('undefined', { inherit: undefined }).inherit, undefined); it('published dependency declarations retain strict positive and negative contracts', () => { const fixture = mkdtempSync(path.join(tmpdir(), 'ontos-declaration-contract-')); try { - symlinkSync( - path.join(workspaceRoot, 'node_modules'), - path.join(fixture, 'node_modules'), - 'dir', - ); + symlinkSync(path.join(workspaceRoot, 'node_modules'), path.join(fixture, 'node_modules'), 'dir'); const imports = `import { pgPolicy, pgRole, type PgPolicyConfig, type PgRoleConfig } from 'drizzle-orm/pg-core'; import { cockroachPolicy, cockroachRole, type CockroachPolicyConfig, type CockroachRoleConfig } from 'drizzle-orm/cockroach-core'; import { sql } from 'drizzle-orm';\n`; diff --git a/app/scripts/tests/effect-rstest-package.test.mts b/app/scripts/tests/effect-rstest-package.test.mts index 74f46cb49..c9decfb53 100644 --- a/app/scripts/tests/effect-rstest-package.test.mts +++ b/app/scripts/tests/effect-rstest-package.test.mts @@ -1,6 +1,6 @@ import { Effect, Equal, Hash, Schema } from 'effect'; -import { FastCheck } from 'effect/testing'; import { addEqualityTesters, expect, it } from 'effect-rstest'; +import { FastCheck } from 'effect/testing'; class SemanticValue implements Equal.Equal { readonly #key: string; @@ -35,13 +35,9 @@ it.prop( }, ); -it.prop( - 'the installed package generates record schemas', - { value: Schema.Literal('schema') }, - ({ value }) => { - expect(value).toBe('schema'); - }, -); +it.prop('the installed package generates record schemas', { value: Schema.Literal('schema') }, ({ value }) => { + expect(value).toBe('schema'); +}); // Promise assimilation would inspect this success value for a `then` property. const value = new Proxy( diff --git a/app/scripts/tests/generated-slot-entries.test.mts b/app/scripts/tests/generated-slot-entries.test.mts index b3adc874b..27a1c3f41 100644 --- a/app/scripts/tests/generated-slot-entries.test.mts +++ b/app/scripts/tests/generated-slot-entries.test.mts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Result } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { readGeneratedSlotEntries } from '../scaffolding/shared.mts'; const start = '// slot:start'; @@ -13,10 +14,7 @@ it('fluent slots split only outer calls, retaining nested multiline fluent chain .add(Group.make('nested')) .pipe(identity), )`; - expect(readEntries(`${nested}\n.addHttpApi(SecondApi)`)).toEqual([ - nested, - '.addHttpApi(SecondApi)', - ]); + expect(readEntries(`${nested}\n.addHttpApi(SecondApi)`)).toEqual([nested, '.addHttpApi(SecondApi)']); }); it('slot delimiters inside strings and comments do not terminate entries', () => { @@ -27,10 +25,7 @@ it('slot delimiters inside strings and comments do not terminate entries', () => it('line comments protect fluent-looking text until the newline', () => { const first = '.addHttpApi(\n FirstApi // .addHttpApi(FakeApi);\n)'; - expect(readEntries(`${first}\n.addHttpApi(SecondApi)`)).toEqual([ - first, - '.addHttpApi(SecondApi)', - ]); + expect(readEntries(`${first}\n.addHttpApi(SecondApi)`)).toEqual([first, '.addHttpApi(SecondApi)']); }); it('empty generated slots remain empty', () => { diff --git a/app/scripts/tests/initialize-local-development.test.mts b/app/scripts/tests/initialize-local-development.test.mts index 54382d7d8..298c54d5b 100644 --- a/app/scripts/tests/initialize-local-development.test.mts +++ b/app/scripts/tests/initialize-local-development.test.mts @@ -1,11 +1,13 @@ -import { expect, it } from 'effect-rstest'; -import { makeModuleContractFixture } from '../../packages/core-runtime/src/testing/module-contract.ts'; import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; + import { NodeServices } from '@effect/platform-node'; import { Effect, Exit, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; + +import { makeModuleContractFixture } from '../../packages/core-runtime/src/testing/module-contract.ts'; import { makeTestDatabase } from '../../packages/core-runtime/tests/support/database.ts'; import type { deriveOntosModuleDeploymentContract } from '../generate-ontos-module-contract.mts'; import { @@ -38,12 +40,16 @@ const PARTY_REGISTRY_MODULE_ID = 'party.registry'; const PARTY_REGISTRY_MODULE_STATE_LABEL = 'Party Registry module state'; const TOPOLOGY_DIRECTORY = 'topology'; const TOPOLOGY_PATH = 'topology/reference-topology.json'; -const topology = JSON.stringify({ verticals: [{ id: 'party-registry' }, { id: 'inventory' }] }); +const topology = JSON.stringify({ + verticals: [{ id: 'party-registry' }, { id: 'inventory' }], +}); -const moduleContract = ( - moduleId: string, -): Effect.Success> => - makeModuleContractFixture({ appId: 'test-module', buildMarker: 'test-build', moduleId }); +const moduleContract = (moduleId: string): Effect.Success> => + makeModuleContractFixture({ + appId: 'test-module', + buildMarker: 'test-build', + moduleId, + }); it.effect('accepts only a development configuration with local service endpoints', () => Effect.gen(function* testEffect1() { @@ -76,83 +82,75 @@ it.effect('accepts only a development configuration with local service endpoints }), ); -it.effect( - 'exact reconciliation is idempotent and contradictory records fail in the typed channel', - () => - Effect.gen(function* testEffect3() { - const expected = { name: 'OntOS Local Development', status: 'active' } as const; - expect(yield* classifyExactLocalRecord('tenant', undefined, expected)).toBe('create'); - expect(yield* classifyExactLocalRecord('tenant', expected, expected)).toBe('existing'); - const conflict = yield* classifyExactLocalRecord( - 'tenant', - { ...expected, status: 'suspended' }, - expected, - ).pipe(Effect.flip); - expect(conflict.code).toBe('local_conflict'); - expect(conflict.reason).toMatch(/status/u); - }), +it.effect('exact reconciliation is idempotent and contradictory records fail in the typed channel', () => + Effect.gen(function* testEffect3() { + const expected = { + name: 'OntOS Local Development', + status: 'active', + } as const; + expect(yield* classifyExactLocalRecord('tenant', undefined, expected)).toBe('create'); + expect(yield* classifyExactLocalRecord('tenant', expected, expected)).toBe('existing'); + const conflict = yield* classifyExactLocalRecord('tenant', { ...expected, status: 'suspended' }, expected).pipe( + Effect.flip, + ); + expect(conflict.code).toBe('local_conflict'); + expect(conflict.reason).toMatch(/status/u); + }), ); -it.effect( - 'module-state reconciliation preserves migrated IDs and rejects identity collisions', - () => - Effect.gen(function* testEffect4() { - const expected = { - moduleKey: PARTY_REGISTRY_MODULE_ID, - state: 'active', - tenantId: LOCAL_DEVELOPMENT_CONTEXT.tenantId, - tenantModuleStateId: moduleStateIdFor(PARTY_REGISTRY_MODULE_ID), - } as const; - expect( - yield* classifyLocalModuleState(PARTY_REGISTRY_MODULE_STATE_LABEL, undefined, expected), - ).toBe('create'); - expect( - yield* classifyLocalModuleState( - PARTY_REGISTRY_MODULE_STATE_LABEL, - { ...expected, tenantModuleStateId: '7f000000-0000-4000-8000-000000000001' }, - expected, - ), - ).toBe('existing'); - expect( - Schema.is(LocalDevelopmentInitializationError)( - yield* Effect.flip( - classifyLocalModuleState( - PARTY_REGISTRY_MODULE_STATE_LABEL, - { - ...expected, - moduleKey: INVENTORY_MODULE_ID, - tenantModuleStateId: expected.tenantModuleStateId, - }, - expected, - ), +it.effect('module-state reconciliation preserves migrated IDs and rejects identity collisions', () => + Effect.gen(function* testEffect4() { + const expected = { + moduleKey: PARTY_REGISTRY_MODULE_ID, + state: 'active', + tenantId: LOCAL_DEVELOPMENT_CONTEXT.tenantId, + tenantModuleStateId: moduleStateIdFor(PARTY_REGISTRY_MODULE_ID), + } as const; + expect(yield* classifyLocalModuleState(PARTY_REGISTRY_MODULE_STATE_LABEL, undefined, expected)).toBe('create'); + expect( + yield* classifyLocalModuleState( + PARTY_REGISTRY_MODULE_STATE_LABEL, + { + ...expected, + tenantModuleStateId: '7f000000-0000-4000-8000-000000000001', + }, + expected, + ), + ).toBe('existing'); + expect( + Schema.is(LocalDevelopmentInitializationError)( + yield* Effect.flip( + classifyLocalModuleState( + PARTY_REGISTRY_MODULE_STATE_LABEL, + { + ...expected, + moduleKey: INVENTORY_MODULE_ID, + tenantModuleStateId: expected.tenantModuleStateId, + }, + expected, ), ), - ).toBe(true); - }), + ), + ).toBe(true); + }), ); it.effect('derives only configured Party Registry through its generated owner contract', () => Effect.gen(function* testEffect5() { - const root = yield* Effect.tryPromise(() => - mkdtemp(path.join(os.tmpdir(), LOCAL_MODULES_DIRECTORY_PREFIX)), - ); + const root = yield* Effect.tryPromise(() => mkdtemp(path.join(os.tmpdir(), LOCAL_MODULES_DIRECTORY_PREFIX))); yield* Effect.tryPromise(() => mkdir(path.join(root, TOPOLOGY_DIRECTORY), { recursive: true })); yield* Effect.tryPromise(() => writeFile(path.join(root, TOPOLOGY_PATH), topology, 'utf-8')); const deriveContract = ({ vertical }: { readonly vertical: string }) => Effect.succeed(moduleContract(`${vertical}.core`)); - expect( - yield* deriveActivatedModuleIds(root, deriveContract).pipe( - Effect.provide(NodeServices.layer), - ), - ).toEqual(['party-registry.core']); + expect(yield* deriveActivatedModuleIds(root, deriveContract).pipe(Effect.provide(NodeServices.layer))).toEqual([ + 'party-registry.core', + ]); }), ); it.effect('rejects duplicate module IDs derived from different verticals', () => Effect.gen(function* testEffect6() { - const root = yield* Effect.tryPromise(() => - mkdtemp(path.join(os.tmpdir(), LOCAL_MODULES_DIRECTORY_PREFIX)), - ); + const root = yield* Effect.tryPromise(() => mkdtemp(path.join(os.tmpdir(), LOCAL_MODULES_DIRECTORY_PREFIX))); yield* Effect.tryPromise(() => mkdir(path.join(root, TOPOLOGY_DIRECTORY), { recursive: true })); yield* Effect.tryPromise(() => writeFile(path.join(root, TOPOLOGY_PATH), topology, 'utf-8')); const deriveContract = () => Effect.succeed(moduleContract('duplicate.core')); @@ -170,16 +168,9 @@ it.effect('rejects duplicate module IDs derived from different verticals', () => it.effect('generates stable module state IDs and complete access relationships', () => Effect.gen(function* testEffect7() { - expect(moduleStateIdFor(PARTY_REGISTRY_MODULE_ID)).toBe( - moduleStateIdFor(PARTY_REGISTRY_MODULE_ID), - ); - expect(moduleStateIdFor(PARTY_REGISTRY_MODULE_ID)).not.toBe( - moduleStateIdFor(INVENTORY_MODULE_ID), - ); - const relationships = yield* buildLocalDevelopmentRelationships([ - PARTY_REGISTRY_MODULE_ID, - INVENTORY_MODULE_ID, - ]); + expect(moduleStateIdFor(PARTY_REGISTRY_MODULE_ID)).toBe(moduleStateIdFor(PARTY_REGISTRY_MODULE_ID)); + expect(moduleStateIdFor(PARTY_REGISTRY_MODULE_ID)).not.toBe(moduleStateIdFor(INVENTORY_MODULE_ID)); + const relationships = yield* buildLocalDevelopmentRelationships([PARTY_REGISTRY_MODULE_ID, INVENTORY_MODULE_ID]); expect(relationships.length).toBe(7); expect(relationships.filter(({ relation }) => relation === 'accessor').length).toBe(2); expect(relationships.filter(({ relation }) => relation === 'legal_entity').length).toBe(2); @@ -196,9 +187,9 @@ it.effect('a late module conflict rolls back Core bootstrap and retains its type }), ); - const conflict = yield* reconcileCoreContext(database, LOCAL_AUTH_USER_ID, [ - PARTY_REGISTRY_MODULE_ID, - ]).pipe(Effect.flip); + const conflict = yield* reconcileCoreContext(database, LOCAL_AUTH_USER_ID, [PARTY_REGISTRY_MODULE_ID]).pipe( + Effect.flip, + ); expect(conflict.code).toBe('local_conflict'); expect(conflict.reason).toMatch(/module-state identity conflicts/u); @@ -214,7 +205,9 @@ it.effect('native commit failure becomes a typed bootstrap error', () => sql === 'COMMIT' ? Effect.fail( new SqlError({ - reason: new ConnectionError({ cause: new Error('connection closed') }), + reason: new ConnectionError({ + cause: new Error('connection closed'), + }), }), ) : Effect.succeed([]), diff --git a/app/scripts/tests/locki-feature.test.mts b/app/scripts/tests/locki-feature.test.mts index ed5f9fc35..3fd790db2 100644 --- a/app/scripts/tests/locki-feature.test.mts +++ b/app/scripts/tests/locki-feature.test.mts @@ -1,20 +1,19 @@ -import { Effect } from 'effect'; -import { expect, it } from 'effect-rstest'; import { spawnSync } from 'node:child_process'; import { chmod, cp, mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { execPath } from 'node:process'; +import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + const workspaceRoot = path.resolve(import.meta.dirname, '../..'); const workflowScript = path.join(workspaceRoot, 'scripts/locki-feature.sh'); const featureSlug = 'customer-search'; it.live('pins pnpm to the npm mise backend for cross-platform sandbox installation', () => Effect.gen(function* testEffect1() { - const miseConfiguration = yield* Effect.tryPromise(() => - readFile(path.join(workspaceRoot, '.mise.toml'), 'utf-8'), - ); + const miseConfiguration = yield* Effect.tryPromise(() => readFile(path.join(workspaceRoot, '.mise.toml'), 'utf-8')); expect(miseConfiguration).toMatch(/\[tool_alias\][\s\S]*pnpm = "npm:pnpm"/u); expect(miseConfiguration).toMatch(/\[tools\][\s\S]*pnpm = "11\.25\.0"/u); }), @@ -41,20 +40,14 @@ const executable = (target: string, content: string) => const makeFixture = (withEnvironment = true) => Effect.gen(function* testEffect3() { - const root = yield* Effect.tryPromise(() => - mkdtemp(path.join(os.tmpdir(), 'ontos-locki-feature-')), - ); + const root = yield* Effect.tryPromise(() => mkdtemp(path.join(os.tmpdir(), 'ontos-locki-feature-'))); const sourceRoot = path.join(root, 'source'); const targetRoot = path.join(root, 'target'); const binDirectory = path.join(root, 'bin'); const logPath = path.join(root, 'commands.log'); - yield* Effect.tryPromise(() => - mkdir(path.join(sourceRoot, 'app/scripts'), { recursive: true }), - ); + yield* Effect.tryPromise(() => mkdir(path.join(sourceRoot, 'app/scripts'), { recursive: true })); yield* Effect.tryPromise(() => mkdir(binDirectory, { recursive: true })); - yield* Effect.tryPromise(() => - cp(workflowScript, path.join(sourceRoot, 'app/scripts/locki-feature.sh')), - ); + yield* Effect.tryPromise(() => cp(workflowScript, path.join(sourceRoot, 'app/scripts/locki-feature.sh'))); if (withEnvironment) { yield* Effect.tryPromise(() => writeFile(path.join(sourceRoot, 'app/.env'), Buffer.from('OPAQUE-SECRET\0VALUE\n')), @@ -151,18 +144,14 @@ it.live('creates one sandbox from main, copies .env opaquely, and prepares in or const result = runWorkflow(fixture, ['--', featureSlug, '--no-ai']); expect(result.code, result.stderr).toBe(0); expect(result.stdout.includes('OPAQUE-SECRET')).toBe(false); - expect( - yield* Effect.tryPromise(() => readFile(path.join(fixture.targetRoot, 'app/.env'))), - ).toEqual(yield* Effect.tryPromise(() => readFile(path.join(fixture.sourceRoot, 'app/.env')))); - const environmentStat = yield* Effect.tryPromise(() => - stat(path.join(fixture.targetRoot, 'app/.env')), + expect(yield* Effect.tryPromise(() => readFile(path.join(fixture.targetRoot, 'app/.env')))).toEqual( + yield* Effect.tryPromise(() => readFile(path.join(fixture.sourceRoot, 'app/.env'))), ); + const environmentStat = yield* Effect.tryPromise(() => stat(path.join(fixture.targetRoot, 'app/.env'))); expect(environmentStat.mode % 0o1000).toBe(0o600); const log = yield* Effect.tryPromise(() => readFile(fixture.logPath, 'utf-8')); expect(log).toMatch(/locki new --from main --branch codex\/customer-search --json/u); - expect(log).toMatch( - /locki exec --match sandbox-42 -- sh app\/scripts\/locki-feature\.sh --prepare/u, - ); + expect(log).toMatch(/locki exec --match sandbox-42 -- sh app\/scripts\/locki-feature\.sh --prepare/u); expect(log.includes('locki ai')).toBe(false); const expectedOrder = [ 'mise install', @@ -189,9 +178,7 @@ it.live('rejects unsafe slugs and alternate options before creating a sandbox', const fixture = yield* makeFixture(); const result = runWorkflow(fixture, commandArguments); expect(result.code).toBe(2); - expect( - yield* Effect.flip(Effect.tryPromise(() => readFile(fixture.logPath, 'utf-8'))), - ).toBeDefined(); + expect(yield* Effect.flip(Effect.tryPromise(() => readFile(fixture.logPath, 'utf-8')))).toBeDefined(); }); yield* assertRejected(['Bad Slug']); yield* assertRejected(['feature', '--from', 'main']); @@ -225,19 +212,23 @@ it.live('fails before creating a sandbox when the workflow is not committed on m it.live('refuses an app path that resolves outside the returned worktree', () => Effect.gen(function* testEffect9() { const fixture = yield* makeFixture(); - const result = runWorkflow(fixture, [featureSlug], { ESCAPE_TARGET: 'true' }); + const result = runWorkflow(fixture, [featureSlug], { + ESCAPE_TARGET: 'true', + }); expect(result.code).toBe(1); expect(result.stderr).toMatch(/Refusing to copy \.env outside the Locki worktree/u); - expect( - yield* Effect.tryPromise(() => readFile(path.join(fixture.sourceRoot, 'app/.env'))), - ).toEqual(Buffer.from('OPAQUE-SECRET\0VALUE\n')); + expect(yield* Effect.tryPromise(() => readFile(path.join(fixture.sourceRoot, 'app/.env')))).toEqual( + Buffer.from('OPAQUE-SECRET\0VALUE\n'), + ); }), ); it.live('preserves a failed sandbox and never launches AI', () => Effect.gen(function* testEffect10() { const fixture = yield* makeFixture(); - const result = runWorkflow(fixture, [featureSlug], { FAIL_PREPARATION: 'true' }); + const result = runWorkflow(fixture, [featureSlug], { + FAIL_PREPARATION: 'true', + }); expect(result.code).toBe(1); expect(result.stdout).toMatch(/locki exec --match sandbox-42/u); expect(result.stdout).toMatch(/locki rm --match sandbox-42/u); @@ -252,8 +243,6 @@ it.live('launches the configured AI only after successful preparation', () => const result = runWorkflow(fixture, [featureSlug]); expect(result.code, result.stderr).toBe(0); const log = yield* Effect.tryPromise(() => readFile(fixture.logPath, 'utf-8')); - expect( - log.indexOf('mise exec -- pnpm db:verify') < log.indexOf('locki ai --match sandbox-42'), - ).toBe(true); + expect(log.indexOf('mise exec -- pnpm db:verify') < log.indexOf('locki ai --match sandbox-42')).toBe(true); }), ); diff --git a/app/scripts/tests/migrate-contacts-authorization.test.mts b/app/scripts/tests/migrate-contacts-authorization.test.mts index 5101447f6..2a817175a 100644 --- a/app/scripts/tests/migrate-contacts-authorization.test.mts +++ b/app/scripts/tests/migrate-contacts-authorization.test.mts @@ -1,9 +1,14 @@ import { expect, it } from 'effect-rstest'; + import { planContactsAuthorizationContext } from '../migrate-contacts-authorization.mts'; import type { ContactsAuthorizationRelationship } from '../migrate-contacts-authorization.mts'; const legacyRelationships = [ - { relation: 'legal_entity', subjectId: 'legal-entity', subjectType: 'legal_entity' }, + { + relation: 'legal_entity', + subjectId: 'legal-entity', + subjectType: 'legal_entity', + }, { relation: 'accessor', subjectId: 'principal', subjectType: 'principal' }, ] as const satisfies readonly ContactsAuthorizationRelationship[]; const prepareMode = 'prepare'; @@ -24,15 +29,15 @@ it('prepare and verify accept an exactly prepared context', () => { expect(planContactsAuthorizationContext(prepareMode, legacyRelationships, reordered).state).toBe( alreadyPreparedState, ); - expect(planContactsAuthorizationContext(verifyMode, legacyRelationships, reordered).state).toBe( - alreadyPreparedState, - ); + expect(planContactsAuthorizationContext(verifyMode, legacyRelationships, reordered).state).toBe(alreadyPreparedState); }); it('finalize removes only an exactly matched legacy context', () => { - expect( - planContactsAuthorizationContext(finalizeMode, legacyRelationships, legacyRelationships), - ).toEqual({ deleteLegacy: true, state: alreadyPreparedState, touchContacts: false }); + expect(planContactsAuthorizationContext(finalizeMode, legacyRelationships, legacyRelationships)).toEqual({ + deleteLegacy: true, + state: alreadyPreparedState, + touchContacts: false, + }); }); it('all modes are idempotent after legacy relationships are gone', () => { @@ -56,8 +61,6 @@ it('verify and finalize fail closed when Contacts relationships are missing', () it('every mode rejects partial or divergent relationship sets', () => { const partial = legacyRelationships.slice(0, 1); for (const mode of [prepareMode, verifyMode, finalizeMode] as const) { - expect(() => planContactsAuthorizationContext(mode, legacyRelationships, partial)).toThrow( - /relationships differ/u, - ); + expect(() => planContactsAuthorizationContext(mode, legacyRelationships, partial)).toThrow(/relationships differ/u); } }); diff --git a/app/scripts/tests/module-entrypoint-boundaries.test.mts b/app/scripts/tests/module-entrypoint-boundaries.test.mts index 98b4b09d4..99e7bf66b 100644 --- a/app/scripts/tests/module-entrypoint-boundaries.test.mts +++ b/app/scripts/tests/module-entrypoint-boundaries.test.mts @@ -1,19 +1,15 @@ -import { expect, it } from 'effect-rstest'; - import { existsSync } from 'node:fs'; - import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; - import os from 'node:os'; - import path from 'node:path'; import { NodeServices } from '@effect/platform-node'; - -import { Cause, Effect, Exit, Schema } from 'effect'; +import { Cause, ConfigProvider, Effect, Exit, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { checkModuleEntrypointBoundaries as checkModuleEntrypointBoundariesEffect } from '../check-module-entrypoint-boundaries.mts'; - +import { hasGeneratedGovernedServerContract } from '../generated-governed-http-boundary.mts'; +import { hasGeneratedGovernedClientContract, hasGeneratedSourceHeader } from '../generated-module-api-boundary.mts'; import { assertPublishedCrossMicroVerticalContractUsage, assertPublishedOutboxContractSource, @@ -23,12 +19,33 @@ import { resolvePublishedContractModuleId, } from '../published-outbox-contracts.mts'; -import { hasGeneratedGovernedServerContract } from '../generated-governed-http-boundary.mts'; - const EXPECTED_EFFECT_FAILURE = 'Expected the Effect to fail'; +it('recognizes real provenance comments after import sorting and rejects string decoys', () => { + const header = + '// @generated by OntOS Codesmith Governed Contribution v1\n// @ontos-contribution-kind search-provider\n'; + expect(hasGeneratedSourceHeader(`${header}export const provider = true;`, header)).toBe(true); + expect( + hasGeneratedSourceHeader(`import { Effect } from "effect";\n${header}export const provider = true;`, header), + ).toBe(true); + expect(hasGeneratedSourceHeader(`const decoy = \`${header}\`;`, header)).toBe(false); + expect(hasGeneratedSourceHeader(`function decoy() {\n${header}}`, header)).toBe(false); + expect(hasGeneratedSourceHeader(header.replace('search-provider', 'report'), header)).toBe(false); +}); + const checkModuleEntrypointBoundaries = (root: string) => - checkModuleEntrypointBoundariesEffect(root).pipe(Effect.provide(NodeServices.layer)); + checkModuleEntrypointBoundariesEffect(root).pipe( + Effect.provide(NodeServices.layer), + // Generated fixtures have no Git history. Keep their inventory identity + // explicit and independent of the host's Git integration. + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + ULTRAMODERN_SOURCE_REVISION: 'working-tree', + }), + ), + ), + ); const ARCHIVE_ORGANIZATION_DECLARATION = 'export const archiveOrganizationEngagementAction'; @@ -58,6 +75,8 @@ const GATEWAY_IMPORT = "import { operationGateway } from './action-gateway.ts';" const CLIENT_CONFIG_DECLARATION = ' makeGovernedEffectBffClient('; +const INVENTORY_CONTRACT_IMPORT = '../../shared/apis/inventory-items-search.ts'; +const OPTIONS_CALL_TAIL = 'options,\n );'; const AUTHORIZATION_VALUE = ' credential,'; const AUTHORIZATION_VALUE_TAIL = " credential: Redacted.make('Bearer bypass'),"; @@ -141,13 +160,9 @@ const governedClientFixture = (options: { }): string => { const apiStem = options.apiValue.replace(/Api$/u, ''); const operationStem = - options.invocationKind === MODULE_API_KIND - ? apiStem - : apiStem.replace(/(?:Report|Search)$/u, ''); + options.invocationKind === MODULE_API_KIND ? apiStem : apiStem.replace(/(?:Report|Search)$/u, ''); const requestType = - options.invocationKind === MODULE_API_KIND - ? `${apiStem}Request` - : `${operationStem}ProviderRequest`; + options.invocationKind === MODULE_API_KIND ? `${apiStem}Request` : `${operationStem}ProviderRequest`; return `${options.generatedHeader}import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { ${options.apiValue} } from '${options.contractImport}'; @@ -178,9 +193,7 @@ export const ${options.authorizedOperation} = ( ) => ${options.clientHelper}(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => client.${options.endpointGroup}.execute(${ - options.invocationKind === MODULE_API_KIND - ? '{ headers: {}, params: {}, payload, query: {} }' - : '{ payload }' + options.invocationKind === MODULE_API_KIND ? '{ headers: {}, params: {}, payload, query: {} }' : '{ payload }' })), ); export const ${options.publicOperation} = ( @@ -192,6 +205,67 @@ export const ${options.publicOperation} = ( );`; }; +it.effect('governed clients retain every boundary check across formatter trailing-comma choices', () => + Effect.sync(() => { + const expectation = { + authorizedOperation: 'loadInventoryItemsClientWithAuthorization', + defaultApiPrefix: '/inventory-stock-api', + endpointGroup: 'inventoryItemsSearch', + generatedHeader: + '// @generated by OntOS Codesmith Governed Contribution v1\n// @ontos-contribution-kind search-provider\n', + invocationKind: 'provider', + ownerApiValue: 'InventoryItemsSearchApi', + ownerContractImport: INVENTORY_CONTRACT_IMPORT, + publicOperation: 'loadInventoryItemsClient', + } as const; + const source = governedClientFixture({ + ...expectation, + apiValue: expectation.ownerApiValue, + clientHelper: 'inventoryItemsClient', + contractImport: expectation.ownerContractImport, + }); + const withoutCallTrailingCommas = source.replaceAll(/,\s*(?=\))/gu, ''); + expect(hasGeneratedGovernedClientContract(withoutCallTrailingCommas, expectation)).toBe(true); + expect( + hasGeneratedGovernedClientContract( + withoutCallTrailingCommas.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), + expectation, + ), + ).toBe(false); + for (const [helperComma, objectComma, callComma] of [ + [false, false, false], + [false, false, true], + [false, true, false], + [false, true, true], + [true, false, false], + [true, false, true], + [true, true, false], + [true, true, true], + ] as const) { + const variant = source + .replace( + 'options: InventoryItemsClientOptions,\n)', + `options: InventoryItemsClientOptions${helperComma ? ',' : ''}\n)`, + ) + .replace('requestCorrelation,\n }', `requestCorrelation${objectComma ? ',' : ''}\n }`) + .replace(OPTIONS_CALL_TAIL, `options${callComma ? ',' : ''}\n );`); + expect(hasGeneratedGovernedClientContract(variant, expectation)).toBe(true); + expect( + hasGeneratedGovernedClientContract(variant.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), expectation), + ).toBe(false); + expect( + hasGeneratedGovernedClientContract( + variant.replace(` requestCorrelation${objectComma ? ',' : ''}`, CORRELATION_VALUE_TAIL), + expectation, + ), + ).toBe(false); + expect( + hasGeneratedGovernedClientContract(variant.replace(DEFAULT_API_PREFIX, DEFAULT_API_PREFIX_TAIL), expectation), + ).toBe(false); + } + }), +); + const problemKinds = [ ['authentication', 'Authentication', 401], ['invalid', 'Invalid', 400], @@ -289,17 +363,11 @@ it('governed servers bind the trusted handler, authentication, registration, and [AUTHENTICATE_PRINCIPAL_BINDING, 'authenticatePrincipal: forgedPrincipal'], [STOCK_LIST_READ_BINDING, 'registration: otherRead'], [STOCK_LIST_READ_BINDING, 'registration: stockListRead || otherRead'], - [ - AUTHENTICATE_PRINCIPAL_BINDING, - 'authenticatePrincipal: authenticateOperationPrincipal || forgedPrincipal', - ], + [AUTHENTICATE_PRINCIPAL_BINDING, 'authenticatePrincipal: authenticateOperationPrincipal || forgedPrincipal'], [' problems,', ' problems, extra: true,'], ["'@app/core-runtime/http/governed-read'", "'./fake-handler.ts'"], ["'./auth/action-principal.ts'", "'./fake-auth.ts'"], - [ - 'authentication: StockListAuthenticationProblemSchema', - 'authentication: { make: () => ({ status: 200 }) }', - ], + ['authentication: StockListAuthenticationProblemSchema', 'authentication: { make: () => ({ status: 200 }) }'], [ 'unavailable: StockListUnavailableProblemSchema', 'unavailable: { make: () => ({ status: 503, retryable: false }) }', @@ -327,10 +395,9 @@ const assertRejectedSources = Effect.fn(function* rejectGovernedSources( ) { for (const [index, source] of sources.entries()) { yield* write(root, file, source); - expect( - String(yield* Effect.flip(checkModuleEntrypointBoundaries(root))), - `invalid source ${index}`, - ).toMatch(expected); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root))), `invalid source ${index}`).toMatch( + expected, + ); } }); @@ -351,9 +418,7 @@ const makeFixture = Effect.fn(function* mergedScenario1( apps: [ { id: 'shell-super-app', path: 'apps/shell-super-app' }, { id: 'inventory-stock', path: INVENTORY_VERTICAL_PATH }, - ...(includeParty === true - ? [{ id: PARTY_DEPLOYMENT_ID, path: 'verticals/party-registry' }] - : []), + ...(includeParty === true ? [{ id: PARTY_DEPLOYMENT_ID, path: 'verticals/party-registry' }] : []), ], }, }), @@ -440,9 +505,7 @@ const writeEngagementLifecycleFixture = Effect.fn(function* mergedScenario2(root ...ENGAGEMENT_ACTIONS.map((action) => `${ENGAGEMENT_ACTION_DIRECTORY}/${action}.action.ts`), ].map( Effect.fn(function* mergedScenario1(file) { - const source = yield* Effect.promise(() => - readFile(path.resolve(import.meta.dirname, '../..', file), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.resolve(import.meta.dirname, '../..', file), 'utf-8')); yield* write(root, file, source); }), ), @@ -475,15 +538,15 @@ it.live( yield* checkModuleEntrypointBoundaries(root); const inventory = Schema.decodeUnknownSync(EngagementInventorySchema)( yield* Effect.promise(() => - readFile( - path.join(root, '.codex/reports/authorization/protected-entrypoints.json'), - 'utf-8', - ), + readFile(path.join(root, '.codex/reports/authorization/protected-entrypoints.json'), 'utf-8'), ), ); expect(inventory.entries.filter((entry) => entry.owner === PARTY_MODULE_ID)).toEqual( ENGAGEMENT_ACTIONS.map((action) => ({ - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, deployment: PARTY_DEPLOYMENT_ID, entrypointKey: `${PARTY_MODULE_ID}.${action}`, owner: PARTY_MODULE_ID, @@ -498,27 +561,13 @@ it.live( Effect.fn(function* mergedScenario1() { const root = yield* makeFixture(true); yield* writeEngagementLifecycleFixture(root); - const source = yield* Effect.promise(() => - readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8')); const mutations = [ - [ - "'./engagement-lifecycle-registration.ts'", - "'../other/engagement-lifecycle-registration.ts'", - ], - [ - 'import { engagementLifecycleRegistration }', - 'import { engagementLifecycleRegistration as counterfeit }', - ], - [ - "'party.registry.archive-organization-engagement'", - "'party.registry.unarchive-organization-engagement'", - ], + ["'./engagement-lifecycle-registration.ts'", "'../other/engagement-lifecycle-registration.ts'"], + ['import { engagementLifecycleRegistration }', 'import { engagementLifecycleRegistration as counterfeit }'], + ["'party.registry.archive-organization-engagement'", "'party.registry.unarchive-organization-engagement'"], ['// @ontos-action-owner party.registry', '// @ontos-action-owner other.owner'], - [ - '// @ontos-action-slug archive-organization-engagement', - '// @ontos-action-slug archive-person-engagement', - ], + ['// @ontos-action-slug archive-organization-engagement', '// @ontos-action-slug archive-person-engagement'], [ 'payloadSchema: OrganizationEngagementLifecyclePayloadSchema', 'payloadSchema: OrganizationEngagementProfileSchema', @@ -569,9 +618,7 @@ it.live( Effect.fn(function* mergedScenario1() { const root = yield* makeFixture(true); yield* writeEngagementLifecycleFixture(root); - const source = yield* Effect.promise(() => - readFile(path.join(root, ENGAGEMENT_REGISTRATION_FILE), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(root, ENGAGEMENT_REGISTRATION_FILE), 'utf-8')); const mutations = [ ["'@app/core-runtime'", "'@app/counterfeit-runtime'"], ['defineTenantModuleEntrypoint', 'defineSystemModuleEntrypoint'], @@ -617,9 +664,7 @@ it.live( Effect.fn(function* mergedScenario1() { const root = yield* makeFixture(true); yield* writeEngagementLifecycleFixture(root); - const source = yield* Effect.promise(() => - readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8')); const wrongFile = `${ENGAGEMENT_ACTION_DIRECTORY}/archive-other-engagement.action.ts`; yield* write(root, wrongFile, source); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { @@ -809,9 +854,7 @@ for (const suffix of ['search', 'report']) { files.map( Effect.fn(function* governanceScenario25(file) { const original = `${INVENTORY_VERTICAL_PATH}/${file}`; - const source = yield* Effect.promise(() => - readFile(path.join(root, original), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(path.join(root, original), 'utf-8')); const renamed = original.replaceAll(STOCK_LIST_STEM, `stock-${suffix}`); yield* write( root, @@ -851,13 +894,9 @@ it.live( const sharedApiPath = 'verticals/inventory-stock/shared/api.ts'; const manifestPath = 'verticals/inventory-stock/vertical.manifest.ts'; const registrationPath = INVENTORY_REGISTRATION_PATH; - const sharedApi = yield* Effect.promise(() => - readFile(path.join(root, sharedApiPath), 'utf-8'), - ); + const sharedApi = yield* Effect.promise(() => readFile(path.join(root, sharedApiPath), 'utf-8')); const manifest = yield* Effect.promise(() => readFile(path.join(root, manifestPath), 'utf-8')); - const registration = yield* Effect.promise(() => - readFile(path.join(root, registrationPath), 'utf-8'), - ); + const registration = yield* Effect.promise(() => readFile(path.join(root, registrationPath), 'utf-8')); yield* write( root, sharedApiPath, @@ -866,9 +905,7 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -877,16 +914,11 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, yield* write( root, sharedApiPath, - sharedApi.replace( - STOCK_LIST_API_BINDING, - `${STOCK_LIST_API_BINDING}${STOCK_LIST_API_BINDING}`, - ), + sharedApi.replace(STOCK_LIST_API_BINDING, `${STOCK_LIST_API_BINDING}${STOCK_LIST_API_BINDING}`), ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -899,18 +931,14 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, }); yield* write(root, sharedApiPath, sharedApi); const handlerRootPath = 'verticals/inventory-stock/api/index.ts'; - const handlerRoot = yield* Effect.promise(() => - readFile(path.join(root, handlerRootPath), 'utf-8'), - ); + const handlerRoot = yield* Effect.promise(() => readFile(path.join(root, handlerRootPath), 'utf-8')); yield* write( root, handlerRootPath, @@ -957,11 +985,7 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, " 'missing-detail': () => import('./src/api/missing-detail-client.ts'),\n 'stock-list': () => import('./src/api/stock-list-client.ts'),", ), ); - yield* write( - root, - secondContractPath, - moduleContractFixture('missing-detail', 'MissingDetail', 'missingDetail'), - ); + yield* write(root, secondContractPath, moduleContractFixture('missing-detail', 'MissingDetail', 'missingDetail')); yield* write( root, 'verticals/inventory-stock/src/api/missing-detail.read.ts', @@ -997,9 +1021,7 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, yield* Effect.promise(() => rm(path.join(root, secondContractPath))); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1007,15 +1029,9 @@ const decoyApi = HttpApi.make('DecoyApi').addHttpApi(StockListApi);`, yield* write(root, sharedApiPath, sharedApi); yield* write(root, manifestPath, manifest); yield* write(root, registrationPath, registration); - yield* Effect.promise(() => - rm(path.join(root, 'verticals/inventory-stock/src/api/missing-detail.read.ts')), - ); - yield* Effect.promise(() => - rm(path.join(root, 'verticals/inventory-stock/src/api/missing-detail-client.ts')), - ); - yield* Effect.promise(() => - rm(path.join(root, 'verticals/inventory-stock/api/missing-detail-read-server.ts')), - ); + yield* Effect.promise(() => rm(path.join(root, 'verticals/inventory-stock/src/api/missing-detail.read.ts'))); + yield* Effect.promise(() => rm(path.join(root, 'verticals/inventory-stock/src/api/missing-detail-client.ts'))); + yield* Effect.promise(() => rm(path.join(root, 'verticals/inventory-stock/api/missing-detail-read-server.ts'))); yield* write(root, handlerRootPath, handlerRoot); yield* write( root, @@ -1025,9 +1041,7 @@ const decoy = { 'stock-list': StockListApi };`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1036,17 +1050,12 @@ const decoy = { 'stock-list': StockListApi };`, yield* write( root, registrationPath, - `${registration.replace( - "import('./src/api/stock-list-client.ts')", - "import('./src/api/other-client.ts')", - )} + `${registration.replace("import('./src/api/stock-list-client.ts')", "import('./src/api/other-client.ts')")} const decoy = { 'stock-list': () => import('./src/api/stock-list-client.ts') };`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1063,9 +1072,7 @@ const decoy = { 'stock-list': () => import('./src/api/stock-list-client.ts') };` ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1083,9 +1090,7 @@ const decoy = { 'stock-list': () => import('./src/api/stock-list-client.ts') };` ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1105,10 +1110,7 @@ const decoy = { 'stock-list': () => import('./src/api/stock-list-client.ts') };` 'export const operationGateway = makeOperationGateway();', "export const operationGateway = { invoke: (attempt) => attempt('Bearer cached') };", ), - gateway.replace( - SHARED_GATEWAY_FACTORY, - "makeSharedOperationGateway('wrong-audience', acquire)", - ), + gateway.replace(SHARED_GATEWAY_FACTORY, "makeSharedOperationGateway('wrong-audience', acquire)"), gateway.replace( '(acquire: OperationGatewayIssuer = issueGatewayContext)', '(issueGatewayContext: OperationGatewayIssuer)', @@ -1133,9 +1135,7 @@ Object.assign(operationGateway, { ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1170,9 +1170,7 @@ Object.assign(operationGateway, { ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1195,10 +1193,7 @@ it.live( 'policies: []', "policies: [], ...{ entrypoint: attackerEntrypoint, legalEntityScope: 'required' }", ), - validRead.replace( - "owningModuleKey: 'inventory.stock'", - "owningModuleKey: 'attacker.module'", - ), + validRead.replace("owningModuleKey: 'inventory.stock'", "owningModuleKey: 'attacker.module'"), validRead.replace("readKey: 'inventory.stock.api.stock-list'", "readKey: 'attacker.read'"), validRead.replace(SCHEMA_VERSION_ONE, "schemaVersion: '2'"), ], @@ -1260,10 +1255,7 @@ export const executeStockList = (payload, requestCorrelation, options) => const clientConfig = {`, ); const invalidClients = [ - validClient.replace( - "from '@app/shared-contracts/client-runtime'", - "from '@modern-js/plugin-bff/effect-client'", - ), + validClient.replace("from '@app/shared-contracts/client-runtime'", "from '@modern-js/plugin-bff/effect-client'"), validClient.replace(GATEWAY_INVOKE_MARKER, GATEWAY_BYPASS_MARKER), validClient.replace(AUTHORIZATION_VALUE, "'x-authorization': Redacted.value(credential)"), validClient.replace(CORRELATION_VALUE, "'x-trace-id': requestCorrelation"), @@ -1275,7 +1267,7 @@ export const executeStockList = (payload, requestCorrelation, options) => validClient.replace(CORRELATION_VALUE, CORRELATION_VALUE_TAIL), validClient.replace(DEFAULT_API_PREFIX, DEFAULT_API_PREFIX_TAIL), validClient.replace( - ' options,\n );', + ` ${OPTIONS_CALL_TAIL}`, " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n clientConfig.transportHeaders['x-correlation-id'] = 'wrong';\n return makeGovernedEffectBffClient(", ), validClient.replace('../../shared/apis/stock-list.ts', '../../shared/api.ts'), @@ -1351,18 +1343,13 @@ const spoof = 'operationGateway.invoke transportHeaders: authorization: x-correl /module APIs require an approved Codesmith generator/u, ); const contractPath = 'verticals/inventory-stock/shared/apis/stock-list.ts'; - const validContract = yield* Effect.promise(() => - readFile(path.join(root, contractPath), 'utf-8'), - ); + const validContract = yield* Effect.promise(() => readFile(path.join(root, contractPath), 'utf-8')); yield* assertRejectedSources( root, contractPath, [ ...problemKinds.map(([, suffix, status]) => - validContract.replace( - `'StockList${suffix}Problem', ${status}`, - `'StockList${suffix}Problem', 200`, - ), + validContract.replace(`'StockList${suffix}Problem', ${status}`, `'StockList${suffix}Problem', 200`), ), validContract.replace( "makeRetryableProblemDetailsSchema('StockListUnavailableProblem'", @@ -1385,16 +1372,10 @@ const spoof = 'operationGateway.invoke transportHeaders: authorization: x-correl .add(HttpApiGroup.make('stockList').add(HttpApiEndpoint.post('execute', '/reads/stock-list', {})))`, ), ); - yield* write( - root, - clientPath, - validClient.replace('client.stockList.execute', 'client.decoy.execute'), - ); + yield* write(root, clientPath, validClient.replace('client.stockList.execute', 'client.decoy.execute')); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1405,16 +1386,10 @@ const spoof = 'operationGateway.invoke transportHeaders: authorization: x-correl `${validContract.replace(".add(HttpApiGroup.make('stockList').add(HttpApiEndpoint.post('execute', '/reads/stock-list', {})))", '')} DecoyApi.add(HttpApiGroup.make('decoy'));`, ); - yield* write( - root, - clientPath, - validClient.replace('client.stockList.execute', 'client.decoy.execute'), - ); + yield* write(root, clientPath, validClient.replace('client.stockList.execute', 'client.decoy.execute')); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1429,9 +1404,7 @@ ${validContract}`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1442,24 +1415,17 @@ ${validContract}`, root, contractPath, [ - ...[ - "HttpApi.make('StockListApi').add(OtherGroup)", - "HttpApi.make('StockListApi').pipe(() => DecoyApi)", - ].map((bypass) => validContract.replace("HttpApi.make('StockListApi')", bypass)), + ...["HttpApi.make('StockListApi').add(OtherGroup)", "HttpApi.make('StockListApi').pipe(() => DecoyApi)"].map( + (bypass) => validContract.replace("HttpApi.make('StockListApi')", bypass), + ), validContract.replace("HttpApi.make('StockListApi')", "HttpApi.make('WrongApi')"), ], /module APIs require an approved Codesmith generator/u, ); - yield* write( - root, - contractPath, - validContract.replace("'/reads/stock-list'", "'/wrong-endpoint'"), - ); + yield* write(root, contractPath, validContract.replace("'/reads/stock-list'", "'/wrong-endpoint'")); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1469,9 +1435,7 @@ ${validContract}`, yield* write(root, contractPath, validContract.replace('})));', '}))).pipe(() => DecoyApi);')); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -1487,25 +1451,18 @@ ${validContract}`, ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - /module APIs require an approved Codesmith generator/u, - ), + expect(String(Cause.squash(cause))).toMatch(/module APIs require an approved Codesmith generator/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, }); yield* write(root, contractPath, validContract); const sharedRootPath = `${INVENTORY_VERTICAL_PATH}/shared/api.ts`; - const sharedRoot = yield* Effect.promise(() => - readFile(path.join(root, sharedRootPath), 'utf-8'), - ); + const sharedRoot = yield* Effect.promise(() => readFile(path.join(root, sharedRootPath), 'utf-8')); yield* write( root, sharedRootPath, - sharedRoot.replace( - "export const endpoint = HttpApiEndpoint.post('listStock', '/stock/list');\n", - '', - ), + sharedRoot.replace("export const endpoint = HttpApiEndpoint.post('listStock', '/stock/list');\n", ''), ); const providerHeader = `// @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider @@ -1517,7 +1474,7 @@ ${validContract}`, apiValue: 'InventoryItemsSearchApi', authorizedOperation: 'loadInventoryItemsClientWithAuthorization', clientHelper: 'inventoryItemsClient', - contractImport: '../../shared/apis/inventory-items-search.ts', + contractImport: INVENTORY_CONTRACT_IMPORT, endpointGroup: 'inventoryItemsSearch', generatedHeader: providerHeader, invocationKind: 'provider', @@ -1545,9 +1502,7 @@ export const inventoryItemsRead = defineRead({ accessKind: 'search', entrypoint: ); const providerManifestPath = 'verticals/inventory-stock/vertical.manifest.ts'; const providerRegistrationPath = INVENTORY_REGISTRATION_PATH; - const providerManifest = yield* Effect.promise(() => - readFile(path.join(root, providerManifestPath), 'utf-8'), - ); + const providerManifest = yield* Effect.promise(() => readFile(path.join(root, providerManifestPath), 'utf-8')); const providerRegistration = yield* Effect.promise(() => readFile(path.join(root, providerRegistrationPath), 'utf-8'), ); @@ -1609,19 +1564,15 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint }, }); yield* write(root, providerContractPath, validProviderContract); - const validProviderManifest = yield* Effect.promise(() => - readFile(path.join(root, providerManifestPath), 'utf-8'), - ); + const validProviderManifest = yield* Effect.promise(() => readFile(path.join(root, providerManifestPath), 'utf-8')); yield* write( root, providerManifestPath, - validProviderManifest - .replace("owningModuleId: 'inventory.stock'", "owningModuleId: 'wrong.owner'") - .replace( - MANIFEST_SEARCH_END, - ` { key: 'inventory.stock.decoy', owningModuleId: 'inventory.stock' }, + validProviderManifest.replace("owningModuleId: 'inventory.stock'", "owningModuleId: 'wrong.owner'").replace( + MANIFEST_SEARCH_END, + ` { key: 'inventory.stock.decoy', owningModuleId: 'inventory.stock' }, // `, - ), + ), ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => @@ -1689,9 +1640,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint }); yield* write(root, providerManifestPath, validProviderManifest); const providerServerPath = 'verticals/inventory-stock/api/inventory-items-search-server.ts'; - const validProviderServer = yield* Effect.promise(() => - readFile(path.join(root, providerServerPath), 'utf-8'), - ); + const validProviderServer = yield* Effect.promise(() => readFile(path.join(root, providerServerPath), 'utf-8')); yield* write( root, providerServerPath, @@ -1714,10 +1663,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint root, providerServerPath, [ - validProviderServer.replace( - AUTHENTICATE_PRINCIPAL_BINDING, - 'authenticatePrincipal: forgedPrincipal', - ), + validProviderServer.replace(AUTHENTICATE_PRINCIPAL_BINDING, 'authenticatePrincipal: forgedPrincipal'), validProviderServer.replace('registration: inventoryItemsRead', 'registration: otherRead'), validProviderServer.replace(' problems,', ' problems, ...overrides,'), validProviderServer.replace(' api,', ' OtherApi,'), @@ -1727,10 +1673,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint 'makeGovernedReadHttpHandler({', 'bypass({', )}\nconst unused = makeGovernedReadHttpHandler({});`, - validProviderServer.replace( - "'@app/core-runtime/http/governed-read'", - "'./fake-handler.ts'", - ), + validProviderServer.replace("'@app/core-runtime/http/governed-read'", "'./fake-handler.ts'"), validProviderServer.replace( 'authentication: InventoryItemsProviderAuthenticationProblemSchema', 'authentication: { make: () => ({ status: 200 }) }', @@ -1740,10 +1683,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint 'unavailable: { make: () => ({ status: 503, retryable: false }) }', ), `${validProviderServer}\nfetch('/bypass');`, - validProviderServer.replace( - providerHeader, - `${providerHeader}import '../db/repository.ts';\n`, - ), + validProviderServer.replace(providerHeader, `${providerHeader}import '../db/repository.ts';\n`), `${validProviderServer}\nexport * from '../db/repository.ts';`, `${validProviderServer}\nexport const bypass = () => fetch('/bypass');`, ], @@ -1804,10 +1744,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint root, providerRegistrationPath, validProviderRegistration - .replace( - " 'inventory-items': () => import('./src/api/inventory-items-search-client.ts'),\n", - '', - ) + .replace(" 'inventory-items': () => import('./src/api/inventory-items-search-client.ts'),\n", '') .replace( ' // ', ` 'inventory-items': () => import('./src/api/inventory-items-search-client.ts'), @@ -1836,16 +1773,11 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint }); yield* write(root, providerContractPath, validProviderContract); const providerSourcePath = 'verticals/inventory-stock/src/search/inventory-items.provider.ts'; - const validProviderSource = yield* Effect.promise(() => - readFile(path.join(root, providerSourcePath), 'utf-8'), - ); + const validProviderSource = yield* Effect.promise(() => readFile(path.join(root, providerSourcePath), 'utf-8')); yield* write( root, providerSourcePath, - validProviderSource.replace( - 'export const inventoryItemsEntrypoint', - 'const inventoryItemsEntrypoint', - ), + validProviderSource.replace('export const inventoryItemsEntrypoint', 'const inventoryItemsEntrypoint'), ); yield* checkModuleEntrypointBoundaries(root); yield* assertRejectedSources( @@ -1856,14 +1788,8 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint "{ kind: 'context_permission', permission: 'module.access' }", "{ kind: 'public' }", ), - validProviderSource.replace( - "owningModuleKey: 'inventory.stock'", - "owningModuleKey: 'attacker.module'", - ), - validProviderSource.replace( - "readKey: 'inventory.stock.search.inventory-items'", - "readKey: 'attacker.read'", - ), + validProviderSource.replace("owningModuleKey: 'inventory.stock'", "owningModuleKey: 'attacker.module'"), + validProviderSource.replace("readKey: 'inventory.stock.search.inventory-items'", "readKey: 'attacker.read'"), validProviderSource.replace(SCHEMA_VERSION_ONE, "schemaVersion: '2'"), ], /generated search and report clients require the shared client runtime/u, @@ -1929,10 +1855,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint [ providerClient.replace('makeGovernedEffectBffClient', 'makeEffectHttpApiClient'), providerClient.replace(GATEWAY_INVOKE_MARKER, GATEWAY_BYPASS_MARKER), - providerClient.replace( - AUTHORIZATION_VALUE, - "'x-authorization': Redacted.value(credential)", - ), + providerClient.replace(AUTHORIZATION_VALUE, "'x-authorization': Redacted.value(credential)"), providerClient.replace(CORRELATION_VALUE, "'x-trace-id': requestCorrelation"), providerClient.replace( CORRELATION_VALUE, @@ -1941,10 +1864,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint providerClient.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), providerClient.replace(CORRELATION_VALUE, CORRELATION_VALUE_TAIL), providerClient.replace(DEFAULT_API_PREFIX, DEFAULT_API_PREFIX_TAIL), - providerClient.replace( - '../../shared/apis/inventory-items-search.ts', - '../../shared/api.ts', - ), + providerClient.replace(INVENTORY_CONTRACT_IMPORT, '../../shared/api.ts'), providerClient.replace('api: InventoryItemsSearchApi', 'api: OtherApi'), providerClient.replace(providerHeader, ''), ], @@ -1989,9 +1909,7 @@ export const stockLevelsRead = defineRead({ accessKind: 'report', entrypoint: st readValue: 'stockLevelsRead', }), ); - const reportManifest = yield* Effect.promise(() => - readFile(path.join(root, providerManifestPath), 'utf-8'), - ); + const reportManifest = yield* Effect.promise(() => readFile(path.join(root, providerManifestPath), 'utf-8')); const reportRegistration = yield* Effect.promise(() => readFile(path.join(root, providerRegistrationPath), 'utf-8'), ); @@ -2026,7 +1944,7 @@ export const stockLevelsRead = defineRead({ accessKind: 'report', entrypoint: st [ reportClient.replace('defaultApiPrefix:', 'bypassedApiPrefix:'), reportClient.replace( - ' options,\n );', + ` ${OPTIONS_CALL_TAIL}`, " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n return makeGovernedEffectBffClient(", ), reportClient.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), @@ -2053,14 +1971,8 @@ export const stockLevelsRead = defineRead({ accessKind: 'report', entrypoint: st }), ); const routeMetadataPath = 'verticals/inventory-stock/src/routes/orders/route.meta.ts'; - const routeMetadata = yield* Effect.promise(() => - readFile(path.join(root, routeMetadataPath), 'utf-8'), - ); - yield* write( - root, - routeMetadataPath, - routeMetadata.replace('inventory-stock', INVENTORY_RUNTIME_ID), - ); + const routeMetadata = yield* Effect.promise(() => readFile(path.join(root, routeMetadataPath), 'utf-8')); + yield* write(root, routeMetadataPath, routeMetadata.replace('inventory-stock', INVENTORY_RUNTIME_ID)); yield* Effect.all( ( [ @@ -2088,9 +2000,7 @@ export const stockLevelsRead = defineRead({ accessKind: 'report', entrypoint: st 'verticals/inventory-stock/src/api/action-gateway.ts', ].map( Effect.fn(function* mergedScenario3(relativePath) { - const boundary = yield* Effect.promise(() => - readFile(path.join(root, relativePath), 'utf-8'), - ); + const boundary = yield* Effect.promise(() => readFile(path.join(root, relativePath), 'utf-8')); yield* write( root, relativePath, @@ -2178,9 +2088,7 @@ for (const violation of violations) { expect( ((error: Error) => { expect(error.message).toMatch(violation.expected); - expect(error.message).toMatch( - new RegExp(violation.file.replaceAll('.', String.raw`\.`), 'u'), - ); + expect(error.message).toMatch(new RegExp(violation.file.replaceAll('.', String.raw`\.`), 'u')); expect(error.message).not.toMatch(/Widget =|endpoint =|registration =/u); return true; })(yield* Effect.flip(checkModuleEntrypointBoundaries(root))), @@ -2201,13 +2109,9 @@ it.live('rejects missing headers, spoofed metadata, and stale generated descript export const stale = true; `, ); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /scaffold:action/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/scaffold:action/u); yield* write(root, ACTION_FILE, `export const ignored = true;`); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /scaffold:action/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/scaffold:action/u); yield* write( root, ACTION_FILE, @@ -2218,9 +2122,7 @@ export const stale = true; export const spoofed = true; `, ); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /scaffold:action/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/scaffold:action/u); yield* write(root, ACTION_FILE, validAction); yield* write( root, @@ -2231,13 +2133,9 @@ export const spoofed = true; export const stale = true; `, ); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /scaffold:outbox-worker/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/scaffold:outbox-worker/u); yield* write(root, WORKER_FILE, `export const ignored = true;`); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /scaffold:outbox-worker/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/scaffold:outbox-worker/u); }), ); @@ -2324,8 +2222,7 @@ it('accepts an exact generated PartyRef contract import', () => { it('rejects a ResourceRef contract with a spoofed Codesmith header', () => { expect(() => assertPublishedPartyUsage({ - readExportSource: () => - partyResourceSource.replace('OntOS Codesmith Resource v1', 'handmade'), + readExportSource: () => partyResourceSource.replace('OntOS Codesmith Resource v1', 'handmade'), }), ).toThrow(/must remain a generated schema-only ResourceRef contract/u); }); @@ -2440,7 +2337,10 @@ it('rejects a published client export that points at backend implementation', () assertPublishedPartyUsage({ dependencyPackageJson: { ...partyPackage, - exports: { ...partyPackage.exports, [PARTY_API_CLIENT_EXPORT]: './api/index.ts' }, + exports: { + ...partyPackage.exports, + [PARTY_API_CLIENT_EXPORT]: './api/index.ts', + }, }, moduleSpecifiers: [PARTY_API_CLIENT_SPECIFIER], }), @@ -2473,9 +2373,7 @@ it('rejects an aggregate whose client leaf has no Codesmith metadata', () => { assertPublishedPartyUsage({ moduleSpecifiers: [PARTY_API_CLIENT_SPECIFIER], readExportSource: (target) => - target === PARTY_CLIENT_SOURCE_PATH - ? partyClientSource - : 'export const executePartyDetail = true;\n', + target === PARTY_CLIENT_SOURCE_PATH ? partyClientSource : 'export const executePartyDetail = true;\n', }), ).toThrow(/must remain a generated public Effect client aggregate/u); }); @@ -2607,17 +2505,12 @@ it.live('rejects missing, orphaned, and cross-owner route manifest entries', () 'apps/shell-super-app/src/routes/ultramodern-route-metadata.ts', `export const routes = [{ entrypoint: { entrypointKey: 'inventory.stock.page.orders' } }];`, ); - expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch( - /manifest is stale/u, - ); + expect(String(yield* Effect.flip(checkModuleEntrypointBoundaries(root)))).toMatch(/manifest is stale/u); }), ); for (const [binding, disconnected] of [ - [ - 'authenticatePrincipal: authenticateOperationPrincipal', - 'authenticatePrincipal: disconnectedPrincipal', - ], + ['authenticatePrincipal: authenticateOperationPrincipal', 'authenticatePrincipal: disconnectedPrincipal'], [STOCK_LIST_READ_BINDING, 'registration: unrelatedRead'], ] as const) { it.live( @@ -2640,10 +2533,7 @@ for (const [binding, disconnected] of [ yield* write( root, serverFile, - server.replace( - "from '@app/core-runtime/http/governed-read'", - "from './unused-neighbor.ts'", - ), + server.replace("from '@app/core-runtime/http/governed-read'", "from './unused-neighbor.ts'"), ); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/module APIs require/u), @@ -2685,23 +2575,16 @@ it.live( const root = yield* makeFixture(); const contractFile = 'apps/shell-super-app/shared/api.ts'; const gatewayFile = GATEWAY_CONTRACT_FILE; - const source = yield* Effect.promise(() => - readFile(new URL(`../../${contractFile}`, import.meta.url), 'utf-8'), - ); + const source = yield* Effect.promise(() => readFile(new URL(`../../${contractFile}`, import.meta.url), 'utf-8')); const gatewaySource = yield* Effect.promise(() => readFile(new URL(`../../${gatewayFile}`, import.meta.url), 'utf-8'), ); yield* write(root, contractFile, source); yield* write(root, gatewayFile, gatewaySource); yield* checkModuleEntrypointBoundaries(root); - yield* write( - root, - contractFile, - source.replace('.add(GatewayContextApiGroup)', '.add(UnrelatedApiGroup)'), - ); + yield* write(root, contractFile, source.replace('.add(GatewayContextApiGroup)', '.add(UnrelatedApiGroup)')); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { - onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch(/mounted gateway contract/u), + onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/mounted gateway contract/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -2711,8 +2594,8 @@ it.live( ); for (const [before, after] of [ - ["post('issueGatewayContext'", "post('unusedNeighbor'"], - ["post('issueApiKeyGatewayContext'", "post('unusedNeighbor'"], + [/post\(\s*'issueGatewayContext'/u, "post('unusedNeighbor'"], + [/post\(\s*'issueApiKeyGatewayContext'/u, "post('unusedNeighbor'"], ["'/auth/gateway-context'", "'/auth/tampered'"], ["'/auth/api-key/gateway-context'", "'/auth/tampered'"], ["make('gatewayContext')", "make('unrelated')"], @@ -2726,11 +2609,10 @@ for (const [before, after] of [ const gatewaySource = yield* Effect.promise(() => readFile(new URL(`../../${gatewayFile}`, import.meta.url), 'utf-8'), ); - expect(gatewaySource.includes(before)).toBeTruthy(); + expect(gatewaySource.replace(before, after)).not.toBe(gatewaySource); yield* write(root, gatewayFile, gatewaySource.replace(before, after)); yield* Effect.matchCause(checkModuleEntrypointBoundaries(root), { - onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch(/mounted gateway contract/u), + onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/mounted gateway contract/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, diff --git a/app/scripts/tests/outbox-worker-delivery.test.mts b/app/scripts/tests/outbox-worker-delivery.test.mts index 01c5298ae..04e39da22 100644 --- a/app/scripts/tests/outbox-worker-delivery.test.mts +++ b/app/scripts/tests/outbox-worker-delivery.test.mts @@ -1,13 +1,15 @@ -import { expect, it } from 'effect-rstest'; import { spawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; import { once } from 'node:events'; -import type { Readable } from 'node:stream'; +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import type { Readable } from 'node:stream'; + import { Cause, Deferred, Effect, Exit, Fiber, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { generateOutboxWorkerDeployment } from '../generate-outbox-worker-deployment.mjs'; import { materializeOutboxWorker } from '../materialize-outbox-worker.mjs'; @@ -52,9 +54,9 @@ const scopedChild = (start: () => Child) => const readWorkerOutput = (child: { readonly stdout: Readable }) => Effect.gen(function* awaitWorkerOutput() { - const dataEvent: unknown = yield* Effect.tryPromise((signal) => - once(child.stdout, 'data', { signal }), - ).pipe(Effect.timeout('3 seconds')); + const dataEvent: unknown = yield* Effect.tryPromise((signal) => once(child.stdout, 'data', { signal })).pipe( + Effect.timeout('3 seconds'), + ); const [output] = decodeDataEvent(dataEvent); return String(output); }); @@ -66,7 +68,9 @@ const makeFixture = () => (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); yield* Effect.tryPromise(() => - mkdir(path.join(root, LEDGER_PATH, 'src/worker-host'), { recursive: true }), + mkdir(path.join(root, LEDGER_PATH, 'src/worker-host'), { + recursive: true, + }), ); yield* Effect.tryPromise(() => mkdir(path.join(root, 'topology'), { recursive: true })); yield* Effect.tryPromise(() => @@ -74,7 +78,9 @@ const makeFixture = () => path.join(root, LEDGER_PATH, 'package.json'), JSON.stringify({ name: LEDGER_PACKAGE, - scripts: { 'worker:start': `node --experimental-strip-types ./${WORKER_HOST_ENTRY}` }, + scripts: { + 'worker:start': `node --experimental-strip-types ./${WORKER_HOST_ENTRY}`, + }, type: 'module', }), ), @@ -92,7 +98,9 @@ const makeFixture = () => verticals: [ { id: 'ledger', - moduleFederation: { manifestUrl: 'http://localhost:4110/mf-manifest.json' }, + moduleFederation: { + manifestUrl: 'http://localhost:4110/mf-manifest.json', + }, package: LEDGER_PACKAGE, path: LEDGER_PATH, }, @@ -115,9 +123,7 @@ it.live('generates a separate supervised worker setup without changing owner con expect(generated.split("setup: 'ledger-worker'")[1]).not.toMatch(/ run build/u); expect(generated.split("setup: 'ledger-worker'")[1]).not.toMatch(/(?:^|\s)&(?:\s|$)/u); expect(generated.match(/ONTOS_KEEP_ME: 'true'/gu)?.length).toBe(2); - expect(yield* Effect.tryPromise(() => generateOutboxWorkerDeployment(root, generated))).toBe( - generated, - ); + expect(yield* Effect.tryPromise(() => generateOutboxWorkerDeployment(root, generated))).toBe(generated); }), ); @@ -165,9 +171,7 @@ it.live('materializes and starts a relocatable production worker artifact', () = ), ); expect(artifact.serviceId).toBe('ledger-worker'); - expect(artifact.sourceInputs.some((input: string) => input.endsWith(WORKER_HOST_ENTRY))).toBe( - true, - ); + expect(artifact.sourceInputs.some((input: string) => input.endsWith(WORKER_HOST_ENTRY))).toBe(true); const runtime = yield* scopedChild(() => spawn(process.execPath, ['worker.mjs'], { cwd: path.join(root, '.zerops/runtime/ledger-worker'), @@ -179,24 +183,18 @@ it.live('materializes and starts a relocatable production worker artifact', () = }), ); -it.live( - 'keeps the live Party Registry worker deployment generated and independently supervised', - () => - Effect.gen(function* testEffect4() { - const root = process.cwd(); - const source = yield* Effect.tryPromise(() => - readFile(path.join(root, 'zerops.yaml'), 'utf-8'), - ); - expect(source).not.toContain('run zerops:materialize -- --app'); - expect(yield* Effect.tryPromise(() => generateOutboxWorkerDeployment(root, source))).toBe( - source, - ); - const [, worker] = source.split("setup: 'party-registry-worker'"); - expect(worker).toMatch(/DATABASE_URL: \$\{partyregistry_DATABASE_URL\}/u); - expect(worker).toMatch(/OUTBOX_WORKER_HEALTH_PORT: '4102'/u); - expect(worker).toMatch(/cd app\/\.zerops\/runtime\/party-registry-worker/u); - expect(worker).not.toMatch(/(?:^|\s)&(?:\s|$)/u); - }), +it.live('keeps the live Party Registry worker deployment generated and independently supervised', () => + Effect.gen(function* testEffect4() { + const root = process.cwd(); + const source = yield* Effect.tryPromise(() => readFile(path.join(root, 'zerops.yaml'), 'utf-8')); + expect(source).not.toContain('run zerops:materialize -- --app'); + expect(yield* Effect.tryPromise(() => generateOutboxWorkerDeployment(root, source))).toBe(source); + const [, worker] = source.split("setup: 'party-registry-worker'"); + expect(worker).toMatch(/DATABASE_URL: \$\{partyregistry_DATABASE_URL\}/u); + expect(worker).toMatch(/OUTBOX_WORKER_HEALTH_PORT: '4102'/u); + expect(worker).toMatch(/cd app\/\.zerops\/runtime\/party-registry-worker/u); + expect(worker).not.toMatch(/(?:^|\s)&(?:\s|$)/u); + }), ); it.live('bundles the real Party host including the production Effect HTTP health adapter', () => @@ -214,18 +212,14 @@ it.live('bundles the real Party host including the production Effect HTTP health workspaceRoot: process.cwd(), }), ); - const bundle = yield* Effect.tryPromise(() => - readFile(path.join(runtimeDir, 'worker.mjs'), 'utf-8'), - ); + const bundle = yield* Effect.tryPromise(() => readFile(path.join(runtimeDir, 'worker.mjs'), 'utf-8')); const artifact = decodeWorkerArtifact( - yield* Effect.tryPromise(() => - readFile(path.join(runtimeDir, 'worker-artifact.json'), 'utf-8'), - ), + yield* Effect.tryPromise(() => readFile(path.join(runtimeDir, 'worker-artifact.json'), 'utf-8')), ); expect(artifact.sourceInputs.includes('packages/core-runtime/src/outbox/health.ts')).toBe(true); expect(bundle).toMatch(/@effect\/platform-node\/NodeHttpServer/u); expect(bundle).not.toMatch(/from ["']@effect\/platform-node["']/u); - expect(runtimePackage.dependencies['@effect/platform-node']).toBe('4.0.0-beta.107'); + expect(runtimePackage.dependencies['@effect/platform-node']).toBe('4.0.0-rc.112'); }), ); @@ -250,10 +244,7 @@ const runCleanupControl = ( const child = yield* scopedChild(() => spawn( process.execPath, - [ - '-e', - 'process.on("SIGTERM", () => {}); console.log("unexpected-startup"); setInterval(() => {}, 1000);', - ], + ['-e', 'process.on("SIGTERM", () => {}); console.log("unexpected-startup"); setInterval(() => {}, 1000);'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], @@ -272,12 +263,13 @@ const runCleanupControl = ( const cleanupControl = (interrupt: boolean) => Effect.gen(function* verifyChildCleanup() { - const started = yield* Deferred.make<{ child: ChildProcess; root: string }>(); + const started = yield* Deferred.make<{ + child: ChildProcess; + root: string; + }>(); const ready = yield* Deferred.make(); const cleanupOrder = yield* Deferred.make(); - const worker = yield* Effect.forkChild( - Effect.scoped(runCleanupControl(interrupt, started, ready, cleanupOrder)), - ); + const worker = yield* Effect.forkChild(Effect.scoped(runCleanupControl(interrupt, started, ready, cleanupOrder))); const { child, root } = yield* Deferred.await(started).pipe(Effect.timeout('5 seconds')); if (interrupt) { yield* Deferred.await(ready).pipe(Effect.timeout('5 seconds')); @@ -298,8 +290,6 @@ const cleanupControl = (interrupt: boolean) => expect(existsSync(root)).toBe(false); }); -it.live('reaps a worker before removing its fixture after failed startup output', () => - cleanupControl(false), -); +it.live('reaps a worker before removing its fixture after failed startup output', () => cleanupControl(false)); it.live('reaps a worker before removing its fixture on interruption', () => cleanupControl(true)); diff --git a/app/scripts/tests/plan-deployment-impact.test.mts b/app/scripts/tests/plan-deployment-impact.test.mts index 0c0179f5e..237a20302 100644 --- a/app/scripts/tests/plan-deployment-impact.test.mts +++ b/app/scripts/tests/plan-deployment-impact.test.mts @@ -1,19 +1,18 @@ -import { expect, it } from 'effect-rstest'; import { execFileSync } from 'node:child_process'; import { access, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; + import { NodeServices } from '@effect/platform-node'; import { Cause, Effect, Exit } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { hashAuthorizationEvidence } from '../check-authorization-readiness.mts'; import { planDeploymentImpact as planDeploymentImpactEffect, validateAuthorizationPromotionGate, } from '../plan-deployment-impact.mts'; -import type { - AuthorizationPromotionGateInput, - PlanDeploymentImpactOptions, -} from '../plan-deployment-impact.mts'; +import type { AuthorizationPromotionGateInput, PlanDeploymentImpactOptions } from '../plan-deployment-impact.mts'; const planningFailure = (effect: Effect.Effect) => effect.pipe( @@ -94,17 +93,14 @@ const writeJson = (root: string, relativePath: string, value: FixtureDocument) = Effect.gen(function* testEffect1() { const target = path.join(root, relativePath); yield* Effect.tryPromise(() => mkdir(path.dirname(target), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(target, `${JSON.stringify(value, undefined, 2)}\n`, 'utf-8'), - ); + yield* Effect.tryPromise(() => writeFile(target, `${JSON.stringify(value, undefined, 2)}\n`, 'utf-8')); }); const makeFixture = (options: FixtureOptions = {}) => Effect.gen(function* testEffect2() { const root = yield* Effect.acquireRelease( Effect.tryPromise(() => mkdtemp(path.join(os.tmpdir(), 'ontos-deployment-impact-'))), - (directory) => - Effect.tryPromise(() => rm(directory, { force: true, recursive: true })).pipe(Effect.orDie), + (directory) => Effect.tryPromise(() => rm(directory, { force: true, recursive: true })).pipe(Effect.orDie), ); const verticalId = options.verticalId ?? 'contacts'; const verticalPackage = `@app/${verticalId}`; @@ -139,9 +135,7 @@ const makeFixture = (options: FixtureOptions = {}) => }); if (options.includeWorker === true) { const workerRoot = path.join(root, verticalPath); - yield* Effect.tryPromise(() => - mkdir(path.join(workerRoot, 'src/worker-host'), { recursive: true }), - ); + yield* Effect.tryPromise(() => mkdir(path.join(workerRoot, 'src/worker-host'), { recursive: true })); yield* Effect.tryPromise(() => writeFile( path.join(workerRoot, 'package.json'), @@ -163,16 +157,11 @@ const makeFixture = (options: FixtureOptions = {}) => 'shellsuperapp', ]; const setupLines = setups.map((setup) => ` - setup: '${setup}'`).join('\n'); - yield* Effect.tryPromise(() => - writeFile(path.join(root, 'zerops.yaml'), `zerops:\n${setupLines}\n`, 'utf-8'), - ); + yield* Effect.tryPromise(() => writeFile(path.join(root, 'zerops.yaml'), `zerops:\n${setupLines}\n`, 'utf-8')); return root; }); -const withFixture = ( - run: (root: string) => Effect.Effect, - options?: FixtureOptions, -) => +const withFixture = (run: (root: string) => Effect.Effect, options?: FixtureOptions) => Effect.gen(function* testEffect3() { const root = yield* makeFixture(options); yield* run(root); @@ -250,12 +239,7 @@ it.live('orders authorization schema and replay migration before every affected changedPaths: ['app/scripts/authorization/rollout-contract.mts'], rootDirectory: root, }); - expect(plan.phases.map(({ id }) => id)).toEqual([ - 'migrator', - 'spicedb', - 'contacts', - SHELL_ID, - ]); + expect(plan.phases.map(({ id }) => id)).toEqual(['migrator', 'spicedb', 'contacts', SHELL_ID]); }), ); }), @@ -374,12 +358,7 @@ for (const changedPath of [ changedPaths: [changedPath], rootDirectory: root, }); - expect(plan.phases.map((phase) => phase.id)).toEqual([ - 'migrator', - 'spicedb', - 'contacts', - SHELL_ID, - ]); + expect(plan.phases.map((phase) => phase.id)).toEqual(['migrator', 'spicedb', 'contacts', SHELL_ID]); }), ); }), @@ -408,16 +387,11 @@ it.live('fails closed for the unknown destination of a renamed application direc expect( yield* planningFailure( planDeploymentImpact({ - changedPaths: [ - 'verticals/contacts/src/index.ts', - 'verticals/relationships/src/index.ts', - ], + changedPaths: ['verticals/contacts/src/index.ts', 'verticals/relationships/src/index.ts'], rootDirectory: root, }), ), - ).toMatch( - /unknown changed path "verticals\/relationships\/src\/index\.ts" in application area "verticals"/u, - ); + ).toMatch(/unknown changed path "verticals\/relationships\/src\/index\.ts" in application area "verticals"/u); }), ); }), @@ -430,7 +404,10 @@ it.live('fails closed when a topology delivery unit has no ownership entry', () Effect.gen(function* testEffect31() { expect( yield* planningFailure( - planDeploymentImpact({ changedPaths: [DOCUMENTATION_PATH], rootDirectory: root }), + planDeploymentImpact({ + changedPaths: [DOCUMENTATION_PATH], + rootDirectory: root, + }), ), ).toMatch(/topology delivery unit "contacts" is missing from topology\/ownership\.json/u); }), @@ -448,12 +425,19 @@ it.live('fails closed when topology and ownership identities disagree', () => CORE_RUNTIME_OWNER, SHARED_CONTRACTS_OWNER, SHELL_OWNER, - { id: 'contacts', package: '@app/contacts-old', path: 'verticals/contacts-old' }, + { + id: 'contacts', + package: '@app/contacts-old', + path: 'verticals/contacts-old', + }, ], }); expect( yield* planningFailure( - planDeploymentImpact({ changedPaths: [DOCUMENTATION_PATH], rootDirectory: root }), + planDeploymentImpact({ + changedPaths: [DOCUMENTATION_PATH], + rootDirectory: root, + }), ), ).toMatch(/topology and ownership disagree for "contacts"/u); }), @@ -470,12 +454,19 @@ it.live('fails closed when shared-package topology and ownership identities disa { ...CORE_RUNTIME_OWNER, path: 'packages/core-runtime-old' }, SHARED_CONTRACTS_OWNER, SHELL_OWNER, - { id: 'contacts', package: '@app/contacts', path: 'verticals/contacts' }, + { + id: 'contacts', + package: '@app/contacts', + path: 'verticals/contacts', + }, ], }); expect( yield* planningFailure( - planDeploymentImpact({ changedPaths: [DOCUMENTATION_PATH], rootDirectory: root }), + planDeploymentImpact({ + changedPaths: [DOCUMENTATION_PATH], + rootDirectory: root, + }), ), ).toMatch(/topology and ownership disagree for shared package "core-runtime"/u); }), @@ -490,7 +481,10 @@ it.live('fails closed when a topology unit has no supported stage setup', () => Effect.gen(function* testEffect37() { expect( yield* planningFailure( - planDeploymentImpact({ changedPaths: [DOCUMENTATION_PATH], rootDirectory: root }), + planDeploymentImpact({ + changedPaths: [DOCUMENTATION_PATH], + rootDirectory: root, + }), ), ).toMatch(/topology delivery unit "contacts" has unsupported stage setup "contacts"/u); }), @@ -510,12 +504,7 @@ it.live('uses a safe full deployment for an all-zero comparison base', () => }); expect(plan.comparison.mode).toBe('full'); expect(plan.comparison.reason ?? '').toMatch(/all-zero/u); - expect(plan.phases.map((phase) => phase.id)).toEqual([ - 'migrator', - 'spicedb', - 'contacts', - SHELL_ID, - ]); + expect(plan.phases.map((phase) => phase.id)).toEqual(['migrator', 'spicedb', 'contacts', SHELL_ID]); }), ); }), @@ -531,9 +520,7 @@ it.live('uses a safe full deployment for an unavailable comparison base', () => rootDirectory: root, }); expect(plan.comparison.mode).toBe('full'); - expect(plan.comparison.reason ?? '').toMatch( - /comparison base "missing-base-revision" is unavailable/u, - ); + expect(plan.comparison.reason ?? '').toMatch(/comparison base "missing-base-revision" is unavailable/u); }), ); }), @@ -547,16 +534,12 @@ it.live('uses a safe full deployment when the comparison base is not an ancestor runGit(root, ['add', '.']); runGit(root, ['commit', '-m', 'fixture root']); const rootRevision = runGit(root, ['rev-parse', 'HEAD']); - yield* Effect.tryPromise(() => - writeFile(path.join(root, 'main-marker.txt'), 'main\n', 'utf-8'), - ); + yield* Effect.tryPromise(() => writeFile(path.join(root, 'main-marker.txt'), 'main\n', 'utf-8')); runGit(root, ['add', 'main-marker.txt']); runGit(root, ['commit', '-m', 'main change']); const rewrittenBase = runGit(root, ['rev-parse', 'HEAD']); runGit(root, ['checkout', '-b', 'rewritten', rootRevision]); - yield* Effect.tryPromise(() => - writeFile(path.join(root, 'rewritten-marker.txt'), 'rewritten\n', 'utf-8'), - ); + yield* Effect.tryPromise(() => writeFile(path.join(root, 'rewritten-marker.txt'), 'rewritten\n', 'utf-8')); runGit(root, ['add', 'rewritten-marker.txt']); runGit(root, ['commit', '-m', 'rewritten change']); @@ -659,25 +642,19 @@ const promotionFixture = (): AuthorizationPromotionGateInput => { }; }; -const withoutImpactEvidence = ( - input: AuthorizationPromotionGateInput, -): AuthorizationPromotionGateInput => { +const withoutImpactEvidence = (input: AuthorizationPromotionGateInput): AuthorizationPromotionGateInput => { const { impact, ...remaining } = input; expect(impact !== undefined).toBe(true); return remaining; }; -const withoutNegativeSmokeEvidence = ( - input: AuthorizationPromotionGateInput, -): AuthorizationPromotionGateInput => { +const withoutNegativeSmokeEvidence = (input: AuthorizationPromotionGateInput): AuthorizationPromotionGateInput => { const { negativeSmoke, ...remaining } = input; expect(negativeSmoke !== undefined).toBe(true); return remaining; }; -const withoutReadinessEvidence = ( - input: AuthorizationPromotionGateInput, -): AuthorizationPromotionGateInput => { +const withoutReadinessEvidence = (input: AuthorizationPromotionGateInput): AuthorizationPromotionGateInput => { const { readiness, ...remaining } = input; expect(readiness !== undefined).toBe(true); return remaining; @@ -722,7 +699,10 @@ it('report-only promotion is bounded, explicit-baseline-only, and never allowed }; expect(validateAuthorizationPromotionGate(reportOnly).status).toBe('observing'); expect(() => - validateAuthorizationPromotionGate({ ...reportOnly, environment: 'production' }), + validateAuthorizationPromotionGate({ + ...reportOnly, + environment: 'production', + }), ).toThrow(/production.*report-only/u); expect(() => validateAuthorizationPromotionGate({ diff --git a/app/scripts/tests/protected-entrypoint-inventory.test.mts b/app/scripts/tests/protected-entrypoint-inventory.test.mts index 9fadec15e..243afdc52 100644 --- a/app/scripts/tests/protected-entrypoint-inventory.test.mts +++ b/app/scripts/tests/protected-entrypoint-inventory.test.mts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import { makeProtectedEntrypointInventory, serializeProtectedEntrypointInventory, @@ -27,9 +28,7 @@ const entries = [ it('inventory normalization, hashing, and serialization are deterministic', () => { const left = makeProtectedEntrypointInventory('revision', entries); const right = makeProtectedEntrypointInventory('revision', [entries[1], entries[0]]); - expect(serializeProtectedEntrypointInventory(left)).toBe( - serializeProtectedEntrypointInventory(right), - ); + expect(serializeProtectedEntrypointInventory(left)).toBe(serializeProtectedEntrypointInventory(right)); expect(left.inventoryHash).toMatch(/^[a-f0-9]{64}$/u); expect(left.entries.map((entry) => entry.surface)).toEqual(['action', 'route']); }); @@ -39,9 +38,7 @@ it('inventory rejects duplicate and unsafe entrypoint identities', () => { /duplicate protected entrypoint/u, ); expect(() => - makeProtectedEntrypointInventory('revision', [ - { ...entries[0], entrypointKey: 'tenant@example.com' }, - ]), + makeProtectedEntrypointInventory('revision', [{ ...entries[0], entrypointKey: 'tenant@example.com' }]), ).toThrow(/stable, non-sensitive identifier/u); }); @@ -62,7 +59,10 @@ it('inventory rejects malformed and excess authorization classification data', ( makeProtectedEntrypointInventory('revision', [ { ...entries[0], - authorization: { kind: 'context_permission', permission: 'tenant@example.com' }, + authorization: { + kind: 'context_permission', + permission: 'tenant@example.com', + }, }, ]), ).toThrow(/classification is invalid/u); diff --git a/app/scripts/tests/provision-current-action-authorization.test.mts b/app/scripts/tests/provision-current-action-authorization.test.mts index f92f67c27..1b04ed4f0 100644 --- a/app/scripts/tests/provision-current-action-authorization.test.mts +++ b/app/scripts/tests/provision-current-action-authorization.test.mts @@ -1,15 +1,13 @@ -import type { deriveOntosModuleDeploymentContract as DeriveModuleContract } from '../generate-ontos-module-contract.mts'; - -import { expect, it } from 'effect-rstest'; -import { NodeServices } from '@effect/platform-node'; - import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; - import { pathToFileURL } from 'node:url'; + import { v1 } from '@authzed/authzed-node'; +import { NodeServices } from '@effect/platform-node'; import { Cause, Effect, Option, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID, ActionAuthorizationProvisioningError, @@ -20,9 +18,9 @@ import type { ActionAuthorizationContext, ActionAuthorizationProvisioningClient, } from '../../packages/core-runtime/src/install/action-authorization-provisioning.ts'; -import { toSpiceDbActionObjectId } from '../../packages/core-runtime/src/permissions/service.ts'; import type { SpiceDbConfigValue } from '../../packages/core-runtime/src/permissions/config.ts'; - +import { toSpiceDbActionObjectId } from '../../packages/core-runtime/src/permissions/service.ts'; +import type { deriveOntosModuleDeploymentContract as DeriveModuleContract } from '../generate-ontos-module-contract.mts'; import { LOCAL_DEVELOPMENT_CONTEXT } from '../initialize-local-development.mts'; import { formatActionAuthorizationProvisioningFailure, @@ -105,9 +103,7 @@ const failureOf = (effect: Effect.Effect( - effect: Effect.Effect, -) => +const rejectionOf = (effect: Effect.Effect) => effect.pipe( Effect.matchCause({ onFailure: Cause.squash, @@ -120,8 +116,7 @@ const rejectionOf = ( it.effect( 'selects only exact source-controlled development and stage targets', Effect.fn(function* testEffect2() { - const development = - yield* selectActionAuthorizationProvisioningTarget(developmentConfiguration); + const development = yield* selectActionAuthorizationProvisioningTarget(developmentConfiguration); expect(development.environment).toBe('development'); expect(development.contexts).toEqual([ { @@ -135,8 +130,7 @@ it.effect( expect(stage.contexts.length).toBe(2); const { deploymentEnvironment: _environment, ...withoutEnvironment } = developmentConfiguration; - const implicitDevelopment = - yield* selectActionAuthorizationProvisioningTarget(withoutEnvironment); + const implicitDevelopment = yield* selectActionAuthorizationProvisioningTarget(withoutEnvironment); expect(implicitDevelopment.contexts).toEqual(development.contexts); expect(implicitDevelopment.environment).toBe('development'); @@ -160,9 +154,7 @@ it.effect( { ...stageConfiguration, insecureLocal: false }, ].map((configuration) => Effect.gen(function* testEffect3() { - const error = yield* failureOf( - selectActionAuthorizationProvisioningTarget(configuration), - ); + const error = yield* failureOf(selectActionAuthorizationProvisioningTarget(configuration)); expect(error.code).toBe('action_authorization_configuration_invalid'); expect(error.reason).not.toMatch(new RegExp(testPreSharedKey, 'u')); }), @@ -196,9 +188,7 @@ it.effect( () => Effect.die(new Error(`client.close failed with ${testPreSharedKey}`)), ), ); - expect(formatActionAuthorizationProvisioningFailure(unexpectedRejection)).toBe( - unexpectedMessage, - ); + expect(formatActionAuthorizationProvisioningFailure(unexpectedRejection)).toBe(unexpectedMessage); }), ); @@ -214,8 +204,7 @@ it.effect( expect(start !== -1 && end > start).toBe(true); const block = source.slice(start, end); const scripts = { - 'authorization:provision-current-actions': - 'node ./scripts/provision-current-action-authorization.mts', + 'authorization:provision-current-actions': 'node ./scripts/provision-current-action-authorization.mts', 'local:initialize': 'node ./scripts/initialize-local-development.mts', }; const validationRoot = yield* Effect.acquireRelease( @@ -223,10 +212,7 @@ it.effect( (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); let validationIndex = 0; - const validate = ( - sources: Readonly>, - overrides: Readonly> = {}, - ) => + const validate = (sources: Readonly>, overrides: Readonly> = {}) => Effect.gen(function* testEffect6() { const modulePath = path.join(validationRoot, `validation-${validationIndex}.mjs`); validationIndex += 1; @@ -311,16 +297,14 @@ it.effect( Effect.fn(function* testEffect7() { const workspaceRoot = path.resolve(import.meta.dirname, '../..'); const { discoverCurrentActionKeys } = yield* Effect.promise( - (): Promise<{ readonly discoverCurrentActionKeys: typeof DiscoverCurrentActionKeys }> => - import( - pathToFileURL( - path.resolve(import.meta.dirname, '../provision-current-action-authorization.mts'), - ).href - ), + (): Promise<{ + readonly discoverCurrentActionKeys: typeof DiscoverCurrentActionKeys; + }> => + import(pathToFileURL(path.resolve(import.meta.dirname, '../provision-current-action-authorization.mts')).href), + ); + expect(yield* discoverCurrentActionKeys(workspaceRoot).pipe(Effect.provide(NodeServices.layer))).toEqual( + currentActionKeys, ); - expect( - yield* discoverCurrentActionKeys(workspaceRoot).pipe(Effect.provide(NodeServices.layer)), - ).toEqual(currentActionKeys); expect(new Set(currentActionKeys).size).toBe(38); expect(currentActionKeys.filter((key) => key.startsWith('core.')).length).toBe(8); expect(currentActionKeys.filter((key) => key.startsWith('party.registry.')).length).toBe(30); @@ -330,17 +314,10 @@ it.effect( it.effect( 'builds lossless, deterministic Tenant-membership grants for development and stage', Effect.fn(function* testEffect8() { - const development = - yield* selectActionAuthorizationProvisioningTarget(developmentConfiguration); + const development = yield* selectActionAuthorizationProvisioningTarget(developmentConfiguration); const stage = yield* selectActionAuthorizationProvisioningTarget(stageConfiguration); - const developmentRelationships = buildActionAuthorizationRelationships( - currentActionKeys, - development.contexts, - ); - const stageRelationships = buildActionAuthorizationRelationships( - currentActionKeys, - stage.contexts, - ); + const developmentRelationships = buildActionAuthorizationRelationships(currentActionKeys, development.contexts); + const stageRelationships = buildActionAuthorizationRelationships(currentActionKeys, stage.contexts); expect(developmentRelationships.length).toBe(38); expect(stageRelationships.length).toBe(76); @@ -354,16 +331,10 @@ it.effect( ({ resource, subject }) => `${resource?.objectId}:${subject?.object?.objectId}`, ); expect( - identifiers.every( - (identifier, index) => - index === 0 || identifiers[index - 1]?.localeCompare(identifier) <= 0, - ), + identifiers.every((identifier, index) => index === 0 || identifiers[index - 1]?.localeCompare(identifier) <= 0), ).toBe(true); expect( - Buffer.from( - toSpiceDbActionObjectId(attachPersonEngagementAction).slice(3), - 'base64url', - ).toString('utf-8'), + Buffer.from(toSpiceDbActionObjectId(attachPersonEngagementAction).slice(3), 'base64url').toString('utf-8'), ).toBe(attachPersonEngagementAction); expect(toSpiceDbActionObjectId(attachPersonEngagementAction)).not.toBe( toSpiceDbActionObjectId('contacts-core-attach-person-engagement'), @@ -398,15 +369,10 @@ const hasActionGrant = ( principalId: string, tenantId: string | undefined, ): boolean => - grants.has(`${resourceId}:${principalId}`) || - (tenantId !== undefined && grants.has(`${resourceId}:${tenantId}`)); - -const makeProvisioningClient = ( - contexts: readonly ActionAuthorizationContext[], -): ProvisioningClientFixture => { - const principalTenants = new Map( - contexts.map(({ principalId, tenantId }) => [principalId, tenantId]), - ); + grants.has(`${resourceId}:${principalId}`) || (tenantId !== undefined && grants.has(`${resourceId}:${tenantId}`)); + +const makeProvisioningClient = (contexts: readonly ActionAuthorizationContext[]): ProvisioningClientFixture => { + const principalTenants = new Map(contexts.map(({ principalId, tenantId }) => [principalId, tenantId])); const state: ProvisioningClientState = { grants: new Set(), relationshipWriteCount: 0, @@ -464,12 +430,8 @@ it.effect( expect(state.relationshipWriteCount).toBe(2); expect(state.grants.size).toBe(38); expect(state.updates.length).toBe(76); - expect( - state.updates.every(({ operation }) => operation === v1.RelationshipUpdate_Operation.TOUCH), - ).toBe(true); - expect( - ![...state.grants].some((grant) => grant.includes(ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID)), - ).toBe(true); + expect(state.updates.every(({ operation }) => operation === v1.RelationshipUpdate_Operation.TOUCH)).toBe(true); + expect(![...state.grants].some((grant) => grant.includes(ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID))).toBe(true); }), ); @@ -527,7 +489,10 @@ it.effect( { actionKey: restrictedAction, assertions: [ - { expected: 'allowed' as const, principalId: target.contexts[0]?.principalId ?? '' }, + { + expected: 'allowed' as const, + principalId: target.contexts[0]?.principalId ?? '', + }, ], }, ], @@ -535,7 +500,10 @@ it.effect( { actionKey: 'core.identity.unknown', assertions: [ - { expected: 'allowed' as const, principalId: target.contexts[0]?.principalId ?? '' }, + { + expected: 'allowed' as const, + principalId: target.contexts[0]?.principalId ?? '', + }, { expected: 'denied' as const, principalId: ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID, @@ -660,9 +628,7 @@ it.effect( const target = yield* selectActionAuthorizationProvisioningTarget(developmentConfiguration); const unavailable: ActionAuthorizationProvisioningClient = { checkPermission: () => - Effect.succeed( - Option.some(response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION)), - ), + Effect.succeed(Option.some(response(v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION))), writeRelationships: () => Effect.succeed(v1.WriteRelationshipsResponse.create({})), writeSchema: () => Effect.fail(upstreamFailure), }; @@ -680,23 +646,21 @@ it.effect( const writeInventory = ( root: string, - verticals: readonly { readonly id: string; readonly package: string; readonly path: string }[], + verticals: readonly { + readonly id: string; + readonly package: string; + readonly path: string; + }[], ) => Effect.gen(function* testEffect18() { yield* Effect.tryPromise(() => mkdir(path.join(root, 'topology'), { recursive: true })); yield* Effect.all( [ Effect.promise(() => - writeFile( - path.join(root, 'topology/reference-topology.json'), - JSON.stringify({ verticals }), - ), + writeFile(path.join(root, 'topology/reference-topology.json'), JSON.stringify({ verticals })), ), Effect.promise(() => - writeFile( - path.join(root, 'topology/ownership.json'), - JSON.stringify({ owners: verticals }), - ), + writeFile(path.join(root, 'topology/ownership.json'), JSON.stringify({ owners: verticals })), ), ], { concurrency: 'unbounded' }, @@ -709,19 +673,15 @@ it.effect( const workspaceRoot = path.resolve(import.meta.dirname, '../..'); // Native discovery imports registrations dynamically; keep its private registry in one module instance. const { discoverCurrentActionKeys } = yield* Effect.promise( - (): Promise<{ readonly discoverCurrentActionKeys: typeof DiscoverCurrentActionKeys }> => - import( - pathToFileURL( - path.resolve(import.meta.dirname, '../provision-current-action-authorization.mts'), - ).href - ), + (): Promise<{ + readonly discoverCurrentActionKeys: typeof DiscoverCurrentActionKeys; + }> => + import(pathToFileURL(path.resolve(import.meta.dirname, '../provision-current-action-authorization.mts')).href), ); const { deriveOntosModuleDeploymentContract } = yield* Effect.promise( - (): Promise<{ readonly deriveOntosModuleDeploymentContract: typeof DeriveModuleContract }> => - import( - pathToFileURL(path.resolve(import.meta.dirname, '../generate-ontos-module-contract.mts')) - .href - ), + (): Promise<{ + readonly deriveOntosModuleDeploymentContract: typeof DeriveModuleContract; + }> => import(pathToFileURL(path.resolve(import.meta.dirname, '../generate-ontos-module-contract.mts')).href), ); const { ActionAuthorizationProvisioningError: NativeProvisioningError } = yield* Effect.promise( (): Promise<{ @@ -746,7 +706,11 @@ it.effect( Effect.tryPromise(() => mkdtemp(path.join(os.tmpdir(), 'ontos-action-discovery-'))), (directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })), ); - const vertical = { id: 'example', package: '@app/example', path: 'verticals/example' }; + const vertical = { + id: 'example', + package: '@app/example', + path: 'verticals/example', + }; yield* writeInventory(root, [vertical]); const incomplete: typeof deriveOntosModuleDeploymentContract = () => Effect.succeed({ @@ -754,7 +718,10 @@ it.effect( deployment: { ...currentContract.deployment, appId: 'example' }, manifest: { ...currentContract.manifest, - publicSurface: { ...currentContract.manifest.publicSurface, actions: [] }, + publicSurface: { + ...currentContract.manifest.publicSurface, + actions: [], + }, }, }); const incompleteError = yield* rejectionOf( @@ -773,7 +740,12 @@ it.effect( ...currentContract.manifest, publicSurface: { ...currentContract.manifest.publicSurface, - actions: [{ ...currentPublicAction, actionKey: 'core.identity.bind-managed-api-key' }], + actions: [ + { + ...currentPublicAction, + actionKey: 'core.identity.bind-managed-api-key', + }, + ], }, }, }); @@ -799,10 +771,7 @@ it.effect( 'the operator entrypoint rejects every command-line argument before loading configuration', Effect.fn(function* testEffect20() { const error = yield* failureOf( - runCurrentActionAuthorizationProvisioning(path.resolve(import.meta.dirname, '../..'), [ - '--tenant', - 'arbitrary', - ]), + runCurrentActionAuthorizationProvisioning(path.resolve(import.meta.dirname, '../..'), ['--tenant', 'arbitrary']), ); expect(error.code).toBe('action_authorization_configuration_invalid'); expect(error.reason).toMatch(/no command-line arguments/u); diff --git a/app/scripts/tests/quality-audit-count-domain.test.mts b/app/scripts/tests/quality-audit-count-domain.test.mts index a9bc19df6..95d911e94 100644 --- a/app/scripts/tests/quality-audit-count-domain.test.mts +++ b/app/scripts/tests/quality-audit-count-domain.test.mts @@ -1,8 +1,9 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Result, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { KnipModelEvidenceSchema } from '../../quality-audit/knip-model.mts'; -import { validateReport } from '../quality-audit.mts'; import { validateQualityAuditSummary } from '../quality-audit-gate.mts'; +import { validateReport } from '../quality-audit.mts'; it('audit evidence requires finite nonnegative integer source positions', () => { const evidence = { diff --git a/app/scripts/tests/quality-audit-gate.test.mts b/app/scripts/tests/quality-audit-gate.test.mts index 52d9dc4b7..dd3dc92fd 100644 --- a/app/scripts/tests/quality-audit-gate.test.mts +++ b/app/scripts/tests/quality-audit-gate.test.mts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Cause, Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { validateQualityAuditSummary } from '../quality-audit-gate.mts'; import { validateReport } from '../quality-audit.mts'; @@ -63,9 +64,7 @@ const clean = () => ({ status: 'reported', }); const encode = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -const validate = Effect.fn(function* mergedScenario1( - summary: ReturnType | Schema.Json, -) { +const validate = Effect.fn(function* mergedScenario1(summary: ReturnType | Schema.Json) { return yield* encode(summary).pipe(Effect.flatMap(validateQualityAuditSummary)); }); @@ -173,9 +172,7 @@ for (const [name, source] of positiveReports) { } yield* Effect.matchCause(validate(summary), { onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch( - new RegExp(`Quality audit gate failed: ${name}=1`, 'u'), - ), + expect(String(Cause.squash(cause))).toMatch(new RegExp(`Quality audit gate failed: ${name}=1`, 'u')), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -248,15 +245,12 @@ it.effect( if (!result) { throw new Error(EXPECTED_PROOF_VALUE); } - yield* Effect.matchCause( - validate({ ...summary, results: [...summary.results, result] }), - { - onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/six unique/u), - onSuccess: () => { - throw new Error(EXPECTED_EFFECT_FAILURE); - }, + yield* Effect.matchCause(validate({ ...summary, results: [...summary.results, result] }), { + onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/six unique/u), + onSuccess: () => { + throw new Error(EXPECTED_EFFECT_FAILURE); }, - ); + }); result.status = 'error'; yield* Effect.matchCause(validate(summary), { onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/Malformed/u), @@ -348,8 +342,7 @@ it.effect( } Object.assign(result, patch); yield* Effect.matchCause(validate(summary), { - onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch(/Malformed|inconsistent/u), + onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/Malformed|inconsistent/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, @@ -395,8 +388,7 @@ it.effect( } Object.assign(result.coverage, coverage); yield* Effect.matchCause(validate(summary), { - onFailure: (cause) => - expect(String(Cause.squash(cause))).toMatch(/Malformed|inconsistent/u), + onFailure: (cause) => expect(String(Cause.squash(cause))).toMatch(/Malformed|inconsistent/u), onSuccess: () => { throw new Error(EXPECTED_EFFECT_FAILURE); }, diff --git a/app/scripts/tests/quality-audit-import-clones.test.mts b/app/scripts/tests/quality-audit-import-clones.test.mts new file mode 100644 index 000000000..fffeb6436 --- /dev/null +++ b/app/scripts/tests/quality-audit-import-clones.test.mts @@ -0,0 +1,62 @@ +import { NodeServices } from '@effect/platform-node'; +import { Effect, FileSystem, Path, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { containsOnlyImportBindings, importCloneEvidence } from '../../quality-audit/import-clone-evidence.mts'; + +it.effect( + 'recognizes complete static bindings while retaining side effects, implementations and incomplete spans', + () => + Effect.sync(() => { + const bindings = "import { alpha } from './alpha';\nimport { beta } from './beta';"; + expect(containsOnlyImportBindings(bindings)).toBe(true); + for (const source of [ + `${bindings}\nalpha(beta);`, + `${bindings}\nimport './initialize';`, + `${bindings}\nconst operation = () => alpha(beta);`, + `/* ${bindings} */`, + `const text = ${JSON.stringify(bindings)};`, + `${bindings}\nimport {`, + ]) { + expect(containsOnlyImportBindings(source)).toBe(false); + } + }), +); + +it.live('requires both reported file ranges to prove import bindings', () => + Effect.gen(function* importRanges() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* Effect.acquireUseRelease( + fs.makeTempDirectory({ prefix: 'ontos-import-clones-' }), + (root) => + Effect.gen(function* verifyImportRanges() { + const first = path.join(root, 'first.ts'); + const second = path.join(root, 'second.ts'); + const source = "import { alpha } from './alpha';\nimport { beta } from './beta';\nalpha(beta);"; + yield* fs.writeFileString(first, source); + yield* fs.writeFileString(second, source); + const firstFile = { end: 2, name: first, start: 1 }; + const secondFile = { end: 2, name: second, start: 1 }; + const duplicates = [ + { firstFile, secondFile }, + { firstFile, secondFile: { ...secondFile, end: 3 } }, + { firstFile, secondFile: { ...secondFile, end: 99 } }, + { firstFile, secondFile: { name: second, start: 1 } }, + { + firstFile, + secondFile: { + ...secondFile, + name: path.join(root, '..', 'outside.ts'), + }, + }, + ]; + const report = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + duplicates, + }); + expect(yield* importCloneEvidence(root, report)).toEqual([{ firstFile, secondFile }]); + }), + (root) => fs.remove(root, { recursive: true }), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/app/scripts/tests/quality-audit-model.test.mts b/app/scripts/tests/quality-audit-model.test.mts index 4b965d409..10ce78a10 100644 --- a/app/scripts/tests/quality-audit-model.test.mts +++ b/app/scripts/tests/quality-audit-model.test.mts @@ -1,23 +1,14 @@ -import { expect, it } from 'effect-rstest'; -import { runPinnedKnip } from './quality-audit-test-support.mts'; - -import { - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { NodeServices } from '@effect/platform-node'; import { Effect, Schema } from 'effect'; -import { runQualityAudit } from '../quality-audit.mts'; +import { expect, it } from 'effect-rstest'; import { buildKnipModel, KnipConfigSchema } from '../../quality-audit/knip-model.mts'; +import { runQualityAudit } from '../quality-audit.mts'; +import { runPinnedKnip } from './quality-audit-test-support.mts'; const rspackPackageName = '@rspack/core'; const fixtureModuleSource = 'module.exports = {};'; @@ -69,7 +60,11 @@ const fixture = () => root, packageFile, yield* stringify({ - dependencies: { 'drizzle-orm': '1.0.0-rc.4', effect: '4.0.0-beta.107', jose: '6.2.5' }, + dependencies: { + 'drizzle-orm': '1.0.0-rc.4', + effect: '4.0.0-beta.107', + jose: '6.2.5', + }, name: 'knip-consumer-controls', private: true, type: 'module', @@ -100,28 +95,20 @@ const fixture = () => }), ); write(root, `${resolverOwner}/index.js`, fixtureModuleSource); - write( - root, - `${resolverTarget}/package.json`, - yield* stringify({ main: 'index.js', name: rspackPackageName }), - ); + write(root, `${resolverTarget}/package.json`, yield* stringify({ main: 'index.js', name: rspackPackageName })); write(root, `${resolverTarget}/index.js`, fixtureModuleSource); const resolverAnchor = path.join(root, resolverOwner, 'index.js'); write( root, resolverFile, - [ - ...requirePrelude, - `require.resolve('@rspack/core', { paths: [${JSON.stringify(resolverAnchor)}] });`, - ].join('\n'), + [...requirePrelude, `require.resolve('@rspack/core', { paths: [${JSON.stringify(resolverAnchor)}] });`].join( + '\n', + ), ); write( root, 'src/own-resolver.ts', - [ - ...requirePrelude, - `require.resolve('oxc-parser', { paths: [${JSON.stringify(root)}] });`, - ].join('\n'), + [...requirePrelude, `require.resolve('oxc-parser', { paths: [${JSON.stringify(root)}] });`].join('\n'), ); write( root, @@ -162,11 +149,7 @@ const fixture = () => 'verticals/remote/shared/ultramodern-build.ts', 'export const declaredBuildIdentity = 1; export const unusedBuildNeighbor = 2;', ); - write( - root, - 'tools/oxlint/effect-native/report.mts', - "runOxlint(join(pluginDirectory, 'report.config.ts'), []);", - ); + write(root, 'tools/oxlint/effect-native/report.mts', "runOxlint(join(pluginDirectory, 'report.config.ts'), []);"); write( root, 'tools/oxlint/effect-native/report.config.ts', @@ -219,51 +202,32 @@ it.live( node: false, project: [sourcePattern, configurationFiles, toolsPattern], }, - 'verticals/*': { entry: [indexFile, configurationFiles], project: ['**/*.{ts,mts}'] }, + 'verticals/*': { + entry: [indexFile, configurationFiles], + project: ['**/*.{ts,mts}'], + }, }, }); const consumerPath = path.join(root, '.codex/knip-model/consumers.mts'); - const model = yield* buildKnipModel(root, base, consumerPath).pipe( - Effect.provide(NodeServices.layer), - ); - const run = yield* runPinnedKnip(root, consumerPath, model).pipe( - Effect.provide(NodeServices.layer), - ); + const model = yield* buildKnipModel(root, base, consumerPath).pipe(Effect.provide(NodeServices.layer)); + const run = yield* runPinnedKnip(root, consumerPath, model).pipe(Effect.provide(NodeServices.layer)); expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(1); expect(run.stderr).toBe(''); - const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))( - run.stdout, - ); + const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))(run.stdout); const findings = (kind: 'files' | 'exports' | 'dependencies' | 'unlisted') => - report.issues.flatMap((issue) => - issue[kind].map((finding) => `${issue.file}#${finding.name}`), - ); + report.issues.flatMap((issue) => issue[kind].map((finding) => `${issue.file}#${finding.name}`)); expect(findings('files').includes('src/dead.ts#src/dead.ts')).toBe(true); expect(!findings('files').some((finding) => finding.startsWith('src/worker.mts#'))).toBe(true); expect(!findings('files').some((finding) => finding.startsWith('src/public.ts#'))).toBe(true); expect(findings('exports').includes('src/helper.ts#unusedNeighbor')).toBe(true); expect(findings('exports').includes('src/validated.ts#unusedValidatedExport')).toBe(true); - expect( - !findings('exports').includes( - 'verticals/remote/shared/ultramodern-build.ts#declaredBuildIdentity', - ), - ).toBe(true); - expect( - findings('exports').includes( - 'verticals/remote/shared/ultramodern-build.ts#unusedBuildNeighbor', - ), - ).toBe(true); - expect( - !findings('exports').includes('tools/oxlint/effect-native/report.config.ts#default'), - ).toBe(true); - expect( - findings('exports').includes( - 'tools/oxlint/effect-native/report.config.ts#unusedConfigNeighbor', - ), - ).toBe(true); - expect(!findings('files').some((finding) => finding.startsWith('src/validated.ts#'))).toBe( + expect(!findings('exports').includes('verticals/remote/shared/ultramodern-build.ts#declaredBuildIdentity')).toBe( true, ); + expect(findings('exports').includes('verticals/remote/shared/ultramodern-build.ts#unusedBuildNeighbor')).toBe(true); + expect(!findings('exports').includes('tools/oxlint/effect-native/report.config.ts#default')).toBe(true); + expect(findings('exports').includes('tools/oxlint/effect-native/report.config.ts#unusedConfigNeighbor')).toBe(true); + expect(!findings('files').some((finding) => finding.startsWith('src/validated.ts#'))).toBe(true); expect(findings('exports').includes('src/schema.ts#unregisteredHelper')).toBe(true); expect(!findings('exports').includes('src/schema.ts#registeredSchema')).toBe(true); expect(!findings('exports').includes('src/public.ts#externallyConsumed')).toBe(true); @@ -272,12 +236,8 @@ it.live( expect(findings('unlisted').includes('src/index.ts#misspelledRemote')).toBe(true); expect(findings('unlisted').includes('src/index.ts#declaredRemote')).toBe(true); expect(!findings('unlisted').includes('verticals/remote/src/index.ts#childRemote')).toBe(true); - expect(findings('unlisted').includes('verticals/remote/src/index.ts#misspelledChild')).toBe( - true, - ); - expect(findings('unlisted').includes('verticals/remote/src/index.ts#declaredRemote')).toBe( - true, - ); + expect(findings('unlisted').includes('verticals/remote/src/index.ts#misspelledChild')).toBe(true); + expect(findings('unlisted').includes('verticals/remote/src/index.ts#declaredRemote')).toBe(true); expect(findings('dependencies').includes('verticals/remote/package.json#effect')).toBe(true); expect( findings('dependencies').includes('verticals/remote/package.json#drizzle-orm'), @@ -287,11 +247,9 @@ it.live( expect(findings('unlisted').includes('src/index.ts#shadowedRemote')).toBe(true); expect(findings('unlisted').includes('src/direct.ts#@rspack/core')).toBe(true); expect(findings('unlisted').includes('src/own-resolver.ts#oxc-parser')).toBe(true); - expect( - !model.evidence.some( - (item) => item.kind === 'resolver' && item.source === 'src/own-resolver.ts', - ), - ).toBe(true); + expect(!model.evidence.some((item) => item.kind === 'resolver' && item.source === 'src/own-resolver.ts')).toBe( + true, + ); expect( model.evidence.some( (item) => @@ -303,13 +261,9 @@ it.live( item.resolved !== undefined, ), ).toBe(true); + expect(!model.evidence.some((item) => item.kind === 'resolver' && item.source === directFile)).toBe(true); expect( - !model.evidence.some((item) => item.kind === 'resolver' && item.source === directFile), - ).toBe(true); - expect( - model.evidence.some( - (item) => item.target === 'src/schema.ts#registeredSchema' && item.kind === 'export', - ), + model.evidence.some((item) => item.target === 'src/schema.ts#registeredSchema' && item.kind === 'export'), ).toBe(true); }), ); @@ -324,9 +278,9 @@ it.live( .pipe( Effect.flip, Effect.map((error) => - expect( - Schema.decodeUnknownSync(Schema.Struct({ reason: Schema.String }))(error).reason, - ).toMatch(/Invalid quality model source/u), + expect(Schema.decodeUnknownSync(Schema.Struct({ reason: Schema.String }))(error).reason).toMatch( + /Invalid quality model source/u, + ), ), ); }), @@ -372,7 +326,10 @@ it.live( node: false, project: [sourcePattern, configurationFiles, toolsPattern], }, - 'verticals/*': { entry: [indexFile, configurationFiles], project: ['**/*.{ts,mts}'] }, + 'verticals/*': { + entry: [indexFile, configurationFiles], + project: ['**/*.{ts,mts}'], + }, }, }), ); @@ -408,23 +365,17 @@ it.live( throw new Error('Expected result to be present'); } expect(result.coverage.modeledUsages).toBe(1); - expect(result.coverage.nativeFindingCounts.unlisted).toBe( - (result.coverage.findingCounts.unlisted ?? 0) + 1, - ); + expect(result.coverage.nativeFindingCounts.unlisted).toBe((result.coverage.findingCounts.unlisted ?? 0) + 1); const modeled = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Array(Schema.Struct({ file: Schema.String }))), )(readFileSync(path.join(summary.runDirectory, 'knip/modeled-usages.json'), 'utf-8')); expect(modeled).toEqual([{ file: resolverFile }]); const raw = Schema.decodeUnknownSync(Schema.fromJsonString(ReportSchema))( - readFileSync(path.join(summary.runDirectory, 'knip/report.ndjson'), 'utf-8') - .trim() - .split('\n')[0], + readFileSync(path.join(summary.runDirectory, 'knip/report.ndjson'), 'utf-8').trim().split('\n')[0], ); expect( raw.issues.some( - (issue) => - issue.file === directFile && - issue.unlisted.some((entry) => entry.name === rspackPackageName), + (issue) => issue.file === directFile && issue.unlisted.some((entry) => entry.name === rspackPackageName), ), ).toBe(true); }), @@ -452,11 +403,7 @@ it.live( ] as const ).map(([directory, name, dependencies]) => Effect.gen(function* testEffect8() { - write( - root, - `${directory}/package.json`, - yield* stringify({ dependencies, main: 'index.js', name }), - ); + write(root, `${directory}/package.json`, yield* stringify({ dependencies, main: 'index.js', name })); write(root, `${directory}/index.js`, fixtureModuleSource); }), ), @@ -480,11 +427,7 @@ it.live( ).pipe(Effect.provide(NodeServices.layer)); }); const differentCopies = yield* build(); - expect( - !differentCopies.evidence.some( - (item) => item.kind === 'resolver' && item.target === 'target', - ), - ).toBe(true); + expect(!differentCopies.evidence.some((item) => item.kind === 'resolver' && item.target === 'target')).toBe(true); const mismatch = differentCopies.evidence.find( (item) => item.kind === 'resolver-unproven' && item.target === 'target', ); @@ -493,21 +436,14 @@ it.live( throw new Error('Expected mismatch to be present'); } expect(mismatch.producerManifest).toBe(path.join(root, producer, packageFile)); - expect(mismatch.producerResolved).toBe( - realpathSync(path.join(root, producerTarget, 'index.js')), - ); + expect(mismatch.producerResolved).toBe(realpathSync(path.join(root, producerTarget, 'index.js'))); expect(mismatch.resolved).toBe(realpathSync(path.join(root, ownerTarget, 'index.js'))); expect(mismatch.reason).toMatch(/different canonical target/u); - const run = yield* runPinnedKnip(root, consumerPath, differentCopies).pipe( - Effect.provide(NodeServices.layer), - ); + const run = yield* runPinnedKnip(root, consumerPath, differentCopies).pipe(Effect.provide(NodeServices.layer)); expect(run.status, run.stderr).toBe(1); const report = Schema.decodeUnknownSync(Schema.fromJsonString(ReportSchema))(run.stdout); expect( - report.issues.some( - (issue) => - issue.file === indexFile && issue.unlisted.some((item) => item.name === 'target'), - ), + report.issues.some((issue) => issue.file === indexFile && issue.unlisted.some((item) => item.name === 'target')), ).toBe(true); rmSync(path.join(root, producerTarget), { force: true, recursive: true }); symlinkSync(path.join(root, ownerTarget), path.join(root, producerTarget), 'dir'); @@ -529,9 +465,7 @@ it.live( ].join('\n'), ); const missingAnchor = yield* build(); - expect( - !missingAnchor.evidence.some((item) => item.kind === 'resolver' && item.target === 'target'), - ).toBe(true); + expect(!missingAnchor.evidence.some((item) => item.kind === 'resolver' && item.target === 'target')).toBe(true); }), ); @@ -547,19 +481,11 @@ it.live( `${lintDirectory}/repository-policy.config.ts`, `${lintDirectory}/tests/shared-helpers-probe.ts`, ]; - write( - root, - packageFile, - '{"name":"consumer-controls","type":"module","scripts":{"test":"rstest --project unit"}}', - ); + write(root, packageFile, '{"name":"consumer-controls","type":"module","scripts":{"test":"rstest --project unit"}}'); for (const file of loadedFiles) { write(root, file, 'export default {}; export const unusedNeighbor = 1;'); } - write( - root, - policyTest, - "runOxlint(nodePath.join(pluginDirectory, 'repository-policy.config.ts'), []);", - ); + write(root, policyTest, "runOxlint(nodePath.join(pluginDirectory, 'repository-policy.config.ts'), []);"); write( root, helperTest, @@ -579,20 +505,12 @@ it.live( }, }, }; - const model = yield* buildKnipModel(root, base, consumerPath).pipe( - Effect.provide(NodeServices.layer), - ); - const run = yield* runPinnedKnip(root, consumerPath, model).pipe( - Effect.provide(NodeServices.layer), - ); + const model = yield* buildKnipModel(root, base, consumerPath).pipe(Effect.provide(NodeServices.layer)); + const run = yield* runPinnedKnip(root, consumerPath, model).pipe(Effect.provide(NodeServices.layer)); expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(1); - const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))( - run.stdout, - ); + const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))(run.stdout); const files = report.issues.flatMap((issue) => issue.files.map((finding) => finding.name)); - const exports = report.issues.flatMap((issue) => - issue.exports.map((finding) => `${issue.file}#${finding.name}`), - ); + const exports = report.issues.flatMap((issue) => issue.exports.map((finding) => `${issue.file}#${finding.name}`)); for (const file of loadedFiles) { expect(files).not.toContain(file); expect(exports).toContain(`${file}#unusedNeighbor`); @@ -630,9 +548,7 @@ it.live( "path.join(testsDirectory, 'other-probe.ts')", ]) { write(root, helperTest, `void { name: 'shared-helpers-probe', specifier: ${specifier} };`); - const unrecognized = yield* buildKnipModel(root, base, consumerPath).pipe( - Effect.provide(NodeServices.layer), - ); + const unrecognized = yield* buildKnipModel(root, base, consumerPath).pipe(Effect.provide(NodeServices.layer)); expect(unrecognized.evidence.some((fact) => fact.source === helperTest)).toBe(false); } write( @@ -647,14 +563,10 @@ it.live( helperTest, "void { name: 'other-plugin', specifier: path.join(testsDirectory, 'shared-helpers-probe.ts') };", ); - const unrelated = yield* buildKnipModel(root, base, consumerPath).pipe( - Effect.provide(NodeServices.layer), + const unrelated = yield* buildKnipModel(root, base, consumerPath).pipe(Effect.provide(NodeServices.layer)); + expect(unrelated.evidence.some((fact) => loadedFiles.includes(fact.target) || fact.target === typeTest)).toBe( + false, ); - expect( - unrelated.evidence.some( - (fact) => loadedFiles.includes(fact.target) || fact.target === typeTest, - ), - ).toBe(false); }), ); @@ -679,9 +591,7 @@ it.live( "export default { testEnvironment: 'node', projects, metadata: unrelated };", ].join('\n'), ); - const model = yield* buildKnipModel(root, { entry: [configFile] }).pipe( - Effect.provide(NodeServices.layer), - ); + const model = yield* buildKnipModel(root, { entry: [configFile] }).pipe(Effect.provide(NodeServices.layer)); const environments = model.evidence.filter((fact) => fact.reason === rstestEnvironmentReason); expect(environments.map((fact) => fact.target)).toEqual(['happy-dom', 'jsdom', 'happy-dom']); expect(environments[0]).toEqual( @@ -699,13 +609,9 @@ it.live( configFile, `const cycle = cycle; export default { testEnvironment: 'happy-dom', projects: ${projects} };`, ); - const dynamic = yield* buildKnipModel(root, { entry: [configFile] }).pipe( - Effect.provide(NodeServices.layer), - ); + const dynamic = yield* buildKnipModel(root, { entry: [configFile] }).pipe(Effect.provide(NodeServices.layer)); expect( - dynamic.evidence - .filter((fact) => fact.reason === rstestEnvironmentReason) - .map((fact) => fact.target), + dynamic.evidence.filter((fact) => fact.reason === rstestEnvironmentReason).map((fact) => fact.target), ).toEqual(['happy-dom']); } }), @@ -725,11 +631,7 @@ it.live( type: 'module', }), ); - write( - root, - rstestConfigFile, - "export default { projects: [{ testEnvironment: 'happy-dom' }] };", - ); + write(root, rstestConfigFile, "export default { projects: [{ testEnvironment: 'happy-dom' }] };"); const consumerPath = path.join(root, auditConsumersFile); const model = yield* buildKnipModel( root, @@ -741,26 +643,21 @@ it.live( }, consumerPath, ).pipe(Effect.provide(NodeServices.layer)); - expect( - model.evidence.some( - (fact) => fact.reason === rstestEnvironmentReason && fact.target === 'happy-dom', - ), - ).toBe(true); + expect(model.evidence.some((fact) => fact.reason === rstestEnvironmentReason && fact.target === 'happy-dom')).toBe( + true, + ); for (const [consumerSource, expectedUnused] of [ [model.consumerSource, false], ['', true], ] as const) { - const run = yield* runPinnedKnip(root, consumerPath, { ...model, consumerSource }).pipe( - Effect.provide(NodeServices.layer), - ); + const run = yield* runPinnedKnip(root, consumerPath, { + ...model, + consumerSource, + }).pipe(Effect.provide(NodeServices.layer)); expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(1); expect(run.stderr).toBe(''); - const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))( - run.stdout, - ); - const dependencies = report.issues.flatMap((issue) => - issue.dependencies.map((item) => item.name), - ); + const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ReportSchema))(run.stdout); + const dependencies = report.issues.flatMap((issue) => issue.dependencies.map((item) => item.name)); expect(dependencies.includes('happy-dom')).toBe(expectedUnused); expect(dependencies).toContain('jose'); } diff --git a/app/scripts/tests/quality-audit-runtime-model.test.mts b/app/scripts/tests/quality-audit-runtime-model.test.mts index 260f8fb43..79b11612a 100644 --- a/app/scripts/tests/quality-audit-runtime-model.test.mts +++ b/app/scripts/tests/quality-audit-runtime-model.test.mts @@ -1,19 +1,18 @@ -import { expect, it } from 'effect-rstest'; -import { runPinnedKnip } from './quality-audit-test-support.mts'; - import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { NodeServices } from '@effect/platform-node'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { buildKnipModel } from '../../quality-audit/knip-model.mts'; import { buildKnipRuntimeEvidence } from '../../quality-audit/knip-runtime-model.mts'; +import { runPinnedKnip } from './quality-audit-test-support.mts'; const shellRoot = 'apps/shell'; const layoutFile = `${shellRoot}/src/routes/layout.tsx`; -const vendorRoot = 'node_modules/@modern-js/create/templates/workspace-scripts'; +const vendorRoot = 'node_modules/@modern-js/ultramodern-create/templates/workspace-scripts'; const resetFile = 'scripts/reset.mjs'; const readinessConfig = 'scripts/readiness.config.mjs'; const launchedFile = 'scripts/launched.mts'; @@ -65,11 +64,7 @@ const fixture = () => type: 'module', }), ); - write( - root, - layoutFile, - "import './index.css'; export default function Layout() { return null; }", - ); + write(root, layoutFile, "import './index.css'; export default function Layout() { return null; }"); write( root, `${shellRoot}/src/routes/index.css`, @@ -100,11 +95,7 @@ const fixture = () => "resolveEffectTsgoCompiler({ from: pathToFileURL(join(workspaceRoot, 'package.json')) });", ); write(root, tsgoReadme, compilerDocumentation); - write( - root, - compilerConfig, - yield* stringify({ compilerOptions: { plugins: [{ name: pluginName }] } }), - ); + write(root, compilerConfig, yield* stringify({ compilerOptions: { plugins: [{ name: pluginName }] } })); write( root, 'scripts/ultramodern-performance-readiness.mts', @@ -124,14 +115,7 @@ it.live( Effect.fn(function* testEffect4() { const root = yield* fixture(); const initial = yield* facts(root); - for (const target of [ - cssUsed, - launchedFile, - resetFile, - tsgoName, - pluginName, - readinessConfig, - ]) { + for (const target of [cssUsed, launchedFile, resetFile, tsgoName, pluginName, readinessConfig]) { expect( initial.some((fact) => fact.target === target), target, @@ -141,11 +125,7 @@ it.live( expect(!initial.some((fact) => fact.target === target), target).toBe(true); } expect(initial.find((fact) => fact.target === pluginName)?.kind).toBe(compilerOptionKind); - expect( - initial.some( - (fact) => fact.target === `${readinessConfig}#default` && fact.kind === 'export', - ), - ).toBe(true); + expect(initial.some((fact) => fact.target === `${readinessConfig}#default` && fact.kind === 'export')).toBe(true); write(root, layoutFile, emptyLayout); write(root, `${shellRoot}/package.json`, '{"name":"@fixture/shell"}'); write(root, 'zerops.yaml', '# - cd app && node scripts/reset.mjs'); @@ -153,7 +133,10 @@ it.live( root, compilerConfig, yield* stringify({ - compilerOptions: { plugins: [{ name: pluginName }], types: [pluginName] }, + compilerOptions: { + plugins: [{ name: pluginName }], + types: [pluginName], + }, }), ); write( @@ -228,14 +211,12 @@ it.live( const root = yield* fixture(); const configFile = `${shellRoot}/module-federation.config.ts`; const source = - "import { resolveEffectTsgoCompiler } from '@modern-js/app-tools/config';\nconst compiler = resolveEffectTsgoCompiler({ from: import.meta.url });\nvoid compiler;"; + "import { resolveEffectTsgoCompiler } from '@modern-js/app-tools/config';\nconst compiler = resolveEffectTsgoCompiler({\n from: import.meta.url,\n });\nvoid compiler;"; write(root, configFile, source); write(root, `${shellRoot}/${tsgoReadme}`, 'tries `typescript`, then `@typescript/native`'); const initial = yield* facts(root); for (const target of ['@effect/tsgo', '@typescript/native']) { - expect(initial.some((fact) => fact.target === target && fact.workspace === shellRoot)).toBe( - true, - ); + expect(initial.some((fact) => fact.target === target && fact.workspace === shellRoot)).toBe(true); } write(root, configFile, `/* ${source} */\nexport default {};`); const commented = yield* facts(root); @@ -285,9 +266,7 @@ it.live( }, consumerPath, ).pipe(Effect.provide(NodeServices.layer)); - const run = yield* runPinnedKnip(root, consumerPath, model).pipe( - Effect.provide(NodeServices.layer), - ); + const run = yield* runPinnedKnip(root, consumerPath, model).pipe(Effect.provide(NodeServices.layer)); expect(run.error).toBe(undefined); expect(run.status === 0 || run.status === 1, run.stderr).toBe(true); const report = yield* Schema.decodeUnknownEffect( @@ -308,23 +287,17 @@ it.live( expect(unusedFiles.some((file) => file.endsWith('/scripts/dead.mts'))).toBe(true); expect( !unusedFiles.some( - (file) => - file.endsWith('/scripts/launched.mts') || file === resetFile || file === readinessConfig, + (file) => file.endsWith('/scripts/launched.mts') || file === resetFile || file === readinessConfig, ), ).toBe(true); - const dependencies = new Set( - report.issues.flatMap((issue) => issue.dependencies.map((item) => item.name)), - ); + const dependencies = new Set(report.issues.flatMap((issue) => issue.dependencies.map((item) => item.name))); expect(dependencies.has('@fixture/css-dead')).toBe(true); expect(dependencies.has('@fixture/css-comment')).toBe(true); expect(!dependencies.has(cssUsed)).toBe(true); - const exports = new Set( - report.issues.flatMap((issue) => issue.exports.map((item) => item.name)), - ); + const exports = new Set(report.issues.flatMap((issue) => issue.exports.map((item) => item.name))); expect( !report.issues.some( - (issue) => - issue.file === readinessConfig && issue.exports.some((item) => item.name === 'default'), + (issue) => issue.file === readinessConfig && issue.exports.some((item) => item.name === 'default'), ), ).toBe(true); for (const name of ['unusedLauncherExport', 'unusedResetExport', 'unusedConfigExport']) { @@ -339,10 +312,7 @@ it.live( const root = yield* fixture(); const runnerFile = 'scripts/shared/ultramodern-command.mts'; try { - const runner = readFileSync( - new URL('../shared/ultramodern-command.mts', import.meta.url), - 'utf-8', - ); + const runner = readFileSync(new URL('../shared/ultramodern-command.mts', import.meta.url), 'utf-8'); write(root, runnerFile, runner); for (const command of ['typecheck', 'performance-readiness']) { write( @@ -361,10 +331,7 @@ it.live( write( root, runnerFile, - runner.replace( - 'ChildProcess.make(launch.executable, launch.args,', - 'ChildProcess.make("unrelated", [],', - ), + runner.replace('ChildProcess.make(launch.executable, launch.args,', 'ChildProcess.make("unrelated", [],'), ); const disconnected = yield* facts(root); expect(!disconnected.some((fact) => fact.target === tsgoName)).toBeTruthy(); diff --git a/app/scripts/tests/quality-audit-test-support.mts b/app/scripts/tests/quality-audit-test-support.mts index cd6d30704..123ff398f 100644 --- a/app/scripts/tests/quality-audit-test-support.mts +++ b/app/scripts/tests/quality-audit-test-support.mts @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; + import { Effect, FileSystem, Schema } from 'effect'; + import { KnipConfigSchema } from '../../quality-audit/knip-model.mts'; const appRoot = path.resolve(import.meta.dirname, '../..'); @@ -18,9 +20,7 @@ export const runPinnedKnip = Effect.fn('runPinnedKnip')(function* runPinnedKnipE yield* fileSystem.makeDirectory(directory, { recursive: true }); yield* fileSystem.writeFileString(consumerPath, model.consumerSource); const configPath = path.join(directory, 'knip.json'); - const configuration = yield* Schema.encodeEffect(Schema.fromJsonString(KnipConfigSchema))( - model.config, - ); + const configuration = yield* Schema.encodeEffect(Schema.fromJsonString(KnipConfigSchema))(model.config); yield* fileSystem.writeFileString(configPath, configuration); return yield* Effect.sync(() => spawnSync( diff --git a/app/scripts/tests/quality-audit.test.mts b/app/scripts/tests/quality-audit.test.mts index 71885d3f0..855c5d558 100644 --- a/app/scripts/tests/quality-audit.test.mts +++ b/app/scripts/tests/quality-audit.test.mts @@ -1,5 +1,3 @@ -import { expect, it } from 'effect-rstest'; - import { copyFileSync, mkdirSync, @@ -14,12 +12,14 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Config, Effect, Match, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; import { auditSteps, runQualityAudit, validateReport } from '../quality-audit.mts'; import { collectToolingProcess } from './tooling-process-fixture.mts'; +const NATIVE_GIT = '/usr/bin/git'; const FALLOW_CLONES = 'fallow-clones'; const FALLOW_SIMILARITY = 'fallow-similarity'; const FALLOW_HEALTH = 'fallow-health'; @@ -37,10 +37,7 @@ const ProvenanceSchema = Schema.fromJsonString( }), ); const appRoot = path.resolve(import.meta.dirname, '../..'); -const includesPolicyFiles = ( - instances: readonly { readonly file: string }[], - policyFiles: readonly string[], -) => { +const includesPolicyFiles = (instances: readonly { readonly file: string }[], policyFiles: readonly string[]) => { const names = new Set(instances.map((instance) => path.basename(instance.file))); return policyFiles.every((file) => names.has(file)); }; @@ -74,14 +71,20 @@ it.effect( const emptySourceError = yield* Effect.flip( validateReport( 'jscpd', - yield* encodeReport({ duplicates: [], statistics: { total: { clones: 0, sources: 0 } } }), + yield* encodeReport({ + duplicates: [], + statistics: { total: { clones: 0, sources: 0 } }, + }), ), ); expect(emptySourceError.message).toMatch(/no files/u); const cloneCountError = yield* Effect.flip( validateReport( 'jscpd', - yield* encodeReport({ ...report, statistics: { total: { clones: 0, sources: 2 } } }), + yield* encodeReport({ + ...report, + statistics: { total: { clones: 0, sources: 2 } }, + }), ), ); expect(cloneCountError.message).toMatch(/count disagrees/u); @@ -144,7 +147,11 @@ it.effect( yield* encodeReport({ ...report, workspace_diagnostics: [ - { kind: 'invalid-package-json', message: 'invalid package', path: 'packages/broken' }, + { + kind: 'invalid-package-json', + message: 'invalid package', + path: 'packages/broken', + }, ], }), ), @@ -181,13 +188,14 @@ const createFixture = () => symlinkSync(path.join(appRoot, 'node_modules'), path.join(root, 'node_modules'), 'dir'); writeFileSync( path.join(root, PACKAGE_JSON), - yield* encodeReport({ name: 'quality-test', private: true, type: 'module' }), + yield* encodeReport({ + name: 'quality-test', + private: true, + type: 'module', + }), ); for (const name of ['scope.json', 'fallow.json', 'jscpd.json', 'knip-reporter.mts']) { - copyFileSync( - path.join(appRoot, CONFIG_DIRECTORY, name), - path.join(root, CONFIG_DIRECTORY, name), - ); + copyFileSync(path.join(appRoot, CONFIG_DIRECTORY, name), path.join(root, CONFIG_DIRECTORY, name)); } writeFileSync( path.join(root, KNIP_CONFIG), @@ -198,21 +206,42 @@ const createFixture = () => project: ['scripts/**/*.ts'], }), ); - const branches = Array.from( - { length: 15 }, - (_, index) => `if (input > ${index}) result += input * ${index};`, - ).join('\n'); + const branches = Array.from({ length: 15 }, (_, index) => `if (input > ${index}) result += input * ${index};`).join( + '\n', + ); const body = `export function calculate(input: number) {\nlet result = input;\n${branches}\nreturn result;\n}\n`; writeFileSync(path.join(root, 'scripts/index.ts'), body); - writeFileSync( - path.join(root, 'scripts/dead.ts'), - body.replace('calculate', 'unusedCalculation'), - ); + writeFileSync(path.join(root, 'scripts/dead.ts'), body.replace('calculate', 'unusedCalculation')); return root; }); const runFixture = (root: string, output: string, tool: 'all' | 'knip' | 'jscpd' | 'fallow') => - runQualityAudit(root, output, tool).pipe(Effect.provide(NodeServices.layer)); + Effect.gen(function* runNativeFixture() { + const nativeSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimePath = yield* Config.string('PATH'); + // Both fixtures and analyzer subprocesses use Git inside this test runtime. + const fixtureSpawner = ChildProcessSpawner.make((command) => + Match.value(command).pipe( + Match.tag('StandardCommand', (standard) => + nativeSpawner.spawn( + ChildProcess.make(standard.command === 'git' ? NATIVE_GIT : standard.command, standard.args, { + ...standard.options, + env: { + ...standard.options.env, + PATH: `/usr/bin:${runtimePath}`, + }, + extendEnv: true, + }), + ), + ), + Match.tag('PipedCommand', (piped) => nativeSpawner.spawn(piped)), + Match.exhaustive, + ), + ); + return yield* runQualityAudit(root, output, tool).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, fixtureSpawner), + ); + }).pipe(Effect.provide(NodeServices.layer)); const SummarySchema = Schema.Struct({ mode: Schema.Literal('report-only'), @@ -241,10 +270,9 @@ it.live( const root = yield* createFixture(); const output = path.join(root, REPORT_DIRECTORY); const props = Array.from({ length: 22 }, (_, index) => `p${index + 1}`).join(', '); - const branches = Array.from( - { length: 11 }, - (_, index) => `if (value === ${index + 1}) return ${index + 1};`, - ).join('\n'); + const branches = Array.from({ length: 11 }, (_, index) => `if (value === ${index + 1}) return ${index + 1};`).join( + '\n', + ); writeFileSync( path.join(root, 'scripts/metric-example.tsx'), `import { useState } from 'react'; @@ -293,10 +321,7 @@ export function branchHeavy(value: number) { expect(corrupted).not.toBe(raw); const contributionsError = yield* Effect.flip(validateReport(FALLOW_HEALTH, corrupted)); expect(contributionsError.message).toMatch(/contributions disagree/u); - const wrongCount = raw.replace( - /"functions_above_threshold"\s*:\s*\d+/u, - '"functions_above_threshold": 0', - ); + const wrongCount = raw.replace(/"functions_above_threshold"\s*:\s*\d+/u, '"functions_above_threshold": 0'); const thresholdCountError = yield* Effect.flip(validateReport(FALLOW_HEALTH, wrongCount)); expect(thresholdCountError.message).toMatch(/count disagrees/u); }), @@ -324,7 +349,9 @@ it.live( const schema = Schema.fromJsonString( Schema.Struct({ clone_groups: Schema.Array( - Schema.Struct({ instances: Schema.Array(Schema.Struct({ file: Schema.String })) }), + Schema.Struct({ + instances: Schema.Array(Schema.Struct({ file: Schema.String })), + }), ), }), ); @@ -391,12 +418,8 @@ it.live( expect(second.results[0]?.status).toBe('error'); expect(readdirSync(path.join(root, '.codex'))).toEqual([CALLER_OWNED_FILE]); expect(readFileSync(path.join(root, '.codex/caller-owned.txt'), 'utf-8')).toBe('keep'); - expect(readFileSync(path.join(output, 'summary.md'), 'utf-8')).toMatch( - /Malformed .*configs\/knip\.json/u, - ); - expect( - readFileSync(path.join(first.runDirectory, 'knip/report.ndjson'), 'utf-8').length > 0, - ).toBe(true); + expect(readFileSync(path.join(output, 'summary.md'), 'utf-8')).toMatch(/Malformed .*configs\/knip\.json/u); + expect(readFileSync(path.join(first.runDirectory, 'knip/report.ndjson'), 'utf-8').length > 0).toBe(true); }), ); @@ -414,10 +437,7 @@ it.live( expect(missing.results).toEqual([ { coverage: {}, - diagnostic: readFileSync( - path.join(failedDirectory, 'validation-error.txt'), - 'utf-8', - ).trimEnd(), + diagnostic: readFileSync(path.join(failedDirectory, 'validation-error.txt'), 'utf-8').trimEnd(), directory: failedDirectory, files: 0, findings: 0, @@ -459,7 +479,7 @@ it.live( const output = path.join(root, REPORT_DIRECTORY); yield* Effect.gen(function* initializeFixtureRepository() { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - yield* spawner.string(ChildProcess.make('git', ['init', '-q', root])); + yield* spawner.string(ChildProcess.make(NATIVE_GIT, ['init', '-q', root])); }).pipe(Effect.provide(NodeServices.layer)); mkdirSync(path.join(root, 'scripts/shared'), { recursive: true }); copyFileSync( @@ -472,16 +492,19 @@ it.live( path.join(appRoot, 'scripts/quality-cli-lifecycle.mts'), path.join(root, 'scripts/quality-cli-lifecycle.mts'), ); - for (const file of ['knip-model.mts', 'knip-runtime-model.mts']) { - copyFileSync( - path.join(appRoot, CONFIG_DIRECTORY, file), - path.join(root, CONFIG_DIRECTORY, file), - ); + for (const file of ['knip-model.mts', 'knip-runtime-model.mts', 'import-clone-evidence.mts']) { + copyFileSync(path.join(appRoot, CONFIG_DIRECTORY, file), path.join(root, CONFIG_DIRECTORY, file)); } const result = yield* collectToolingProcess( ChildProcess.make(process.execPath, [executable, '--tool', 'knip', '--output', output], { cwd: tmpdir(), - env: { CI: 'true', FORCE_COLOR: '1', GITHUB_ACTIONS: 'true', NO_COLOR: '1' }, + env: { + CI: 'true', + FORCE_COLOR: '1', + GITHUB_ACTIONS: 'true', + NO_COLOR: '1', + PATH: `/usr/bin:${yield* Config.string('PATH')}`, + }, extendEnv: true, stderr: 'pipe', stdin: 'ignore', @@ -497,9 +520,7 @@ it.live( ); expect(provenance.sourceState).toBe('modified'); expect(provenance.workingTreeChanges.some((file) => file === '?? scripts/index.ts')).toBe(true); - expect(!provenance.workingTreeChanges.some((file) => file.startsWith('?? reports/'))).toBe( - true, - ); + expect(!provenance.workingTreeChanges.some((file) => file.startsWith('?? reports/'))).toBe(true); }), ); @@ -533,9 +554,7 @@ it.live( expect(symlinkOutputError.message).toMatch(/analysis failed/u); const rejected = yield* summary(output); expect(rejected.results[0]?.name).toBe('setup'); - expect(rejected.results[0]?.diagnostic ?? '').toMatch( - /output directory outside configured source roots/u, - ); + expect(rejected.results[0]?.diagnostic ?? '').toMatch(/output directory outside configured source roots/u); expect(readdirSync(rejected.runDirectory)).toEqual([]); expect(readdirSync(path.join(root, '.codex'))).toEqual([CALLER_OWNED_FILE]); @@ -577,7 +596,7 @@ it.live( commands, (args) => spawner - .exitCode(ChildProcess.make('git', args, { cwd: root })) + .exitCode(ChildProcess.make(NATIVE_GIT, args, { cwd: root })) .pipe(Effect.map((code) => expect(Number(code)).toBe(0))), { concurrency: 1 }, ); @@ -608,22 +627,18 @@ it.live( expect(narrowed.results[0]?.status).toBe('reported'); expect(narrowed.results.at(-1)?.name).toBe('coverage'); expect(narrowed.results.at(-1)?.diagnostic ?? '').toMatch(/Knip workspace coverage mismatch/u); - expect( - narrowed.results.some((result) => result.name === 'coverage' && result.status === 'error'), - ).toBe(true); + expect(narrowed.results.some((result) => result.name === 'coverage' && result.status === 'error')).toBe(true); writeFileSync( path.join(root, 'quality-audit/fallow.json'), - yield* encodeReport({ ignorePatterns: ['scripts/**', 'node_modules/**', 'packages/**'] }), + yield* encodeReport({ + ignorePatterns: ['scripts/**', 'node_modules/**', 'packages/**'], + }), ); const omittedSourceError = yield* Effect.flip(runFixture(root, output, 'fallow')); expect(omittedSourceError.message).toMatch(/analysis failed/u); const omitted = yield* summary(output); - expect( - omitted.results.some((result) => result.name === 'coverage' && result.status === 'error'), - ).toBe(true); - expect(readFileSync(path.join(omitted.runDirectory, 'coverage.json'), 'utf-8')).toMatch( - /scripts\/index.ts/u, - ); + expect(omitted.results.some((result) => result.name === 'coverage' && result.status === 'error')).toBe(true); + expect(readFileSync(path.join(omitted.runDirectory, 'coverage.json'), 'utf-8')).toMatch(/scripts\/index.ts/u); }), ); @@ -636,10 +651,7 @@ it.live( rmSync(path.join(root, 'node_modules')); mkdirSync(path.join(root, 'node_modules/knip/bin'), { recursive: true }); writeFileSync(path.join(root, 'node_modules/knip/bin/knip.js'), 'must never execute'); - writeFileSync( - path.join(root, 'node_modules/knip/package.json'), - yield* encodeReport({ version: '0.0.0' }), - ); + writeFileSync(path.join(root, 'node_modules/knip/package.json'), yield* encodeReport({ version: '0.0.0' })); const versionError = yield* Effect.flip(runFixture(root, output, 'knip')); expect(versionError.message).toMatch(/analysis failed/u); const mismatch = yield* summary(output); @@ -659,10 +671,7 @@ it.live( rmSync(path.join(root, 'node_modules')); const toolDirectory = path.join(root, 'node_modules/jscpd'); mkdirSync(toolDirectory, { recursive: true }); - writeFileSync( - path.join(toolDirectory, PACKAGE_JSON), - yield* encodeReport({ type: 'module', version: '5.1.2' }), - ); + writeFileSync(path.join(toolDirectory, PACKAGE_JSON), yield* encodeReport({ type: 'module', version: '5.1.2' })); const report = yield* encodeReport({ duplicates: [], statistics: { total: { clones: 0, sources: 2 } }, @@ -713,15 +722,10 @@ it.live('external report directories preserve valid Fallow exclusions and source mkdirSync(generatedTypes, { recursive: true }); writeFileSync(path.join(root, GITIGNORE_FILE), '**/@mf-types/\n'); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const initialized = yield* spawner.exitCode( - ChildProcess.make('git', ['init', '-q'], { cwd: root }), - ); + const initialized = yield* spawner.exitCode(ChildProcess.make(NATIVE_GIT, ['init', '-q'], { cwd: root })); expect(Number(initialized)).toBe(0); - writeFileSync( - path.join(generatedTypes, 'index.d.ts'), - 'export declare const remoteComponent: unknown;\n', - ); - yield* runQualityAudit(root, output, 'fallow'); + writeFileSync(path.join(generatedTypes, 'index.d.ts'), 'export declare const remoteComponent: unknown;\n'); + yield* runFixture(root, output, 'fallow'); const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SummarySchema))( readFileSync(path.join(output, SUMMARY_FILE), 'utf-8'), ); @@ -739,9 +743,7 @@ it.live('external report directories preserve valid Fallow exclusions and source )(readFileSync(path.join(result.runDirectory, 'coverage.json'), 'utf-8')); // Two authored fixture sources plus the copied Knip reporter, not remote declarations. expect(coverage).toEqual({ extra: [], intendedSources: 3, missing: [] }); - expect( - result.results.some((row) => row.name === FALLOW_HEALTH && row.findings > 0), - ).toBeTruthy(); + expect(result.results.some((row) => row.name === FALLOW_HEALTH && row.findings > 0)).toBeTruthy(); expect(readFileSync(path.join(result.runDirectory, 'configs/fallow.json'), 'utf-8')).toBe( readFileSync(path.join(root, 'quality-audit/fallow.json'), 'utf-8'), ); diff --git a/app/scripts/tests/quality-cli-lifecycle.test.mts b/app/scripts/tests/quality-cli-lifecycle.test.mts index 3a4d79bac..e18bb2b18 100644 --- a/app/scripts/tests/quality-cli-lifecycle.test.mts +++ b/app/scripts/tests/quality-cli-lifecycle.test.mts @@ -1,6 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { NodeServices } from '@effect/platform-node'; import { Effect, Schema, Stream } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -8,9 +8,7 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const lifecycleUrl = new URL('../quality-cli-lifecycle.mts', import.meta.url).href; const runChild = Effect.fn('runLifecycleChild')(function* runLifecycleChild(source: string) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make(process.execPath, ['--input-type=module', '-e', source]), - ); + const child = yield* spawner.spawn(ChildProcess.make(process.execPath, ['--input-type=module', '-e', source])); const [stdout, stderr, code] = yield* Effect.all( [ child.stdout.pipe(Stream.decodeText(), Stream.mkString), @@ -32,12 +30,8 @@ const verifyImports = Effect.gen(function* verifyInertImports() { expect(yield* runChild(imports)).toEqual({ code: 0, stderr: '', stdout: '' }); }); -it.live('CLI modules are inert when imported', () => - verifyImports.pipe(Effect.provide(NodeServices.layer)), -); -const verifyFinalization = Effect.fn('verifyFinalization')(function* verifyCliFinalization( - fails: boolean, -) { +it.live('CLI modules are inert when imported', () => verifyImports.pipe(Effect.provide(NodeServices.layer))); +const verifyFinalization = Effect.fn('verifyFinalization')(function* verifyCliFinalization(fails: boolean) { const result = yield* runChild(` import { Console, Data, Effect } from 'effect'; import { runQualityCli } from ${encodeJson(lifecycleUrl)}; diff --git a/app/scripts/tests/report-fail-closed-authorization-impact.test.mts b/app/scripts/tests/report-fail-closed-authorization-impact.test.mts index 8df733a91..be30e446c 100644 --- a/app/scripts/tests/report-fail-closed-authorization-impact.test.mts +++ b/app/scripts/tests/report-fail-closed-authorization-impact.test.mts @@ -42,12 +42,8 @@ it('impact reduction is deterministic and aggregates sanitized evidence', () => }); it('impact reduction rejects mixed build evidence and sensitive extra fields', () => { - expect(() => reduceAuthorizationImpact([event(), event({ sourceRevision: 'other' })])).toThrow( - /mixes/u, - ); - expect(() => reduceAuthorizationImpact([event({ principalId: 'secret' })])).toThrow( - /prohibited/u, - ); + expect(() => reduceAuthorizationImpact([event(), event({ sourceRevision: 'other' })])).toThrow(/mixes/u); + expect(() => reduceAuthorizationImpact([event({ principalId: 'secret' })])).toThrow(/prohibited/u); expect(() => reduceAuthorizationImpact([event({ tenantId: 'secret' })])).toThrow(/prohibited/u); }); diff --git a/app/scripts/tests/root-environment.test.mts b/app/scripts/tests/root-environment.test.mts index 67f9943d1..ffe2d1870 100644 --- a/app/scripts/tests/root-environment.test.mts +++ b/app/scripts/tests/root-environment.test.mts @@ -1,12 +1,12 @@ -import { expect, it } from 'effect-rstest'; -import { Effect } from 'effect'; - import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + const appRoot = path.resolve(import.meta.dirname, '../..'); const repositoryRoot = path.dirname(appRoot); const expectedEnvironmentPath = path.join(appRoot, '.env'); @@ -14,16 +14,7 @@ const expectedEnvironmentPath = path.join(appRoot, '.env'); it('apps contain no environment files that can override the app-root .env', () => { const result = spawnSync( '/usr/bin/find', - [ - path.join(appRoot, 'apps'), - '-type', - 'f', - '-name', - '.env*', - '-not', - '-path', - '*/node_modules/*', - ], + [path.join(appRoot, 'apps'), '-type', 'f', '-name', '.env*', '-not', '-path', '*/node_modules/*'], { encoding: 'utf-8' }, ); @@ -53,13 +44,7 @@ it('all server configuration resolves the app-root .env from any invocation dire const probe = new URL('server-environment-paths.fixture.mts', import.meta.url); const child = spawnSync( '/usr/bin/env', - [ - '-u', - 'ULTRAMODERN_WORKSPACE_ROOT', - `INIT_CWD=${repositoryRoot}`, - process.execPath, - fileURLToPath(probe), - ], + ['-u', 'ULTRAMODERN_WORKSPACE_ROOT', `INIT_CWD=${repositoryRoot}`, process.execPath, fileURLToPath(probe)], { cwd: '/', encoding: 'utf-8', diff --git a/app/scripts/tests/server-environment-paths.fixture.mts b/app/scripts/tests/server-environment-paths.fixture.mts index 1901901fe..c5b32a327 100644 --- a/app/scripts/tests/server-environment-paths.fixture.mts +++ b/app/scripts/tests/server-environment-paths.fixture.mts @@ -1,11 +1,10 @@ import { NodeRuntime } from '@effect/platform-node'; import { Console } from 'effect'; + +import { ROOT_ENV_PATH as authenticationEnvironmentPath } from '../../apps/shell-super-app/api/auth/config.ts'; import { ROOT_ENV_PATH as databaseEnvironmentPath } from '../../packages/core-runtime/src/db/config.ts'; import { SPICEDB_ROOT_ENV_PATH } from '../../packages/core-runtime/src/permissions/config.ts'; -import { ROOT_ENV_PATH as authenticationEnvironmentPath } from '../../apps/shell-super-app/api/auth/config.ts'; NodeRuntime.runMain( - Console.log( - JSON.stringify([databaseEnvironmentPath, SPICEDB_ROOT_ENV_PATH, authenticationEnvironmentPath]), - ), + Console.log(JSON.stringify([databaseEnvironmentPath, SPICEDB_ROOT_ENV_PATH, authenticationEnvironmentPath])), ); diff --git a/app/scripts/tests/setup-agent-reference-repos.test.mts b/app/scripts/tests/setup-agent-reference-repos.test.mts new file mode 100644 index 000000000..4a1447f8e --- /dev/null +++ b/app/scripts/tests/setup-agent-reference-repos.test.mts @@ -0,0 +1,186 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + +const script = fileURLToPath(new URL('../setup-agent-reference-repos.mts', import.meta.url)); +const configPath = '.agents/agent-reference-repos.json'; +const gitCallsPath = 'git-calls.txt'; +const manifestPath = '.modernjs/agent-reference-repos.json'; +const repository = { + id: 'fixture', + name: 'Fixture reference', + path: 'repos/fixture', + readOnly: true, + ref: 'main', + url: 'https://example.invalid/fixture.git', +}; +const config = { + defaultEnabled: true, + installDir: 'repos', + repositories: [repository], + schemaVersion: 1, + strategy: 'git-subtree-squash', +}; + +const fixtureDirectory = Effect.gen(function* fixtureDirectory() { + const root = yield* Effect.acquireRelease( + Effect.sync(() => mkdtempSync(path.join(tmpdir(), 'ontos-agent-reference-'))), + (directory) => Effect.sync(() => rmSync(directory, { force: true, recursive: true })), + ); + mkdirSync(path.join(root, '.agents')); + mkdirSync(path.join(root, 'bin')); + writeFileSync(path.join(root, configPath), JSON.stringify(config)); + // No real Git mutation or network access: record the exact native child-process contract. + writeFileSync( + path.join(root, 'bin/git'), + `#!/bin/sh +printf '%s\\n' "$*" >> git-calls.txt +case "$*" in + '--version') printf 'git version fixture\\n' ;; + 'subtree -h') printf 'usage: git subtree\\n'; exit 129 ;; + 'rev-parse --is-inside-work-tree') printf 'true\\n' ;; + 'rev-parse --verify HEAD') printf 'fixture-head\\n' ;; + 'status --porcelain') ;; + 'status --porcelain -- '* ) printf ' M manifest\\n' ;; + 'log '* ) printf 'fixture-subtree-commit\\n' ;; + 'ls-remote '* ) printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\\n' ;; + 'fetch '* | 'subtree add '* | 'add '* | 'commit '* ) ;; + *) printf 'Unexpected git invocation: %s\\n' "$*" >&2; exit 91 ;; +esac +`, + { mode: 0o755 }, + ); + return root; +}); +const runSetup = (root: string, args: readonly string[] = [], env: Record = {}) => { + const result = spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: 'utf-8', + env: { PATH: path.join(root, 'bin'), ...env }, + timeout: 15_000, + }); + expect(result.error).toBeUndefined(); + return result; +}; + +it.live('optional reference setup warns, while check and required modes fail closed', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + rmSync(path.join(root, configPath)); + for (const [args, env, status] of [ + [[], {}, 0], + [['--check'], {}, 1], + [[], { ULTRAMODERN_AGENT_REPOS_REQUIRED: 'true' }, 1], + ] as const) { + const result = runSetup(root, args, env); + expect(result.status, result.stdout + result.stderr).toBe(status); + expect(result.stdout + result.stderr).toMatch(/Missing \.agents\/agent-reference-repos\.json/u); + expect(existsSync(path.join(root, gitCallsPath))).toBe(false); + } + }), +); + +it.live('disabled reference setup never invokes Git or writes a manifest', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + const disabledEnvironments: readonly Record[] = [ + { ULTRAMODERN_SKIP_AGENT_REPOS: 'YES' }, + { ULTRAMODERN_AGENT_REPOS: 'OFF' }, + ]; + for (const env of disabledEnvironments) { + const result = runSetup(root, [], env); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout + result.stderr).toMatch(/setup skipped/u); + } + writeFileSync(path.join(root, configPath), JSON.stringify({ ...config, defaultEnabled: false })); + expect(runSetup(root).status).toBe(0); + expect(existsSync(path.join(root, gitCallsPath))).toBe(false); + expect(existsSync(path.join(root, manifestPath))).toBe(false); + }), +); + +it.live('reference setup rejects malformed configuration and unsafe paths before Git', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + writeFileSync(path.join(root, configPath), '{invalid'); + const malformed = runSetup(root, ['--check']); + expect(malformed.status).toBe(1); + expect(malformed.stdout + malformed.stderr).toMatch(/Invalid reference repository configuration/u); + for (const unsafePath of [ + '../outside', + 'repos/../outside', + String.raw`repos\..\outside`, + '/repos/fixture', + 'repos/.', + 'repos/', + 'repos//.', + ]) { + writeFileSync( + path.join(root, configPath), + JSON.stringify({ + ...config, + repositories: [{ ...repository, path: unsafePath }], + }), + ); + const result = runSetup(root, ['--check']); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stdout + result.stderr).toMatch(/Unsafe reference repository path/u); + } + expect(existsSync(path.join(root, gitCallsPath))).toBe(false); + }), +); + +it.live('reference check requires subtree evidence and never mutates Git or the manifest', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + const missing = runSetup(root, ['--check']); + expect(missing.status, missing.stdout + missing.stderr).toBe(1); + expect(missing.stdout + missing.stderr).toMatch(/repos\/fixture is missing/u); + mkdirSync(path.join(root, repository.path), { recursive: true }); + const present = runSetup(root, ['--check']); + expect(present.status, present.stdout + present.stderr).toBe(0); + const calls = readFileSync(path.join(root, gitCallsPath), 'utf-8'); + expect(calls).toMatch(/log --grep git-subtree-dir: repos\/fixture/u); + expect(calls).not.toMatch(/^(?:fetch|add|commit|init|subtree add)\b/mu); + expect(existsSync(path.join(root, manifestPath))).toBe(false); + }), +); + +it.live('reference installation defaults check off and preserves commit hooks', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + const result = runSetup(root, [], { + ULTRAMODERN_AGENT_REPOS_REQUIRED: 'true', + }); + expect(result.status, result.stdout + result.stderr).toBe(0); + const manifest = readFileSync(path.join(root, manifestPath), 'utf-8'); + expect(manifest).toMatch(/"status": "installed"/u); + expect(manifest).toMatch(/"commit": "a{40}"/u); + expect(manifest).toMatch(/"installedAt": "\d{4}-\d{2}-\d{2}T/u); + const calls = readFileSync(path.join(root, gitCallsPath), 'utf-8'); + expect(calls).toMatch(/fetch --depth 1 https:\/\/example.invalid\/fixture.git main/u); + expect(calls).toMatch(/subtree add --prefix repos\/fixture FETCH_HEAD --squash/u); + expect(calls).toMatch(/commit -m Record agent reference repo manifest/u); + expect(calls).not.toMatch(/--no-verify/u); + }), +); + +it.live('reference refresh refuses existing subtrees without fetching or overwriting', () => + Effect.gen(function* referenceScenario() { + const root = yield* fixtureDirectory; + mkdirSync(path.join(root, repository.path), { recursive: true }); + const result = runSetup(root, [], { + ULTRAMODERN_AGENT_REPOS_REFRESH: 'true', + ULTRAMODERN_AGENT_REPOS_REQUIRED: 'true', + }); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stdout + result.stderr).toMatch(/refresh for subtree references is intentionally manual/u); + expect(readFileSync(path.join(root, gitCallsPath), 'utf-8')).not.toMatch(/^(?:fetch|subtree add)\b/mu); + expect(existsSync(path.join(root, manifestPath))).toBe(false); + }), +); diff --git a/app/scripts/tests/tooling-process-fixture.mts b/app/scripts/tests/tooling-process-fixture.mts index eeff578d8..ae2ebbd88 100644 --- a/app/scripts/tests/tooling-process-fixture.mts +++ b/app/scripts/tests/tooling-process-fixture.mts @@ -1,11 +1,8 @@ import { Effect, Stream } from 'effect'; import { ChildProcessSpawner } from 'effect/unstable/process'; - import type { ChildProcess } from 'effect/unstable/process'; -export const collectToolingProcess = Effect.fn(function* collectToolingProcess( - command: ChildProcess.Command, -) { +export const collectToolingProcess = Effect.fn(function* collectToolingProcess(command: ChildProcess.Command) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const handle = yield* spawner.spawn(command); const [status, stdout, stderr] = yield* Effect.all( diff --git a/app/scripts/tests/typecheck-project-references.test.mts b/app/scripts/tests/typecheck-project-references.test.mts index a3c666a95..88205c0c6 100644 --- a/app/scripts/tests/typecheck-project-references.test.mts +++ b/app/scripts/tests/typecheck-project-references.test.mts @@ -1,12 +1,11 @@ -import { expect, it } from 'effect-rstest'; - import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; - import { fileURLToPath, pathToFileURL } from 'node:url'; + import { NodeServices } from '@effect/platform-node'; import { Config, Effect, Predicate, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess } from 'effect/unstable/process'; import { collectToolingProcess } from './tooling-process-fixture.mts'; @@ -56,7 +55,7 @@ const runTypecheck = (fixture: string, commandArguments: readonly string[]) => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); it.live( - 'installed workspace generator keeps build mode as the root typecheck default', + 'installed generator and consumer both check the full project reference graph', Effect.fn(function* testEffect2() { const generator = Schema.decodeUnknownSync(WorkspaceScriptPlanModuleSchema)( yield* Effect.tryPromise( @@ -65,18 +64,15 @@ it.live( pathToFileURL( path.join( workspaceRoot, - 'node_modules/@modern-js/create/dist/esm-node/ultramodern-workspace/workspace-script-plan.js', + 'node_modules/@modern-js/ultramodern-create/dist/esm-node/ultramodern-workspace/workspace-script-plan.js', ), ).href ), ), ); - const scriptPlan = Schema.decodeUnknownSync(WorkspaceScriptPlanSchema)( - generator.createWorkspaceRootScriptPlan([]), - ); - expect(scriptPlan.typecheck).toBe( - 'node ./scripts/ultramodern-typecheck.mts --build tsconfig.json', - ); + const scriptPlan = Schema.decodeUnknownSync(WorkspaceScriptPlanSchema)(generator.createWorkspaceRootScriptPlan([])); + expect(scriptPlan.typecheck).toBe('node ./scripts/ultramodern-typecheck.mts --build tsconfig.json'); + expect(packageJson.scripts.typecheck).toBe('node ./scripts/ultramodern-typecheck.mts --build tsconfig.json'); }), ); @@ -87,11 +83,7 @@ it.live( Effect.sync(() => mkdtempSync(path.join(os.tmpdir(), 'ontos-drizzle-declarations-'))), (directory) => Effect.sync(() => rmSync(directory, { force: true, recursive: true })), ); - symlinkSync( - path.join(workspaceRoot, 'node_modules'), - path.join(fixture, 'node_modules'), - 'dir', - ); + symlinkSync(path.join(workspaceRoot, 'node_modules'), path.join(fixture, 'node_modules'), 'dir'); writeFileSync(path.join(fixture, packageJsonFile), '{"private":true,"type":"module"}\n'); writeFileSync( path.join(fixture, tsconfigFile), @@ -129,11 +121,7 @@ it.live( (directory) => Effect.sync(() => rmSync(directory, { force: true, recursive: true })), ); mkdirSync(path.join(fixture, 'referenced')); - symlinkSync( - path.join(workspaceRoot, 'node_modules'), - path.join(fixture, 'node_modules'), - 'dir', - ); + symlinkSync(path.join(workspaceRoot, 'node_modules'), path.join(fixture, 'node_modules'), 'dir'); writeFileSync(path.join(fixture, packageJsonFile), '{"private":true,"type":"module"}\n'); writeFileSync( path.join(fixture, tsconfigFile), @@ -162,9 +150,7 @@ it.live( const initial = yield* runTypecheck(fixture, args); expect(initial.status, initial.stdout + initial.stderr).toBe(0); expect( - readFileSync(path.join(fixture, 'referenced/output/index.d.ts'), 'utf-8').includes( - 'referenceGateFixture', - ), + readFileSync(path.join(fixture, 'referenced/output/index.d.ts'), 'utf-8').includes('referenceGateFixture'), 'the referenced project must actually be built; a root files:[] project check is a no-op', ).toBe(true); writeFileSync(sourceFile, 'export const referenceGateFixture: number = "invalid";\n'); diff --git a/app/scripts/tests/ultramodern-command.test.mts b/app/scripts/tests/ultramodern-command.test.mts index 5e041157f..625d9e6ed 100644 --- a/app/scripts/tests/ultramodern-command.test.mts +++ b/app/scripts/tests/ultramodern-command.test.mts @@ -1,13 +1,16 @@ -import { expect, it } from 'effect-rstest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import { NodeServices } from '@effect/platform-node'; import { Effect, Stream } from 'effect'; +import { expect, it } from 'effect-rstest'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const createFilename = 'create.mjs'; +const routeGeneratorScript = 'generate-tanstack-routes'; const wrappers = [ ['assert-mf-types', 'mf-types'], ['generate-node-backend-federation', 'backend-federation-generate'], @@ -25,26 +28,18 @@ const fixtureDirectory = () => (directory) => Effect.sync(() => rmSync(directory, { force: true, recursive: true })), ); -const invokeWrapper = ( - script: string, - environment: Readonly>, - args: readonly string[] = [], -) => +const invokeWrapper = (script: string, environment: Readonly>, args: readonly string[] = []) => Effect.gen(function* invokeWrapperEffect() { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( - ChildProcess.make( - process.execPath, - [path.join(workspaceRoot, 'scripts', `${script}.mts`), ...args], - { - cwd: workspaceRoot, - env: environment, - extendEnv: true, - stderr: 'pipe', - stdin: 'ignore', - stdout: 'pipe', - }, - ), + ChildProcess.make(process.execPath, [path.join(workspaceRoot, 'scripts', `${script}.mts`), ...args], { + cwd: workspaceRoot, + env: environment, + extendEnv: true, + stderr: 'pipe', + stdin: 'ignore', + stdout: 'pipe', + }), ); return yield* Effect.all( { @@ -61,38 +56,39 @@ for (const [script, command] of wrappers) { `${script} forwards arguments, workspace and child exit status`, Effect.fn(function* mergedScenario1() { const fixture = yield* fixtureDirectory(); - const createBin = path.join(fixture, 'create.mjs'); + const createBin = path.join(fixture, createFilename); writeFileSync( createBin, 'console.log(process.argv.slice(2).join("|")); console.log(process.env.ULTRAMODERN_WORKSPACE_ROOT); process.exitCode = 7;', ); const result = yield* invokeWrapper( script, - { ULTRAMODERN_CREATE_BIN: createBin, ULTRAMODERN_WORKSPACE_ROOT: fixture }, + { + ULTRAMODERN_CREATE_BIN: createBin, + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }, ['--probe', 'argument with spaces'], ); expect(result.status, result.stderr).toBe(7); - expect(result.stdout).toBe( - `ultramodern|${command}|--probe|argument with spaces\n${fixture}\n`, - ); + expect(result.stdout).toBe(`ultramodern|${command}|--probe|argument with spaces\n${fixture}\n`); }), ); } it.live( - 'route generation continues compatibility generation after a nonzero framework exit', + 'route generation fails closed on a nonzero framework exit', Effect.fn(function* mergedScenario2() { const fixture = yield* fixtureDirectory(); - const createBin = path.join(fixture, 'create.mjs'); + const createBin = path.join(fixture, createFilename); writeFileSync(createBin, 'process.exitCode = 7;'); mkdirSync(path.join(fixture, '.modernjs')); writeFileSync(path.join(fixture, '.modernjs/ultramodern.json'), '{"topology":{"apps":[]}}'); - const result = yield* invokeWrapper('generate-tanstack-routes', { + const result = yield* invokeWrapper(routeGeneratorScript, { ULTRAMODERN_CREATE_BIN: createBin, ULTRAMODERN_WORKSPACE_ROOT: fixture, }); - expect(result.status, result.stderr).toBe(0); - expect(result.stderr).toMatch(/continuing with the repository compatibility manifest/u); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toMatch(/Framework route-artifact generation failed: exit 7/u); }), ); @@ -106,7 +102,97 @@ it.live( ULTRAMODERN_WORKSPACE_ROOT: fixture, }); expect(result.status).toBe(1); - expect(result.stderr).toMatch(/Failed to launch modern-js-create from PATH/u); + expect(result.stderr).toMatch(/Failed to launch ultramodern-create from PATH/u); expect(result.stderr).toMatch(/UltraModern command "mf-types"/u); }), ); + +const routeFixture = Effect.fn(function* routeFixture(scope: string) { + const fixture = yield* fixtureDirectory(); + const ownerPath = 'verticals/inventory'; + mkdirSync(path.join(fixture, '.modernjs')); + mkdirSync(path.join(fixture, ownerPath, 'src/routes/items'), { + recursive: true, + }); + mkdirSync(path.join(fixture, 'bin')); + writeFileSync(path.join(fixture, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { + mode: 0o755, + }); + writeFileSync( + path.join(fixture, '.modernjs/ultramodern.json'), + JSON.stringify({ + topology: { apps: [{ id: 'inventory', path: ownerPath }] }, + }), + ); + writeFileSync( + path.join(fixture, ownerPath, 'package.json'), + JSON.stringify({ + modernjs: { ontosModule: { moduleId: 'inventory' } }, + }), + ); + const metadata = { + canonicalPath: '/items', + descriptionKey: 'items.description', + entrypoint: { + access: 'read', + authorization: { kind: 'public' }, + entrypointKey: 'inventory.items', + moduleKey: 'inventory', + role: 'page', + scope, + }, + id: 'items', + indexable: false, + localisedPaths: { cs: '/polozky', en: '/items' }, + namespace: 'inventory', + ownerAppId: 'inventory', + public: false, + titleKey: 'items.title', + }; + writeFileSync( + path.join(fixture, ownerPath, 'src/routes/items/route.meta.ts'), + `export const routeMeta = ${JSON.stringify(metadata)};\n`, + ); + const createBin = path.join(fixture, createFilename); + writeFileSync( + createBin, + `import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +const manifest = readFileSync(path.join(process.env.ULTRAMODERN_WORKSPACE_ROOT, '${ownerPath}/src/routes/ultramodern-route-metadata.ts'), 'utf8'); +const urls = JSON.parse(manifest.split('export const ultramodernLocalisedUrls = ')[1].split(' as const;')[0]); +assert.deepEqual(urls, { '/items': { cs: '/polozky', en: '/items' } }); +console.log('framework observed canonical-only metadata'); +`, + ); + return { createBin, fixture }; +}); + +it.live( + 'route metadata precedes framework generation and keeps canonical-only locale keys', + Effect.fn(function* canonicalRouteMetadata() { + const { createBin, fixture } = yield* routeFixture('tenant'); + const result = yield* invokeWrapper(routeGeneratorScript, { + PATH: path.join(fixture, 'bin'), + ULTRAMODERN_CREATE_BIN: createBin, + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toMatch(/framework observed canonical-only metadata/u); + }), +); + +it.live( + 'route metadata with the wrong owner scope fails before framework launch', + Effect.fn(function* invalidRouteScope() { + const { createBin, fixture } = yield* routeFixture('system'); + const result = yield* invokeWrapper(routeGeneratorScript, { + PATH: path.join(fixture, 'bin'), + ULTRAMODERN_CREATE_BIN: createBin, + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toMatch(/must declare one governed tenant page entrypoint owned by inventory/u); + expect(result.stdout).not.toMatch(/framework observed/u); + }), +); diff --git a/app/scripts/typescript-api-contract-boundary.mts b/app/scripts/typescript-api-contract-boundary.mts index 1775dd425..8fd4886b3 100644 --- a/app/scripts/typescript-api-contract-boundary.mts +++ b/app/scripts/typescript-api-contract-boundary.mts @@ -2,15 +2,9 @@ import path from 'node:path'; -import type { CallExpression, Expression, MemberExpression, VariableDeclarator } from 'oxc-parser'; -import { - ExportExportNameKind, - ExportImportNameKind, - ImportNameKind, - parseSync, - Visitor, -} from 'oxc-parser'; import { Result, Schema } from 'effect'; +import type { CallExpression, Expression, MemberExpression, VariableDeclarator } from 'oxc-parser'; +import { ExportExportNameKind, ExportImportNameKind, ImportNameKind, parseSync, Visitor } from 'oxc-parser'; /* oxlint-disable no-nested-ternary, no-use-before-define, prefer-destructuring, prefer-template, anti-slop/require-safety-comment-for-type-assertion, perfectionist/sort-interfaces, perfectionist/sort-objects, sonarjs/function-name, sonarjs/no-collapsible-if, sonarjs/no-duplicate-string, sonarjs/too-many-break-or-continue-in-loop, typescript/no-unsafe-type-assertion, unicorn/no-array-reverse, unicorn/no-array-sort, unicorn/no-lonely-if, unicorn/no-nested-ternary -- Oxc requires syntax-node callback keys, narrowed generated-node bridges, mutually recursive graph resolvers, and a single indexed resolver model; remove-when: Oxc exposes a typed scope/module graph or these resolvers move behind dedicated typed modules; expires: 2026-12-31. */ @@ -33,7 +27,11 @@ interface ImportBinding { type ExportBinding = | { readonly kind: 'expression'; readonly span: Span } | { readonly kind: 'local'; readonly local: string } - | { readonly imported: string; readonly kind: 'reexport'; readonly specifier: string } + | { + readonly imported: string; + readonly kind: 'reexport'; + readonly specifier: string; + } | { readonly kind: 'namespace'; readonly specifier: string } | { readonly kind: 'star'; readonly specifier: string }; @@ -74,16 +72,9 @@ interface SourceModel { } const forbiddenSchemaMembers = new Set(['Any', 'Json', 'Unknown', 'UnknownFromJsonString']); -const problemDetailsFactoryNames = new Set([ - 'makeProblemDetailsSchema', - 'makeRetryableProblemDetailsSchema', -]); +const problemDetailsFactoryNames = new Set(['makeProblemDetailsSchema', 'makeRetryableProblemDetailsSchema']); const endpointMethods = new Set(['delete', 'get', 'head', 'options', 'patch', 'post', 'put']); -const schemaProviderSpecifiers = new Set([ - '@modern-js/plugin-bff/effect-client', - 'effect', - 'effect/Schema', -]); +const schemaProviderSpecifiers = new Set(['@modern-js/plugin-bff/effect-client', 'effect', 'effect/Schema']); const endpointProviderSpecifiers = new Set([ '@modern-js/plugin-bff/effect-client', '@modern-js/plugin-bff/effect-edge', @@ -110,9 +101,7 @@ const PackageExportValueSchema: Schema.Codec = Schema.suspen Schema.Record(Schema.String, PackageExportValueSchema), ]), ); -const PackageJsonExportsSchema = Schema.fromJsonString( - Schema.Struct({ exports: PackageExportValueSchema }), -); +const PackageJsonExportsSchema = Schema.fromJsonString(Schema.Struct({ exports: PackageExportValueSchema })); const isPackageExportString = Schema.is(Schema.String); const isPackageExportArray = Schema.is(Schema.Array(PackageExportValueSchema)); const isPackageExportRecord = Schema.is(Schema.Record(Schema.String, PackageExportValueSchema)); @@ -135,17 +124,17 @@ interface PackageExportResolution { const isRootPackageExport = (value: PackageExportValue): boolean => value === null || isPackageExportString(value) || isPackageExportArray(value); -const packageExportResolution = ( - packageJson: string, - exportKey: string, -): PackageExportResolution => { +const packageExportResolution = (packageJson: string, exportKey: string): PackageExportResolution => { const parsed = Schema.decodeUnknownResult(PackageJsonExportsSchema)(packageJson); if (Result.isFailure(parsed)) { return { governed: false, targets: [] }; } const exportsField = parsed.success.exports; if (isRootPackageExport(exportsField)) { - return { governed: true, targets: exportKey === '.' ? exportTargets(exportsField) : [] }; + return { + governed: true, + targets: exportKey === '.' ? exportTargets(exportsField) : [], + }; } if (!isPackageExportRecord(exportsField)) { return { governed: true, targets: [] }; @@ -154,7 +143,10 @@ const packageExportResolution = ( return { governed: true, targets: exportTargets(exportsField) }; } if (Object.hasOwn(exportsField, exportKey)) { - return { governed: true, targets: exportTargets(exportsField[exportKey] ?? null) }; + return { + governed: true, + targets: exportTargets(exportsField[exportKey] ?? null), + }; } return wildcardExportResolution(exportsField, exportKey); }; @@ -175,7 +167,10 @@ const wildcardExportResolution = ( } const substitution = exportKey.slice(prefix.length, exportKey.length - suffix.length); return [ - { key, targets: exportTargets(value).map((target) => target.replaceAll('*', substitution)) }, + { + key, + targets: exportTargets(value).map((target) => target.replaceAll('*', substitution)), + }, ]; }); if (wildcardMatches.length === 0) { @@ -206,23 +201,19 @@ const resolveSources = ( ); const bases = specifier.startsWith('.') ? [path.posix.normalize(path.posix.join(path.posix.dirname(importingFile), specifier))] - : (packageRootsWithManifest.length === 0 ? packageRoots : packageRootsWithManifest).flatMap( - (packageRoot) => { - const packageJson = context.sources.get(`${packageRoot}/package.json`); - if (packageJson !== undefined) { - const exportKey = subpath === undefined ? '.' : `./${subpath}`; - const resolution = packageExportResolution(packageJson, exportKey); - if (resolution.governed) { - return resolution.targets.map((target) => - path.posix.normalize(path.posix.join(packageRoot, target)), - ); - } + : (packageRootsWithManifest.length === 0 ? packageRoots : packageRootsWithManifest).flatMap((packageRoot) => { + const packageJson = context.sources.get(`${packageRoot}/package.json`); + if (packageJson !== undefined) { + const exportKey = subpath === undefined ? '.' : `./${subpath}`; + const resolution = packageExportResolution(packageJson, exportKey); + if (resolution.governed) { + return resolution.targets.map((target) => path.posix.normalize(path.posix.join(packageRoot, target))); } - return subpath === undefined - ? [`${packageRoot}/src/index`, `${packageRoot}/index`] - : [`${packageRoot}/src/${subpath}`, `${packageRoot}/${subpath}`]; - }, - ); + } + return subpath === undefined + ? [`${packageRoot}/src/index`, `${packageRoot}/index`] + : [`${packageRoot}/src/${subpath}`, `${packageRoot}/${subpath}`]; + }); return [ ...new Set( bases.flatMap((base) => { @@ -266,11 +257,7 @@ const moduleSpecifierTargets = ( const externalSpecifier = (file: string): string | undefined => file.startsWith(externalModulePrefix) ? file.slice(externalModulePrefix.length) : undefined; -const addExport = ( - exports: Map, - exportedName: string, - binding: ExportBinding, -): void => { +const addExport = (exports: Map, exportedName: string, binding: ExportBinding): void => { const existing = exports.get(exportedName) ?? []; existing.push(binding); exports.set(exportedName, existing); @@ -355,13 +342,11 @@ const parseSourceModel = (file: string, content: string): SourceModel => { const enclosingScope = (span: Span): Span => scopes .filter((scope) => within(span, scope)) - .sort((left, right) => left.end - left.start - (right.end - right.start))[0] ?? - parsed.program; + .sort((left, right) => left.end - left.start - (right.end - right.start))[0] ?? parsed.program; const enclosingFunctionScope = (span: Span): Span => functionScopes .filter((scope) => within(span, scope)) - .sort((left, right) => left.end - left.start - (right.end - right.start))[0] ?? - parsed.program; + .sort((left, right) => left.end - left.start - (right.end - right.start))[0] ?? parsed.program; const addBinding = (name: string, binding: ScopedBinding): void => { const existing = bindings.get(name) ?? []; existing.push(binding); @@ -470,7 +455,10 @@ const parseSourceModel = (file: string, content: string): SourceModel => { }, ClassDeclaration: (node) => { if (node.id !== null) { - addBinding(node.id.name, { kind: 'shadow', scope: enclosingScope(node) }); + addBinding(node.id.name, { + kind: 'shadow', + scope: enclosingScope(node), + }); } }, FunctionDeclaration: (node) => { @@ -503,7 +491,10 @@ const parseSourceModel = (file: string, content: string): SourceModel => { }, ReturnStatement: (node) => { if (node.argument !== null) { - returns.push({ expression: node.argument, scope: enclosingFunctionScope(node) }); + returns.push({ + expression: node.argument, + scope: enclosingFunctionScope(node), + }); } }, TSEnumDeclaration: (node) => { @@ -511,18 +502,23 @@ const parseSourceModel = (file: string, content: string): SourceModel => { }, TSModuleDeclaration: (node) => { if (node.id.type === 'Identifier') { - addBinding(node.id.name, { kind: 'shadow', scope: enclosingScope(node) }); + addBinding(node.id.name, { + kind: 'shadow', + scope: enclosingScope(node), + }); } }, VariableDeclarator: (node: VariableDeclarator) => { - const scope = varDeclarators.has(node.start) - ? enclosingFunctionScope(node) - : enclosingScope(node); + const scope = varDeclarators.has(node.start) ? enclosingFunctionScope(node) : enclosingScope(node); if (node.id.type === 'Identifier') { if (node.init === null) { addBinding(node.id.name, { kind: 'shadow', scope }); } else { - addBinding(node.id.name, { expression: node.init, kind: 'expression', scope }); + addBinding(node.id.name, { + expression: node.init, + kind: 'expression', + scope, + }); if (scope === parsed.program) { declarations.set(node.id.name, node.init); } @@ -531,16 +527,16 @@ const parseSourceModel = (file: string, content: string): SourceModel => { const source = node.init; const addDestructuredProperty = (property: (typeof node.id.properties)[number]): void => { const propertyName = destructuredPropertyName(property); - if ( - property.type === 'Property' && - propertyName !== undefined && - property.value.type === 'Identifier' - ) { + if (property.type === 'Property' && propertyName !== undefined && property.value.type === 'Identifier') { const binding = { member: propertyName, source, }; - addBinding(property.value.name, { ...binding, kind: 'destructured', scope }); + addBinding(property.value.name, { + ...binding, + kind: 'destructured', + scope, + }); if (scope === parsed.program) { destructured.set(property.value.name, binding); } @@ -593,15 +589,10 @@ const parseSourceModel = (file: string, content: string): SourceModel => { for (const declaration of parsed.module.staticExports) { const collectExportEntry = (entry: (typeof declaration.entries)[number]): void => { const exportName = (): string | undefined => - entry.exportName.kind === ExportExportNameKind.Default - ? 'default' - : (entry.exportName.name ?? undefined); + entry.exportName.kind === ExportExportNameKind.Default ? 'default' : (entry.exportName.name ?? undefined); const exportedName = exportName(); const specifier = entry.moduleRequest?.value; - if ( - entry.importName.kind === ExportImportNameKind.AllButDefault && - specifier !== undefined - ) { + if (entry.importName.kind === ExportImportNameKind.AllButDefault && specifier !== undefined) { starExports.push({ kind: 'star', specifier }); return; } @@ -622,7 +613,10 @@ const parseSourceModel = (file: string, content: string): SourceModel => { span: { end: entry.end, start: entry.start }, }); } else { - addExport(exports, exportedName, { kind: 'local', local: entry.localName.name }); + addExport(exports, exportedName, { + kind: 'local', + local: entry.localName.name, + }); } }; for (const entry of declaration.entries) { @@ -676,8 +670,7 @@ const scopedBindingsAt = ( model.bindings .get(name) ?.filter( - ({ at, scope }) => - position >= scope.start && position <= scope.end && (at === undefined || at <= position), + ({ at, scope }) => position >= scope.start && position <= scope.end && (at === undefined || at <= position), ) .sort( (left, right) => @@ -749,10 +742,7 @@ const memberPath = (expression: Expression): readonly string[] | undefined => { return undefined; }; -const objectPathExpression = ( - expression: Expression, - pathParts: readonly string[], -): Expression | undefined => { +const objectPathExpression = (expression: Expression, pathParts: readonly string[]): Expression | undefined => { const unwrapped = unwrapExpression(expression); const [member, ...rest] = pathParts; if (member === undefined || unwrapped.type !== 'ObjectExpression') { @@ -763,9 +753,7 @@ const objectPathExpression = ( continue; } const propertyName = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); + !property.computed && property.key.type === 'Identifier' ? property.key.name : staticString(property.key); if (propertyName !== member) { continue; } @@ -798,15 +786,7 @@ const staticStringAt = ( ? [] : scopedBindingsAt(model, unwrapped.name, position).flatMap((binding) => binding.kind === 'expression' - ? [ - staticStringAt( - context, - file, - binding.expression, - binding.expression.start, - visited, - ), - ] + ? [staticStringAt(context, file, binding.expression, binding.expression.start, visited)] : [], ); return values.find((value) => value !== undefined); @@ -838,19 +818,14 @@ const memberPathAt = ( return object === undefined || property === undefined ? undefined : [...object, property]; }; -const memberNameAt = ( - context: ApiContractSourceContext, - file: string, - member: MemberExpression, -): string | undefined => +const memberNameAt = (context: ApiContractSourceContext, file: string, member: MemberExpression): string | undefined => !member.computed && member.property.type === 'Identifier' ? member.property.name : member.computed ? staticStringAt(context, file, member.property, member.start, new Set()) : undefined; -const within = (inner: Span, outer: Span): boolean => - inner.start >= outer.start && inner.end <= outer.end; +const within = (inner: Span, outer: Span): boolean => inner.start >= outer.start && inner.end <= outer.end; const isForbiddenMember = (member: string, forbidRecord: boolean): boolean => forbiddenSchemaMembers.has(member) || (forbidRecord && member === 'Record'); @@ -884,13 +859,7 @@ const schemaNamespacePath = ( const resolveExpressionSchema = () => { const declarationPath = memberPath(scoped.expression); if (declarationPath !== undefined) { - return schemaNamespacePath( - context, - file, - [...declarationPath, ...rest], - scoped.expression.start, - visited, - ); + return schemaNamespacePath(context, file, [...declarationPath, ...rest], scoped.expression.start, visited); } const unwrapped = unwrapExpression(scoped.expression); return ( @@ -900,13 +869,7 @@ const schemaNamespacePath = ( (property) => property.type === 'SpreadElement' && memberPath(property.argument) !== undefined && - schemaNamespacePath( - context, - file, - memberPath(property.argument) ?? [], - property.argument.start, - visited, - ), + schemaNamespacePath(context, file, memberPath(property.argument) ?? [], property.argument.start, visited), ) ); }; @@ -916,13 +879,7 @@ const schemaNamespacePath = ( const sourcePath = memberPath(scoped.source); return ( sourcePath !== undefined && - schemaNamespacePath( - context, - file, - [...sourcePath, scoped.member, ...rest], - scoped.source.start, - visited, - ) + schemaNamespacePath(context, file, [...sourcePath, scoped.member, ...rest], scoped.source.start, visited) ); } if (scoped.kind === 'namespace-rest') { @@ -1012,9 +969,7 @@ const exportOrigins = ( const targets = moduleSpecifierTargets(context, file, binding.specifier); return targets.length === 0 ? [`external:${binding.specifier}:${binding.imported}`] - : targets.flatMap((target) => [ - ...exportOrigins(context, target, binding.imported, new Set(visited)), - ]); + : targets.flatMap((target) => [...exportOrigins(context, target, binding.imported, new Set(visited))]); } if (binding.kind === 'namespace') { return [`namespace:${file}:${exportedName}:${binding.specifier}`]; @@ -1025,9 +980,7 @@ const exportOrigins = ( const targets = moduleSpecifierTargets(context, file, imported.specifier); return targets.length === 0 ? [`external:${imported.specifier}:${imported.imported}`] - : targets.flatMap((target) => [ - ...exportOrigins(context, target, imported.imported, new Set(visited)), - ]); + : targets.flatMap((target) => [...exportOrigins(context, target, imported.imported, new Set(visited))]); } } return [`local:${file}:${binding.kind === 'local' ? binding.local : exportedName}`]; @@ -1096,15 +1049,7 @@ function exportedNamespaceTargets( targets.push(...exportedNamespaceTargets(context, target, binding.imported, visited)); } } else if (binding.kind === 'local') { - targets.push( - ...localNamespaceTargets( - context, - file, - binding.local, - model?.moduleScope.start ?? 0, - visited, - ), - ); + targets.push(...localNamespaceTargets(context, file, binding.local, model?.moduleScope.start ?? 0, visited)); } } for (const target of unambiguousStarTargets(context, file, exportedName)) { @@ -1145,13 +1090,7 @@ function localNamespaceTargets( const pathParts = memberPath(scoped.source); return pathParts === undefined ? [] - : namespacePathTargets( - context, - file, - [...pathParts, scoped.member], - scoped.source.start, - visited, - ); + : namespacePathTargets(context, file, [...pathParts, scoped.member], scoped.source.start, visited); } if (scoped.kind === 'namespace-rest') { const pathParts = memberPath(scoped.source); @@ -1176,9 +1115,7 @@ function localNamespaceTargets( if (binding.kind === 'namespace') { return directTargets; } - return directTargets.flatMap((target) => - exportedNamespaceTargets(context, target, binding.imported, visited), - ); + return directTargets.flatMap((target) => exportedNamespaceTargets(context, target, binding.imported, visited)); } // eslint-disable-next-line func-style -- Member paths recurse through arbitrarily nested exported namespaces. @@ -1195,9 +1132,7 @@ function namespacePathTargets( } let targets = localNamespaceTargets(context, file, root, position, visited); for (const member of rest) { - targets = targets.flatMap((target) => - exportedNamespaceTargets(context, target, member, visited), - ); + targets = targets.flatMap((target) => exportedNamespaceTargets(context, target, member, visited)); } return [...new Set(targets)]; } @@ -1225,22 +1160,12 @@ function exportedExpressionIsForbidden( visited.add(key); const model = sourceModel(context, file); const bindingMatches = (binding: ExportBinding): boolean => { - if ( - binding.kind === 'expression' && - expressionIsForbidden(context, file, binding.span, forbidRecord, visited) - ) { + if (binding.kind === 'expression' && expressionIsForbidden(context, file, binding.span, forbidRecord, visited)) { return true; } if ( binding.kind === 'local' && - localIdentifierIsForbidden( - context, - file, - binding.local, - model?.moduleScope.start ?? 0, - forbidRecord, - visited, - ) + localIdentifierIsForbidden(context, file, binding.local, model?.moduleScope.start ?? 0, forbidRecord, visited) ) { return true; } @@ -1353,8 +1278,8 @@ const memberIsForbidden = ( if (schemaMember === undefined) { return false; } - return namespacePathTargets(context, file, pathParts.slice(0, -1), member.start, new Set()).some( - (target) => exportedExpressionIsForbidden(context, target, schemaMember, forbidRecord, visited), + return namespacePathTargets(context, file, pathParts.slice(0, -1), member.start, new Set()).some((target) => + exportedExpressionIsForbidden(context, target, schemaMember, forbidRecord, visited), ); }; @@ -1374,9 +1299,7 @@ function expressionIsForbidden( const model = sourceModel(context, file); if ( model?.members.some( - (member) => - within(member, expression) && - memberIsForbidden(context, file, member, forbidRecord, visited), + (member) => within(member, expression) && memberIsForbidden(context, file, member, forbidRecord, visited), ) === true ) { return true; @@ -1385,14 +1308,7 @@ function expressionIsForbidden( model?.identifiers.some( (identifier) => within(identifier, expression) && - localIdentifierIsForbidden( - context, - file, - identifier.name, - identifier.start, - forbidRecord, - visited, - ), + localIdentifierIsForbidden(context, file, identifier.name, identifier.start, forbidRecord, visited), ) === true ); } @@ -1418,14 +1334,7 @@ function exportedBindingResolvesSymbol( const bindingMatches = (binding: ExportBinding): boolean => { if ( binding.kind === 'local' && - localBindingResolvesSymbol( - context, - file, - binding.local, - model?.moduleScope.start ?? 0, - symbols, - visited, - ) + localBindingResolvesSymbol(context, file, binding.local, model?.moduleScope.start ?? 0, symbols, visited) ) { return true; } @@ -1441,10 +1350,7 @@ function exportedBindingResolvesSymbol( return true; } } - if ( - binding.kind === 'expression' && - expressionResolvesSymbol(context, file, binding.span, symbols, visited) - ) { + if (binding.kind === 'expression' && expressionResolvesSymbol(context, file, binding.span, symbols, visited)) { return true; } return false; @@ -1460,11 +1366,7 @@ function exportedBindingResolvesSymbol( return false; } -const specifierProvidesSymbol = ( - specifier: string, - importedName: string, - symbols: ReadonlySet, -): boolean => { +const specifierProvidesSymbol = (specifier: string, importedName: string, symbols: ReadonlySet): boolean => { if (!symbols.has(importedName)) { return false; } @@ -1505,14 +1407,7 @@ function localBindingResolvesSymbol( const pathParts = memberPath(candidate.source); return ( pathParts !== undefined && - pathResolvesSymbol( - context, - file, - [...pathParts, candidate.member], - candidate.source.start, - symbols, - visited, - ) + pathResolvesSymbol(context, file, [...pathParts, candidate.member], candidate.source.start, symbols, visited) ); } if (candidate.kind === 'namespace-rest') { @@ -1529,10 +1424,7 @@ function localBindingResolvesSymbol( if (binding === undefined) { return symbols.has(name); } - if ( - binding.kind !== 'namespace' && - specifierProvidesSymbol(binding.specifier, binding.imported, symbols) - ) { + if (binding.kind !== 'namespace' && specifierProvidesSymbol(binding.specifier, binding.imported, symbols)) { return true; } return ( @@ -1553,10 +1445,7 @@ const pathResolvesSymbol = ( ): boolean => { if (pathParts.length === 1) { const name = pathParts[0]; - return ( - name !== undefined && - localBindingResolvesSymbol(context, file, name, position, symbols, visited) - ); + return name !== undefined && localBindingResolvesSymbol(context, file, name, position, symbols, visited); } const symbol = pathParts.at(-1); const root = pathParts[0]; @@ -1582,10 +1471,7 @@ const pathResolvesSymbol = ( const assignedObjectResolvesSymbol = (): boolean => { if (scoped?.kind === 'expression') { const propertyValue = objectPathExpression(scoped.expression, pathParts.slice(1)); - if ( - propertyValue !== undefined && - expressionResolvesSymbol(context, file, propertyValue, symbols, visited) - ) { + if (propertyValue !== undefined && expressionResolvesSymbol(context, file, propertyValue, symbols, visited)) { return true; } } @@ -1607,8 +1493,8 @@ const pathResolvesSymbol = ( if (localPathResolvesSymbol()) { return true; } - return namespacePathTargets(context, file, pathParts.slice(0, -1), position, new Set()).some( - (target) => exportedBindingResolvesSymbol(context, target, symbol, symbols, visited), + return namespacePathTargets(context, file, pathParts.slice(0, -1), position, new Set()).some((target) => + exportedBindingResolvesSymbol(context, target, symbol, symbols, visited), ); }; @@ -1625,10 +1511,7 @@ function expressionResolvesSymbol( .filter((member) => member.start === expression.start && member.end === expression.end) .map((member) => memberPathAt(context, file, member, expression.start)) .find((candidate) => candidate !== undefined); - if ( - pathParts !== undefined && - pathResolvesSymbol(context, file, pathParts, expression.start, symbols, visited) - ) { + if (pathParts !== undefined && pathResolvesSymbol(context, file, pathParts, expression.start, symbols, visited)) { return true; } const identifier = model?.identifiers.find( @@ -1720,13 +1603,7 @@ function exportedExpressionResolvesEndpointFactory( if ( binding.kind === 'reexport' && moduleSpecifierTargets(context, file, binding.specifier).some((target) => - exportedExpressionResolvesEndpointFactory( - context, - target, - binding.imported, - isBuilder, - visited, - ), + exportedExpressionResolvesEndpointFactory(context, target, binding.imported, isBuilder, visited), ) ) { return true; @@ -1766,16 +1643,9 @@ function localIdentifierResolvesEndpointFactory( return isBuilder && functionReturnsEndpointFactory(context, file, candidate.body, visited); } if (candidate.kind === 'expression') { - return isBuilder && - functionExpressionReturnsEndpointFactory(context, file, candidate.expression, visited) + return isBuilder && functionExpressionReturnsEndpointFactory(context, file, candidate.expression, visited) ? true - : expressionResolvesEndpointFactory( - context, - file, - candidate.expression, - isBuilder, - visited, - ); + : expressionResolvesEndpointFactory(context, file, candidate.expression, isBuilder, visited); } if (candidate.kind === 'destructured') { const targets = namespacePathTargets( @@ -1786,13 +1656,7 @@ function localIdentifierResolvesEndpointFactory( new Set(), ); return targets.some((target) => - exportedExpressionResolvesEndpointFactory( - context, - target, - candidate.member, - isBuilder, - visited, - ), + exportedExpressionResolvesEndpointFactory(context, target, candidate.member, isBuilder, visited), ); } return false; @@ -1803,13 +1667,7 @@ function localIdentifierResolvesEndpointFactory( binding !== undefined && binding.kind !== 'namespace' && moduleSpecifierTargets(context, file, binding.specifier).some((target) => - exportedExpressionResolvesEndpointFactory( - context, - target, - binding.imported, - isBuilder, - visited, - ), + exportedExpressionResolvesEndpointFactory(context, target, binding.imported, isBuilder, visited), ) ); } @@ -1837,33 +1695,15 @@ function expressionResolvesEndpointFactory( if ( method !== undefined && (isBuilder ? method === 'make' : endpointMethods.has(method)) && - expressionResolvesSymbol( - context, - file, - member.object, - new Set(['HttpApiEndpoint']), - new Set(), - ) + expressionResolvesSymbol(context, file, member.object, new Set(['HttpApiEndpoint']), new Set()) ) { return true; } const pathParts = memberPathAt(context, file, member, member.start); const exportedName = pathParts?.at(-1); if (pathParts !== undefined && exportedName !== undefined) { - return namespacePathTargets( - context, - file, - pathParts.slice(0, -1), - member.start, - new Set(), - ).some((target) => - exportedExpressionResolvesEndpointFactory( - context, - target, - exportedName, - isBuilder, - visited, - ), + return namespacePathTargets(context, file, pathParts.slice(0, -1), member.start, new Set()).some((target) => + exportedExpressionResolvesEndpointFactory(context, target, exportedName, isBuilder, visited), ); } } @@ -1878,14 +1718,7 @@ function expressionResolvesEndpointFactory( ); if ( identifier !== undefined && - localIdentifierResolvesEndpointFactory( - context, - file, - identifier.name, - identifier.start, - isBuilder, - visited, - ) + localIdentifierResolvesEndpointFactory(context, file, identifier.name, identifier.start, isBuilder, visited) ) { return true; } @@ -1893,17 +1726,11 @@ function expressionResolvesEndpointFactory( (candidate) => candidate.start === expression.start && candidate.end === expression.end, ); return ( - !isBuilder && - call !== undefined && - expressionResolvesEndpointFactory(context, file, call.callee, true, visited) + !isBuilder && call !== undefined && expressionResolvesEndpointFactory(context, file, call.callee, true, visited) ); } -const isEndpointCall = ( - context: ApiContractSourceContext, - file: string, - call: CallExpression, -): boolean => { +const isEndpointCall = (context: ApiContractSourceContext, file: string, call: CallExpression): boolean => { const callee = unwrapExpression(call.callee); if (expressionResolvesEndpointFactory(context, file, callee, false, new Set())) { return true; @@ -1911,16 +1738,9 @@ const isEndpointCall = ( const resolveIdentifierCall = () => { if (callee.type === 'Identifier') { const model = sourceModel(context, file); - const scoped = - model === undefined ? undefined : scopedBindingAt(model, callee.name, callee.start); + const scoped = model === undefined ? undefined : scopedBindingAt(model, callee.name, callee.start); if (scoped?.kind === 'destructured' && endpointMethods.has(scoped.member)) { - return expressionResolvesSymbol( - context, - file, - scoped.source, - new Set(['HttpApiEndpoint']), - new Set(), - ); + return expressionResolvesSymbol(context, file, scoped.source, new Set(['HttpApiEndpoint']), new Set()); } const resolveAliasedCall = (): boolean => { if (scoped?.kind === 'expression') { @@ -1930,13 +1750,7 @@ const isEndpointCall = ( return ( method !== undefined && endpointMethods.has(method) && - expressionResolvesSymbol( - context, - file, - aliased.object, - new Set(['HttpApiEndpoint']), - new Set(), - ) + expressionResolvesSymbol(context, file, aliased.object, new Set(['HttpApiEndpoint']), new Set()) ); } } @@ -1961,11 +1775,7 @@ const isEndpointCall = ( ); }; -const isProblemDetailsCall = ( - context: ApiContractSourceContext, - file: string, - call: CallExpression, -): boolean => +const isProblemDetailsCall = (context: ApiContractSourceContext, file: string, call: CallExpression): boolean => expressionResolvesSymbol(context, file, call.callee, problemDetailsFactoryNames, new Set()); const violationMessage = diff --git a/app/scripts/ultramodern-api-boundary-rules.mts b/app/scripts/ultramodern-api-boundary-rules.mts index 79c8c0880..527818265 100644 --- a/app/scripts/ultramodern-api-boundary-rules.mts +++ b/app/scripts/ultramodern-api-boundary-rules.mts @@ -1,11 +1,7 @@ -import { - matchingDelimiter, - separatedSource, - topLevelSeparators, -} from './boundary-source-structure.mts'; - import path from 'node:path'; +import { matchingDelimiter, separatedSource, topLevelSeparators } from './boundary-source-structure.mts'; + export { unconstrainedHttpApiContractSchemaViolation } from './typescript-api-contract-boundary.mts'; const normalize = (filePath: string): string => filePath.split(path.sep).join('/'); @@ -15,11 +11,9 @@ const identifierPattern = String.raw`[$A-Z_a-z][$\w]*`; const effectEdgeSpecifier = '@modern-js/plugin-bff/effect-edge'; const layerMergeAllCallee = 'Layer.mergeAll'; -const escapesRegularExpression = (value: string): string => - value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); +const escapesRegularExpression = (value: string): string => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); -const sourceTriviaPattern = - /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//gu; +const sourceTriviaPattern = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//gu; const regularExpressionLiteralPattern = /(?(?:^|[!(:,;=[{]|=>|\b(?:case|return|throw))[\t ]*)\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n\r])*\]|[^/\\\n\r])+\/[dgimsuvy]*/gmu; @@ -39,11 +33,7 @@ const withoutComments = (source: string): string => const withoutCommentsOrLiterals = (source: string): string => withoutRegularExpressionLiterals(source).replaceAll(sourceTriviaPattern, mask); -const importsNamedValueMatchingSpecifier = ( - source: string, - name: string, - specifierPattern: string, -): boolean => { +const importsNamedValueMatchingSpecifier = (source: string, name: string, specifierPattern: string): boolean => { const visibleSource = withoutComments(source); const code = withoutCommentsOrLiterals(source); const imports = visibleSource.matchAll( @@ -100,9 +90,7 @@ const importsNamedValueFromSharedApi = (source: string, name: string): boolean = importsNamedValue(source, name, '../shared/api.ts'); const withoutTerminalSatisfies = (expression: string): string => - expression - .replace(/\s+satisfies\s+[$A-Z_a-z][$\w]*(?:\.[$A-Z_a-z][$\w]*)*(?:<[^<>]*>)?$/u, '') - .trim(); + expression.replace(/\s+satisfies\s+[$A-Z_a-z][$\w]*(?:\.[$A-Z_a-z][$\w]*)*(?:<[^<>]*>)?$/u, '').trim(); const assignmentStart = (source: string, declarationEnd: number): number | undefined => { const index = topLevelSeparators(source, '=;', declarationEnd, source.length, true).find( @@ -123,11 +111,7 @@ const curlyAncestorsAt = (source: string, targetIndex: number): readonly number[ return ancestors; }; -const isScopeVisibleAt = ( - source: string, - declarationIndex: number, - usageIndex: number, -): boolean => { +const isScopeVisibleAt = (source: string, declarationIndex: number, usageIndex: number): boolean => { if (declarationIndex >= usageIndex) { return false; } @@ -136,21 +120,13 @@ const isScopeVisibleAt = ( return declarationAncestors.every((ancestor, index) => usageAncestors[index] === ancestor); }; -const isBindingScopeAt = ( - source: string, - declarationIndex: number, - usageIndex: number, -): boolean => { +const isBindingScopeAt = (source: string, declarationIndex: number, usageIndex: number): boolean => { const declarationAncestors = curlyAncestorsAt(source, declarationIndex); const usageAncestors = curlyAncestorsAt(source, usageIndex); return declarationAncestors.every((ancestor, index) => usageAncestors[index] === ancestor); }; -const initializerFor = ( - source: string, - name: string, - usageIndex = source.length, -): string | undefined => { +const initializerFor = (source: string, name: string, usageIndex = source.length): string | undefined => { const declarations = [ ...source.matchAll(new RegExp(String.raw`\bconst\s+${escapesRegularExpression(name)}\b`, 'gu')), ].filter(({ index }) => isScopeVisibleAt(source, index, usageIndex)); @@ -182,9 +158,7 @@ const initializerFor = ( }; const callArguments = (expression: string, callee: string): readonly string[] | undefined => { - const prefix = new RegExp(String.raw`^${escapesRegularExpression(callee)}\s*\(`, 'u').exec( - expression, - ); + const prefix = new RegExp(String.raw`^${escapesRegularExpression(callee)}\s*\(`, 'u').exec(expression); if (prefix === null) { return undefined; } @@ -212,9 +186,7 @@ const safeLayerPipeArguments = (expression: string): readonly string[] | undefin }; const layerConstructorRemainder = (expression: string, callee: string): string | undefined => { - const prefix = new RegExp(String.raw`^${escapesRegularExpression(callee)}\s*\(`, 'u').exec( - expression, - ); + const prefix = new RegExp(String.raw`^${escapesRegularExpression(callee)}\s*\(`, 'u').exec(expression); if (prefix === null) { return undefined; } @@ -235,16 +207,10 @@ const layerConstructorRemainder = (expression: string, callee: string): string | const hasSafeLayerConstructor = (initializer: string, callee: string): boolean => { const remainder = layerConstructorRemainder(initializer, callee); - return ( - remainder !== undefined && - (remainder.length === 0 || safeLayerPipeArguments(remainder) !== undefined) - ); + return remainder !== undefined && (remainder.length === 0 || safeLayerPipeArguments(remainder) !== undefined); }; -const leadingCallArguments = ( - expression: string, - callee: string, -): readonly string[] | undefined => { +const leadingCallArguments = (expression: string, callee: string): readonly string[] | undefined => { const remainder = layerConstructorRemainder(expression, callee); return remainder === undefined ? undefined @@ -263,10 +229,9 @@ const groupCallbackRegistersHandler = (groupArguments: readonly string[]): boole const handlerBuilder = binding?.groups?.parenthesized ?? binding?.groups?.bare; return ( handlerBuilder !== undefined && - new RegExp( - String.raw`\b${escapesRegularExpression(handlerBuilder)}\s*\.\s*handle\s*\(`, - 'u', - ).test(callback.slice(binding?.[0].length ?? 0)) + new RegExp(String.raw`\b${escapesRegularExpression(handlerBuilder)}\s*\.\s*handle\s*\(`, 'u').test( + callback.slice(binding?.[0].length ?? 0), + ) ); }; @@ -276,20 +241,14 @@ interface RuntimeTopologyModule { readonly source: string; } -export type RuntimeTopologyModuleResolver = ( - specifier: string, -) => RuntimeTopologyModule | undefined; +export type RuntimeTopologyModuleResolver = (specifier: string) => RuntimeTopologyModule | undefined; interface NamedBinding { readonly imported: string; readonly specifier: string; } -const namedBindingInDeclaration = ( - bindings: string, - name: string, - specifier: string, -): NamedBinding | undefined => { +const namedBindingInDeclaration = (bindings: string, name: string, specifier: string): NamedBinding | undefined => { for (const binding of bindings.split(',')) { const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); if (local === name && imported !== undefined) { @@ -299,21 +258,14 @@ const namedBindingInDeclaration = ( return undefined; }; -const namedModuleBinding = ( - source: string, - name: string, - allowExport: boolean, -): NamedBinding | undefined => { +const namedModuleBinding = (source: string, name: string, allowExport: boolean): NamedBinding | undefined => { const visible = withoutComments(source); const code = withoutCommentsOrLiterals(source); for (const candidate of visible.matchAll( /^(?[\t ]*)(?import|export)\s*\{(?[^}]*)\}\s*from\s*['"](?[^'"]+)['"]/gmu, )) { const { bindings = '', indent = '', keyword = '', specifier = '' } = candidate.groups ?? {}; - if ( - (keyword === 'export' && !allowExport) || - !code.slice(candidate.index + indent.length).startsWith(keyword) - ) { + if ((keyword === 'export' && !allowExport) || !code.slice(candidate.index + indent.length).startsWith(keyword)) { continue; } const binding = namedBindingInDeclaration(bindings, name, specifier); @@ -326,22 +278,15 @@ const namedModuleBinding = ( const importedBindingFromAnyModule = (source: string, name: string): NamedBinding | undefined => namedModuleBinding(source, name, true); -const importedValueBindingFromAnyModule = ( - source: string, - name: string, -): NamedBinding | undefined => namedModuleBinding(source, name, false); +const importedValueBindingFromAnyModule = (source: string, name: string): NamedBinding | undefined => + namedModuleBinding(source, name, false); const hasUnaliasedValueImport = (source: string, name: string, specifier: string): boolean => { const binding = importedValueBindingFromAnyModule(source, name); return binding?.imported === name && binding.specifier === specifier; }; -const initializerCalls = ( - code: string, - name: string, - callee: string, - index = code.length, -): boolean => { +const initializerCalls = (code: string, name: string, callee: string, index = code.length): boolean => { const initializer = initializerFor(code, name, index); return initializer !== undefined && callArguments(initializer, callee) !== undefined; }; @@ -362,10 +307,7 @@ const layerValueUsesCors = ( if (/\bHttpRouter\.cors\s*\(/u.test(initializer)) { return true; } - const pipedLayer = new RegExp( - String.raw`^(?${identifierPattern})(?\.pipe\s*\()`, - 'u', - ).exec(initializer); + const pipedLayer = new RegExp(String.raw`^(?${identifierPattern})(?\.pipe\s*\()`, 'u').exec(initializer); return pipedLayer?.groups?.base === undefined || pipedLayer.groups.pipe === undefined ? false : layerValueUsesCors(source, pipedLayer.groups.base, usageIndex, new Set([...seen, name])); @@ -377,15 +319,12 @@ const matchingRoundClose = (source: string, openIndex: number): number | undefin const matchingCurlyClose = (source: string, openIndex: number): number | undefined => matchingDelimiter(source, openIndex, '{', '}'); -const parameterBinding = (parameter: string): string => - parameter.slice(0, topLevelSeparators(parameter, ':=')[0]); +const parameterBinding = (parameter: string): string => parameter.slice(0, topLevelSeparators(parameter, ':=')[0]); const parameterListShadows = (parameters: string, name: string): boolean => { const pattern = new RegExp(String.raw`\b${escapesRegularExpression(name)}\b`, 'u'); const separators = topLevelSeparators(parameters, ',', 0, parameters.length, true); - return separatedSource(parameters, separators).some((parameter) => - pattern.test(parameterBinding(parameter)), - ); + return separatedSource(parameters, separators).some((parameter) => pattern.test(parameterBinding(parameter))); }; const controlFlowParentheses = new Set(['for', 'if', 'switch', 'while', 'with']); @@ -398,9 +337,7 @@ const hasParameterDeclarationPrefix = (prefix: string): boolean => { if (withoutGeneric.endsWith(']')) { return true; } - const precedingWord = new RegExp(String.raw`(?${identifierPattern})$`, 'u').exec( - withoutGeneric, - )?.groups?.word; + const precedingWord = new RegExp(String.raw`(?${identifierPattern})$`, 'u').exec(withoutGeneric)?.groups?.word; if (precedingWord === undefined || controlFlowParentheses.has(precedingWord)) { return false; } @@ -437,11 +374,7 @@ const parameterScopeContains = (code: string, closeIndex: number, usageIndex: nu }; const parameterBindingsShadow = (code: string, name: string, usageIndex: number): boolean => { - for ( - let openIndex = code.indexOf('('); - openIndex >= 0; - openIndex = code.indexOf('(', openIndex + 1) - ) { + for (let openIndex = code.indexOf('('); openIndex >= 0; openIndex = code.indexOf('(', openIndex + 1)) { const closeIndex = matchingRoundClose(code, openIndex); if ( closeIndex !== undefined && @@ -481,10 +414,7 @@ const shadowsBinding = (code: string, name: string, usageIndex: number): boolean return ( patternHasVisibleMatch( code, - new RegExp( - String.raw`\b(?:class|const|function|let|module|namespace|using|var)\s+${escapedName}\b`, - 'gu', - ), + new RegExp(String.raw`\b(?:class|const|function|let|module|namespace|using|var)\s+${escapedName}\b`, 'gu'), usageIndex, ) || singleArrowBindingShadows(code, name, usageIndex) || @@ -521,11 +451,7 @@ const canonicalApiExport = ( return undefined; } const code = withoutCommentsOrLiterals(source); - if ( - !new RegExp(String.raw`\bexport\s+const\s+${escapesRegularExpression(name)}\s*=`, 'u').test( - code, - ) - ) { + if (!new RegExp(String.raw`\bexport\s+const\s+${escapesRegularExpression(name)}\s*=`, 'u').test(code)) { return undefined; } const initializer = initializerFor(withoutComments(source), name, source.length); @@ -554,17 +480,12 @@ const sameApiExport = ( const composedLayerOperand = (rawArgument: string): string | undefined => { const argument = withoutTerminalSatisfies(rawArgument); - const layer = new RegExp( - String.raw`^(?${identifierPattern})(?[\s\S]*)$`, - 'u', - ).exec(argument); + const layer = new RegExp(String.raw`^(?${identifierPattern})(?[\s\S]*)$`, 'u').exec(argument); const { name, remainder } = layer?.groups ?? {}; if (name === undefined || remainder === undefined) { return undefined; } - return remainder.trim().length === 0 || safeLayerPipeArguments(remainder.trim()) !== undefined - ? name - : undefined; + return remainder.trim().length === 0 || safeLayerPipeArguments(remainder.trim()) !== undefined ? name : undefined; }; const groupUsesExpectedApi = ( @@ -818,11 +739,7 @@ const functionBodyStartsBefore = (code: string, endIndex: number): ReadonlySet { const functionBodies = functionBodyStartsBefore(code, endIndex); - return [...code.slice(bodyStart + 1, endIndex).matchAll(/\b(?:return|throw)\b/gu)].some( - ({ index }) => { - const absoluteIndex = bodyStart + 1 + index; - const owningFunction = innermostFunctionBody( - curlyAncestorsAt(code, absoluteIndex), - bodyStart, - functionBodies, - ); - return owningFunction === bodyStart; - }, - ); + return [...code.slice(bodyStart + 1, endIndex).matchAll(/\b(?:return|throw)\b/gu)].some(({ index }) => { + const absoluteIndex = bodyStart + 1 + index; + const owningFunction = innermostFunctionBody(curlyAncestorsAt(code, absoluteIndex), bodyStart, functionBodies); + return owningFunction === bodyStart; + }); }; -const isDirectFactoryReturn = ( - code: string, - bodyStart: number, - callIndex: number, - callEnd: number, -): boolean => { +const isDirectFactoryReturn = (code: string, bodyStart: number, callIndex: number, callEnd: number): boolean => { let depth = 1; let statementStart = bodyStart + 1; for (let index = bodyStart + 1; index < callIndex; index += 1) { @@ -887,10 +793,7 @@ const exportedFactoryOwnsCall = (code: string, callIndex: number, callEnd: numbe const candidates = code .slice(0, callIndex) .matchAll( - new RegExp( - String.raw`\bexport\s+const\s+(?${identifierPattern})\s*=[\s\S]{0,2000}?=>\s*\{`, - 'gu', - ), + new RegExp(String.raw`\bexport\s+const\s+(?${identifierPattern})\s*=[\s\S]{0,2000}?=>\s*\{`, 'gu'), ); let candidate: RegExpExecArray | undefined; for (const current of candidates) { @@ -917,13 +820,40 @@ const exportedFactoryOwnsCall = (code: string, callIndex: number, callEnd: numbe return ( defaultRuntime !== undefined && initializerCalls(code, defaultRuntime, factory) && - new RegExp( - String.raw`\bexport\s+default\s+${escapesRegularExpression(defaultRuntime)}\s*;`, - 'u', - ).test(code) + new RegExp(String.raw`\bexport\s+default\s+${escapesRegularExpression(defaultRuntime)}\s*;`, 'u').test(code) ); }; +const expressionFactoryOwnsCall = (code: string, callIndex: number, callEnd: number): boolean => { + const expressionFactory = new RegExp( + String.raw`\bexport\s+const\s+(?${identifierPattern})\s*=\s*\(\s*\)\s*=>\s*$`, + 'u', + ).exec(code.slice(0, callIndex)); + const factory = expressionFactory?.groups?.factory; + if ( + expressionFactory !== null && + factory !== undefined && + curlyDepthAt(code, expressionFactory.index) === 0 && + /^\s*;/u.test(code.slice(callEnd)) + ) { + const factoryResult = new RegExp( + String.raw`\bconst\s+(?${identifierPattern})\s*=\s*${escapesRegularExpression(factory)}\s*\(\s*\)\s*;`, + 'gu', + ); + return [...code.slice(callEnd).matchAll(factoryResult)].some((match) => { + const runtime = match.groups?.runtime; + return ( + runtime !== undefined && + curlyDepthAt(code, callEnd + match.index) === 0 && + new RegExp(String.raw`\bexport\s+default\s+${escapesRegularExpression(runtime)}\s*;`, 'u').test( + code.slice(callEnd), + ) + ); + }); + } + return false; +}; + const isRuntimeRootCall = (code: string, callIndex: number, callEnd: number): boolean => { const prefix = code.slice(0, callIndex); if (/\bexport\s+default\s*$/u.test(prefix)) { @@ -932,20 +862,19 @@ const isRuntimeRootCall = (code: string, callIndex: number, callEnd: number): bo if (/\breturn\s*$/u.test(prefix)) { return exportedFactoryOwnsCall(code, callIndex, callEnd); } - const assignment = new RegExp( - String.raw`\bconst\s+(?${identifierPattern})\s*=\s*$`, - 'u', - ).exec(prefix); + if (expressionFactoryOwnsCall(code, callIndex, callEnd)) { + return true; + } + const assignment = new RegExp(String.raw`\bconst\s+(?${identifierPattern})\s*=\s*$`, 'u').exec(prefix); const runtimeName = assignment?.groups?.name; return ( runtimeName !== undefined && assignment !== null && /^\s*;/u.test(code.slice(callEnd)) && curlyDepthAt(code, assignment.index) === 0 && - new RegExp( - String.raw`\bexport\s+default\s+${escapesRegularExpression(runtimeName)}\s*;`, - 'u', - ).test(code.slice(callIndex)) + new RegExp(String.raw`\bexport\s+default\s+${escapesRegularExpression(runtimeName)}\s*;`, 'u').test( + code.slice(callIndex), + ) ); }; @@ -962,12 +891,7 @@ const usesUnshadowedHelperImports = ( !shadowsBinding(code, 'Layer', callIndex) && !shadowsBinding(code, api, callIndex); -const usesImportedCorsTransport = ( - source: string, - code: string, - transport: string, - callIndex: number, -): boolean => +const usesImportedCorsTransport = (source: string, code: string, transport: string, callIndex: number): boolean => !layerValueUsesCors(code, transport, callIndex) || (importedValueBindingFromAnyModule(source, 'HttpRouter')?.imported === 'HttpRouter' && importedValueBindingFromAnyModule(source, 'HttpRouter')?.specifier === effectEdgeSpecifier && @@ -993,20 +917,13 @@ const hasRpcGroupContract = ( ); }; -const hasRpcRuntimeLayers = ( - source: string, - code: string, - call: RegExpExecArray, - helper: string, -): boolean => { +const hasRpcRuntimeLayers = (source: string, code: string, call: RegExpExecArray, helper: string): boolean => { const { api, group, layer = 'layer', rpcLayer } = call.groups ?? {}; if (api === undefined || group === undefined || rpcLayer === undefined) { return false; } return ( - ['HttpApi', 'Layer'].every((name) => - hasUnaliasedValueImport(source, name, effectEdgeSpecifier), - ) && + ['HttpApi', 'Layer'].every((name) => hasUnaliasedValueImport(source, name, effectEdgeSpecifier)) && [helper, 'HttpApi', 'Layer', group].every((name) => !shadowsBinding(code, name, call.index)) && initializerCalls(code, api, 'HttpApi.make', call.index) && initializerFor(code, layer, call.index) === 'Layer.empty' && @@ -1025,10 +942,7 @@ export const usesStrictRpcRuntimeTopology = ( 'defineEffectBff', escapesRegularExpression(effectEdgeSpecifier), ); - if ( - helper === undefined || - !/\bfrom\s+['"]\.\.\/shared\/rpc\.ts['"]/u.test(withoutComments(source)) - ) { + if (helper === undefined || !/\bfrom\s+['"]\.\.\/shared\/rpc\.ts['"]/u.test(withoutComments(source))) { return false; } const call = new RegExp( @@ -1065,19 +979,7 @@ const assemblyTransportViolation = ( if (transport === undefined) { return undefined; } - if ( - !declaresLayerValue( - source, - code, - transport, - true, - false, - apiExport, - expectedApiModuleId, - resolveImport, - index, - ) - ) { + if (!declaresLayerValue(source, code, transport, true, false, apiExport, expectedApiModuleId, resolveImport, index)) { return 'must pass an explicitly composed Layer as assembleEffectBffRuntime transport'; } return usesImportedCorsTransport(source, code, transport, index) @@ -1110,17 +1012,7 @@ const assembledRuntimeViolation = ( return 'must use unshadowed server helper, API, and Layer imports for assembly'; } if ( - !declaresLayerValue( - source, - code, - handlers, - false, - true, - apiExport, - expectedApiModuleId, - resolveImport, - call.index, - ) + !declaresLayerValue(source, code, handlers, false, true, apiExport, expectedApiModuleId, resolveImport, call.index) ) { return 'must pass an explicitly composed Layer as assembleEffectBffRuntime handlers'; } @@ -1166,21 +1058,10 @@ export const strictEffectRuntimeTopologyViolation = ( if (call === null || api === undefined || handlers === undefined) { return 'must pass a concrete api and composed handlers directly to assembleEffectBffRuntime'; } - return assembledRuntimeViolation( - source, - code, - helper, - call, - { api, handlers, transport }, - resolveImport, - ); + return assembledRuntimeViolation(source, code, helper, call, { api, handlers, transport }, resolveImport); }; -export const privateOwnerImportViolation = ( - root: string, - file: string, - specifier: string, -): string | undefined => { +export const privateOwnerImportViolation = (root: string, file: string, specifier: string): string | undefined => { if (!privateOwnerSpecifierPattern.test(specifier)) { return undefined; } diff --git a/app/scripts/ultramodern-command-failure.mts b/app/scripts/ultramodern-command-failure.mts index 6ad64e22c..1e5b951c5 100644 --- a/app/scripts/ultramodern-command-failure.mts +++ b/app/scripts/ultramodern-command-failure.mts @@ -1,9 +1,8 @@ import { Schema } from 'effect'; -class UltramodernCommandError extends Schema.TaggedError()( - 'UltramodernCommandError', - { reason: Schema.String }, -) {} +class UltramodernCommandError extends Schema.TaggedError()('UltramodernCommandError', { + reason: Schema.String, +}) {} export const ultramodernCommandFailure = (reason: string): UltramodernCommandError => new UltramodernCommandError({ reason }); diff --git a/app/scripts/ultramodern-performance-readiness.mts b/app/scripts/ultramodern-performance-readiness.mts index 69686bcc5..dbc17012e 100644 --- a/app/scripts/ultramodern-performance-readiness.mts +++ b/app/scripts/ultramodern-performance-readiness.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/scripts/ultramodern-typecheck.mts b/app/scripts/ultramodern-typecheck.mts index 1bcf00057..452d6635f 100644 --- a/app/scripts/ultramodern-typecheck.mts +++ b/app/scripts/ultramodern-typecheck.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/scripts/validate-ultramodern-workspace.mts b/app/scripts/validate-ultramodern-workspace.mts index 52cb3fd40..65bf2678a 100644 --- a/app/scripts/validate-ultramodern-workspace.mts +++ b/app/scripts/validate-ultramodern-workspace.mts @@ -1,8 +1,3 @@ -import type { nodeFileTrace as traceNodeFiles } from '@vercel/nft'; -import { - hasUltramodernDispatch, - hasUltramodernSkillsDispatch, -} from './shared/ultramodern-wrapper-source.mts'; import { ok as assertCondition } from 'node:assert'; import type { execFileSync as nodeExecFileSync } from 'node:child_process'; import crypto from 'node:crypto'; @@ -18,53 +13,44 @@ import type { import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; + import { NodeRuntime, NodeServices } from '@effect/platform-node'; -import { Config, Effect, Inspectable, Layer, Predicate, Result, Schema } from 'effect'; +import type { nodeFileTrace as traceNodeFiles } from '@vercel/nft'; +import { Array as EffectArray, Config, Effect, Inspectable, Layer, Order, Predicate, Result, Schema } from 'effect'; import type { Json } from 'effect/Schema'; + import compactConfigDocument from '../.modernjs/ultramodern.json' with { type: 'json' }; import shellPackageDocument from '../apps/shell-super-app/package.json' with { type: 'json' }; import rootPackageDocument from '../package.json' with { type: 'json' }; import developmentOverlayDocument from '../topology/local-overlays/development.json' with { type: 'json' }; import ownershipDocument from '../topology/ownership.json' with { type: 'json' }; import referenceTopologyDocument from '../topology/reference-topology.json' with { type: 'json' }; -import { tailwindPrefixForNamespace } from './scaffolding/tailwind-prefix.mts'; +import { checkOntosModuleContracts } from './check-ontos-module-contracts.mts'; +import { moduleFederationBridgeViolation } from './module-federation-bridge-boundary.mts'; import { assertPublishedCrossMicroVerticalContractUsage, assertPublishedOutboxContractSource, publishedOutboxContractExports, resolvePublishedContractModuleId, } from './published-outbox-contracts.mts'; -import { checkOntosModuleContracts } from './check-ontos-module-contracts.mts'; +import { tailwindPrefixForNamespace } from './scaffolding/tailwind-prefix.mts'; +import { hasUltramodernDispatch, hasUltramodernSkillsDispatch } from './shared/ultramodern-wrapper-source.mts'; const nodeRequire = createRequire(import.meta.url); const nodeFileSystemModule = ['node', 'fs'].join(':'); const nodeChildProcessModule = ['node', 'child_process'].join(':'); const NodeFileSystemModuleSchema = Schema.Struct({ - existsSync: Schema.declare((input): input is typeof nodeExistsSync => - Predicate.isFunction(input), - ), - mkdtempSync: Schema.declare((input): input is typeof nodeMkdtempSync => - Predicate.isFunction(input), - ), - readdirSync: Schema.declare((input): input is typeof nodeReaddirSync => - Predicate.isFunction(input), - ), - readFileSync: Schema.declare((input): input is typeof nodeReadFileSync => - Predicate.isFunction(input), - ), + existsSync: Schema.declare((input): input is typeof nodeExistsSync => Predicate.isFunction(input)), + mkdtempSync: Schema.declare((input): input is typeof nodeMkdtempSync => Predicate.isFunction(input)), + readdirSync: Schema.declare((input): input is typeof nodeReaddirSync => Predicate.isFunction(input)), + readFileSync: Schema.declare((input): input is typeof nodeReadFileSync => Predicate.isFunction(input)), rmSync: Schema.declare((input): input is typeof nodeRmSync => Predicate.isFunction(input)), - writeFileSync: Schema.declare((input): input is typeof nodeWriteFileSync => - Predicate.isFunction(input), - ), + writeFileSync: Schema.declare((input): input is typeof nodeWriteFileSync => Predicate.isFunction(input)), }); const NodeChildProcessModuleSchema = Schema.Struct({ - execFileSync: Schema.declare((input): input is typeof nodeExecFileSync => - Predicate.isFunction(input), - ), + execFileSync: Schema.declare((input): input is typeof nodeExecFileSync => Predicate.isFunction(input)), }); -const fs = Result.getOrThrow( - Schema.decodeUnknownResult(NodeFileSystemModuleSchema)(nodeRequire(nodeFileSystemModule)), -); +const fs = Result.getOrThrow(Schema.decodeUnknownResult(NodeFileSystemModuleSchema)(nodeRequire(nodeFileSystemModule))); const { execFileSync } = Result.getOrThrow( Schema.decodeUnknownResult(NodeChildProcessModuleSchema)(nodeRequire(nodeChildProcessModule)), ); @@ -92,7 +78,7 @@ const SHARED_VALIDATOR_STRING_020 = '@app/shared-design-tokens'; const SHARED_VALIDATOR_STRING_021 = '@app/shell-super-app'; const SHARED_VALIDATOR_STRING_022 = '@modern-js/app-tools'; const SHARED_VALIDATOR_STRING_023 = '@modern-js/code-tools'; -const SHARED_VALIDATOR_STRING_024 = '@modern-js/create'; +const SHARED_VALIDATOR_STRING_024 = '@modern-js/ultramodern-create'; const SHARED_VALIDATOR_STRING_025 = '@modern-js/plugin-bff'; const SHARED_VALIDATOR_STRING_026 = '@modern-js/plugin-bff/effect'; const SHARED_VALIDATOR_STRING_027 = '@modern-js/plugin-i18n'; @@ -105,9 +91,9 @@ const SHARED_VALIDATOR_STRING_033 = '/party-registry-api/openapi.json'; const SHARED_VALIDATOR_STRING_034 = '/party-registry-api/party-registry/readiness'; const SHARED_VALIDATOR_STRING_035 = '#super-app-platform'; const SHARED_VALIDATOR_STRING_036 = '2026-06-02'; -const SHARED_VALIDATOR_STRING_037 = '3.8.2-ultramodern.12'; +const SHARED_VALIDATOR_STRING_037 = '3.9.0-ultramodern.4'; const SHARED_VALIDATOR_STRING_038 = '3f023644c8a07e9a'; -const SHARED_VALIDATOR_STRING_039 = '4.0.0-beta.107'; +const SHARED_VALIDATOR_STRING_039 = '4.0.0-rc.112'; const SHARED_VALIDATOR_STRING_040 = 'additionalShellBuildMarkerIds'; const SHARED_VALIDATOR_STRING_041 = 'additionalShellDegradedStateIds'; const SHARED_VALIDATOR_STRING_042 = 'additionalShellDeliveryUnitIds'; @@ -135,8 +121,7 @@ const SHARED_VALIDATOR_STRING_063 = 'commonjs-module'; const SHARED_VALIDATOR_STRING_064 = 'core-runtime'; const SHARED_VALIDATOR_STRING_065 = 'deliveryUnit'; const SHARED_VALIDATOR_STRING_066 = 'docs/super-app-rfc-adr/wave2/blast-radius.md#shared-packages'; -const SHARED_VALIDATOR_STRING_067 = - 'docs/super-app-rfc-adr/wave2/reference-topology.md#shared-packages'; +const SHARED_VALIDATOR_STRING_067 = 'docs/super-app-rfc-adr/wave2/reference-topology.md#shared-packages'; const SHARED_VALIDATOR_STRING_068 = 'effect-tsgo'; const SHARED_VALIDATOR_STRING_069 = 'framework-invariant'; const SHARED_VALIDATOR_STRING_070 = 'global_fetch_strictly_public'; @@ -156,8 +141,7 @@ const SHARED_VALIDATOR_STRING_083 = 'node ./scripts/migrate-strict-effect.mts'; const SHARED_VALIDATOR_STRING_084 = 'node ./scripts/proof-cloudflare-version.mts --out .codex/reports/cloudflare-version-proof/public-url-proof.json'; const SHARED_VALIDATOR_STRING_085 = 'node ./scripts/ultramodern-performance-readiness.mts'; -const SHARED_VALIDATOR_STRING_086 = - 'node ./scripts/ultramodern-typecheck.mts --build tsconfig.json'; +const SHARED_VALIDATOR_STRING_086 = 'node ./scripts/ultramodern-typecheck.mts --build tsconfig.json'; const SHARED_VALIDATOR_STRING_087 = 'node ./scripts/validate-ultramodern-workspace.mts'; const SHARED_VALIDATOR_STRING_088 = 'node-mf-runtime'; const SHARED_VALIDATOR_STRING_089 = 'nodejs_compat'; @@ -218,7 +202,7 @@ const SHARED_VALIDATOR_STRING_141 = 'super-app-platform'; const SHARED_VALIDATOR_STRING_142 = 'traceparent'; const SHARED_VALIDATOR_STRING_143 = 'ULTRAMODERN_ASSET_PREFIX'; const SHARED_VALIDATOR_STRING_144 = - 'ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS=true pnpm run cloudflare:build && wrangler deploy --config .output/wrangler.json'; + 'cross-env ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS=true pnpm run cloudflare:build && wrangler deploy --config .output/wrangler.json'; const SHARED_VALIDATOR_STRING_145 = 'ULTRAMODERN_CLOUDFLARE_WORKERS_DEV_SUBDOMAIN'; const SHARED_VALIDATOR_STRING_146 = 'ULTRAMODERN_PERFORMANCE_READINESS_DIAGNOSTICS=false'; const SHARED_VALIDATOR_STRING_147 = 'ULTRAMODERN_PUBLIC_URL_PARTY_REGISTRY'; @@ -253,7 +237,7 @@ const SHARED_VALIDATOR_STRING_175 = '@app/gateway-principal-verifier'; const SHARED_VALIDATOR_STRING_176 = 'gateway-principal-verifier'; const SHARED_VALIDATOR_STRING_177 = 'packages/gateway-principal-verifier'; -// Generated by modern-js-create with an immutable expected proof contract. +// Generated by ultramodern-create with an immutable expected proof contract. const root = process.cwd(); const isString = Schema.is(Schema.String); const isNumber = Schema.is(Schema.Number); @@ -380,14 +364,7 @@ const createCloudflareSecurityContract = () => ({ 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], 'manifest-src': ["'self'", 'https:', 'http:'], 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], + 'script-src': ["'self'", SHARED_VALIDATOR_STRING_016, SHARED_VALIDATOR_STRING_015, 'https:', 'http:', 'blob:'], 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], 'worker-src': ["'self'", 'blob:'], }, @@ -473,7 +450,7 @@ const createVerticalBackendFederationContract = () => ({ compatibility: { contractVersion: SHARED_VALIDATOR_STRING_079, effectVersion: SHARED_VALIDATOR_STRING_039, - moduleFederationVersion: '2.8.0', + moduleFederationVersion: '2.9.0', packageName: SHARED_VALIDATOR_STRING_018, }, deliveryUnit: { @@ -605,17 +582,20 @@ const workspaceValidationContractDefinition = { aliases: { '@modern-js/adapter-rstest': '@bleedingdev/modern-js-adapter-rstest', '@modern-js/app-tools': '@bleedingdev/modern-js-app-tools', + '@modern-js/app-tools-extensions': '@bleedingdev/modern-js-app-tools-extensions', '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core', + '@modern-js/bff-effect': '@bleedingdev/modern-js-bff-effect', '@modern-js/bff-runtime': '@bleedingdev/modern-js-bff-runtime', '@modern-js/builder': '@bleedingdev/modern-js-builder', '@modern-js/code-tools': '@bleedingdev/modern-js-code-tools', - '@modern-js/create': '@bleedingdev/modern-js-create', '@modern-js/create-request': '@bleedingdev/modern-js-create-request', + '@modern-js/i18n-runtime-extensions': '@bleedingdev/modern-js-i18n-runtime-extensions', '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils', '@modern-js/image': '@bleedingdev/modern-js-image', '@modern-js/main-doc': '@bleedingdev/modern-js-main-doc', '@modern-js/plugin': '@bleedingdev/modern-js-plugin', '@modern-js/plugin-bff': '@bleedingdev/modern-js-plugin-bff', + '@modern-js/plugin-bff-extensions': '@bleedingdev/modern-js-plugin-bff-extensions', '@modern-js/plugin-data-loader': '@bleedingdev/modern-js-plugin-data-loader', '@modern-js/plugin-i18n': '@bleedingdev/modern-js-plugin-i18n', '@modern-js/plugin-polyfill': '@bleedingdev/modern-js-plugin-polyfill', @@ -625,6 +605,7 @@ const workspaceValidationContractDefinition = { '@modern-js/prod-server': '@bleedingdev/modern-js-prod-server', '@modern-js/render': '@bleedingdev/modern-js-render', '@modern-js/runtime': '@bleedingdev/modern-js-runtime', + '@modern-js/runtime-extensions': '@bleedingdev/modern-js-runtime-extensions', '@modern-js/runtime-utils': '@bleedingdev/modern-js-runtime-utils', '@modern-js/sandpack-react': '@bleedingdev/modern-js-sandpack-react', '@modern-js/server': '@bleedingdev/modern-js-server', @@ -634,6 +615,8 @@ const workspaceValidationContractDefinition = { '@modern-js/server-utils': '@bleedingdev/modern-js-server-utils', '@modern-js/tsconfig': '@bleedingdev/modern-js-tsconfig', '@modern-js/types': '@bleedingdev/modern-js-types', + '@modern-js/ultramodern-create': '@bleedingdev/modern-js-ultramodern-create', + '@modern-js/ultramodern-sandpack-profile': '@bleedingdev/modern-js-ultramodern-sandpack-profile', '@modern-js/utils': '@bleedingdev/modern-js-utils', }, packages: [ @@ -643,15 +626,25 @@ const workspaceValidationContractDefinition = { version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_022, + sourceName: '@modern-js/app-tools', targetName: '@bleedingdev/modern-js-app-tools', version: SHARED_VALIDATOR_STRING_037, }, + { + sourceName: '@modern-js/app-tools-extensions', + targetName: '@bleedingdev/modern-js-app-tools-extensions', + version: SHARED_VALIDATOR_STRING_037, + }, { sourceName: '@modern-js/bff-core', targetName: '@bleedingdev/modern-js-bff-core', version: SHARED_VALIDATOR_STRING_037, }, + { + sourceName: '@modern-js/bff-effect', + targetName: '@bleedingdev/modern-js-bff-effect', + version: SHARED_VALIDATOR_STRING_037, + }, { sourceName: '@modern-js/bff-runtime', targetName: '@bleedingdev/modern-js-bff-runtime', @@ -663,18 +656,18 @@ const workspaceValidationContractDefinition = { version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_023, + sourceName: '@modern-js/code-tools', targetName: '@bleedingdev/modern-js-code-tools', version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_024, - targetName: '@bleedingdev/modern-js-create', + sourceName: '@modern-js/create-request', + targetName: '@bleedingdev/modern-js-create-request', version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: '@modern-js/create-request', - targetName: '@bleedingdev/modern-js-create-request', + sourceName: '@modern-js/i18n-runtime-extensions', + targetName: '@bleedingdev/modern-js-i18n-runtime-extensions', version: SHARED_VALIDATOR_STRING_037, }, { @@ -698,17 +691,22 @@ const workspaceValidationContractDefinition = { version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_025, + sourceName: '@modern-js/plugin-bff', targetName: '@bleedingdev/modern-js-plugin-bff', version: SHARED_VALIDATOR_STRING_037, }, + { + sourceName: '@modern-js/plugin-bff-extensions', + targetName: '@bleedingdev/modern-js-plugin-bff-extensions', + version: SHARED_VALIDATOR_STRING_037, + }, { sourceName: '@modern-js/plugin-data-loader', targetName: '@bleedingdev/modern-js-plugin-data-loader', version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_027, + sourceName: '@modern-js/plugin-i18n', targetName: '@bleedingdev/modern-js-plugin-i18n', version: SHARED_VALIDATOR_STRING_037, }, @@ -728,7 +726,7 @@ const workspaceValidationContractDefinition = { version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_028, + sourceName: '@modern-js/plugin-tanstack', targetName: '@bleedingdev/modern-js-plugin-tanstack', version: SHARED_VALIDATOR_STRING_037, }, @@ -743,10 +741,15 @@ const workspaceValidationContractDefinition = { version: SHARED_VALIDATOR_STRING_037, }, { - sourceName: SHARED_VALIDATOR_STRING_029, + sourceName: '@modern-js/runtime', targetName: '@bleedingdev/modern-js-runtime', version: SHARED_VALIDATOR_STRING_037, }, + { + sourceName: '@modern-js/runtime-extensions', + targetName: '@bleedingdev/modern-js-runtime-extensions', + version: SHARED_VALIDATOR_STRING_037, + }, { sourceName: '@modern-js/runtime-utils', targetName: '@bleedingdev/modern-js-runtime-utils', @@ -792,6 +795,16 @@ const workspaceValidationContractDefinition = { targetName: '@bleedingdev/modern-js-types', version: SHARED_VALIDATOR_STRING_037, }, + { + sourceName: '@modern-js/ultramodern-create', + targetName: '@bleedingdev/modern-js-ultramodern-create', + version: SHARED_VALIDATOR_STRING_037, + }, + { + sourceName: '@modern-js/ultramodern-sandpack-profile', + targetName: '@bleedingdev/modern-js-ultramodern-sandpack-profile', + version: SHARED_VALIDATOR_STRING_037, + }, { sourceName: '@modern-js/utils', targetName: '@bleedingdev/modern-js-utils', @@ -805,7 +818,7 @@ const workspaceValidationContractDefinition = { schema: 'bleedingdev.ultramodern.release-cohort', schemaVersion: 1, source: { - commit: '69f2b5648e13a057261f22bb36cb2d8ca2d5962f', + commit: 'ef99279246046685f1684c59ca145f2a6a3f9d53', repository: 'BleedingDev/ultramodern.js', }, }, @@ -838,10 +851,8 @@ const workspaceValidationContractDefinition = { id: 'hydration-flag', }, { - diagnostic: - 'Federated hosts must not render a local component copy while loading a remote implementation.', - expression: - '(?:loading\\s*:\\s*|fallback\\s*=\\s*\\{\\s*)<\\s*(?:ServerComponent|LocalComponent)\\b', + diagnostic: 'Federated hosts must not render a local component copy while loading a remote implementation.', + expression: '(?:loading\\s*:\\s*|fallback\\s*=\\s*\\{\\s*)<\\s*(?:ServerComponent|LocalComponent)\\b', flags: 'u', id: 'local-loading-copy', }, @@ -1026,8 +1037,7 @@ const workspaceValidationContractDefinition = { ], patterns: [ { - diagnostic: - 'Generated Zephyr integration must not be gated or disabled through ULTRAMODERN_ZEPHYR.', + diagnostic: 'Generated Zephyr integration must not be gated or disabled through ULTRAMODERN_ZEPHYR.', expression: '\\bULTRAMODERN_ZEPHYR\\b', fixArea: 'use the framework-owned Zephyr integration without a gate', flags: 'u', @@ -1048,13 +1058,6 @@ const workspaceValidationContractDefinition = { }, ], patterns: [ - { - diagnostic: 'Generated Module Federation must keep bridge routing enabled.', - expression: '\\benableBridgeRouter\\s*:\\s*false\\b', - fixArea: 'remove enableBridgeRouter: false', - flags: 'u', - id: 'bridge-router-disabled', - }, { diagnostic: 'Generated Module Federation must keep dynamic remote type hints enabled.', expression: '\\bdisableDynamicRemoteTypeHints\\s*:\\s*true\\b', @@ -1063,8 +1066,7 @@ const workspaceValidationContractDefinition = { id: 'dynamic-remote-type-hints-disabled', }, { - diagnostic: - 'Generated Module Federation must not exclude shared plugins from tree shaking.', + diagnostic: 'Generated Module Federation must not exclude shared plugins from tree shaking.', expression: '\\btreeShakingSharedExcludePlugins\\b', fixArea: 'remove treeShakingSharedExcludePlugins', flags: 'u', @@ -1083,8 +1085,7 @@ const workspaceValidationContractDefinition = { ], patterns: [ { - diagnostic: - 'Generated shell routing must use native router navigation instead of window.location.', + diagnostic: 'Generated shell routing must use native router navigation instead of window.location.', expression: '\\bwindow\\s*\\.\\s*location(?:\\s*\\.\\s*(?:assign|replace|reload)\\s*\\(|\\s*\\.\\s*href\\s*=|\\s*=)', fixArea: 'replace manual window.location navigation with the router primitive', @@ -1114,8 +1115,7 @@ const workspaceValidationContractDefinition = { ], patterns: [ { - diagnostic: - 'Generated shell routing must use native Module Federation loading primitives.', + diagnostic: 'Generated shell routing must use native Module Federation loading primitives.', expression: '\\b(?:hydrateRoot|loadRemote|loadShare)\\s*\\(', fixArea: 'remove the manual Module Federation hydration or loading wrapper', flags: 'u', @@ -1166,11 +1166,7 @@ const workspaceValidationContractDefinition = { }, kind: 'modernjs.ultramodern-workspace-validation-contract', legacy: { - forbiddenCompactConfigFields: [ - 'generatedContract', - 'packageCohort', - 'workspaceValidationContract', - ], + forbiddenCompactConfigFields: ['generatedContract', 'packageCohort', 'workspaceValidationContract'], forbiddenPackageSourceFields: ['generatedWorkspacePackages', 'metadata', 'modernPackages'], forbiddenTopologyFields: ['effectServices', 'remotes'], retiredMetadataPaths: [ @@ -1203,7 +1199,7 @@ const workspaceValidationContractDefinition = { }, node: { engineRange: '>=26', - version: '26.5.0', + version: '26.7.0', }, oldRemotePaths: ['apps/remotes'], packageScope: 'app', @@ -1212,8 +1208,7 @@ const workspaceValidationContractDefinition = { 'agents:refs:check': 'node ./scripts/setup-agent-reference-repos.mts --check', 'agents:refs:install': 'node ./scripts/setup-agent-reference-repos.mts', 'api:check': 'node ./scripts/check-ultramodern-api-boundaries.mts', - build: - 'pnpm --filter "./apps/shell-super-app" run build && pnpm mf:types && pnpm performance:readiness', + build: 'pnpm --filter "./apps/shell-super-app" run build && pnpm mf:types && pnpm performance:readiness', check: 'pnpm format:check && pnpm lint && pnpm action:test:unit && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm database-access:check && pnpm module-entrypoints:check && pnpm check:module-contracts && pnpm contract:check && pnpm performance:readiness', 'check:module-contracts': 'node ./scripts/check-ontos-module-contracts.mts', @@ -1250,8 +1245,7 @@ const workspaceValidationContractDefinition = { 'performance:readiness': SHARED_VALIDATOR_STRING_085, postinstall: "node ./scripts/bootstrap-agent-skills.mts --postinstall && oxfmt . '!repos/**'", 'scaffold:action': 'node ./scripts/scaffolding/cli.mts action', - 'scaffold:microvertical-action-boundary': - 'node ./scripts/scaffolding/cli.mts microvertical-action-boundary', + 'scaffold:microvertical-action-boundary': 'node ./scripts/scaffolding/cli.mts microvertical-action-boundary', 'scaffold:microvertical-page': 'node ./scripts/scaffolding/cli.mts microvertical-page', 'scaffold:module-api': 'node ./scripts/scaffolding/cli.mts module-api', 'scaffold:module-contract': 'node ./scripts/scaffolding/cli.mts module-contract', @@ -1335,7 +1329,7 @@ const workspaceValidationContractDefinition = { profile: SHARED_VALIDATOR_STRING_057, schemaVersion: 1, tooling: { - command: 'modern-js-create ultramodern', + command: 'ultramodern-create ultramodern', wrappers: { apiBoundaries: 'scripts/check-ultramodern-api-boundaries.mts', backendFederationGenerate: SHARED_VALIDATOR_STRING_116, @@ -1355,7 +1349,7 @@ const workspaceValidationContractDefinition = { workspace: { node: { engineRange: '>=26', - version: '26.5.0', + version: '26.7.0', }, packageManager: { name: 'pnpm', @@ -1374,11 +1368,11 @@ const workspaceValidationContractDefinition = { scripts: { backendFederationGenerate: 'node ./scripts/generate-node-backend-federation.mts', build: - 'pnpm -r --filter "./verticals/*" run build && pnpm --filter "./apps/shell-super-app" run build && pnpm mf:types && pnpm performance:readiness', + 'node ./scripts/ultramodern-typecheck.mts --build packages/shared-contracts/tsconfig.json && node ./scripts/ultramodern-typecheck.mts --build packages/shared-design-tokens/tsconfig.json && pnpm -r --filter "./verticals/*" run build && pnpm --filter "./apps/shell-super-app" run build && pnpm mf:types && pnpm performance:readiness', check: 'pnpm format:check && pnpm lint && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm contract:check && pnpm performance:readiness', cloudflareBuild: - 'pnpm -r --filter "./verticals/*" run cloudflare:build && pnpm --filter "./apps/shell-super-app" run cloudflare:build && ULTRAMODERN_MF_TYPES_ARCHIVE=dist-cloudflare/@mf-types.zip pnpm mf:types && pnpm cloudflare-output:verify && pnpm cloudflare:ssr-proof', + 'node ./scripts/ultramodern-typecheck.mts --build packages/shared-contracts/tsconfig.json && node ./scripts/ultramodern-typecheck.mts --build packages/shared-design-tokens/tsconfig.json && pnpm -r --filter "./verticals/*" run cloudflare:build && pnpm --filter "./apps/shell-super-app" run cloudflare:build && pnpm mf:types --target cloudflare && pnpm cloudflare-output:verify && pnpm cloudflare:ssr-proof', cloudflareDeploy: 'pnpm -r --filter "./verticals/*" run cloudflare:deploy && pnpm --filter "./apps/shell-super-app" run cloudflare:deploy', cloudflareOutputVerify: 'node ./scripts/verify-cloudflare-output.mts', @@ -1548,6 +1542,10 @@ const workspaceValidationContractDefinition = { id: SHARED_VALIDATOR_STRING_098, kind: 'vertical', moduleFederation: { + dts: { + compilerInstance: SHARED_VALIDATOR_STRING_068, + tsConfigPath: SHARED_VALIDATOR_STRING_007, + }, exposes: [SHARED_VALIDATOR_STRING_005], name: SHARED_VALIDATOR_STRING_159, role: 'remote', @@ -1698,8 +1696,7 @@ const workspaceValidationContractDefinition = { schemaVersion: 1, }, referenceTopology: { - description: - 'Generated UltraModern SuperApp shell that can grow by adding full-stack verticals.', + description: 'Generated UltraModern SuperApp shell that can grow by adding full-stack verticals.', id: 'ultramodern-superapp-workspace-reference-topology', preset: SHARED_VALIDATOR_STRING_104, schemaVersion: 1, @@ -1883,16 +1880,15 @@ const workspaceValidationContractDefinition = { versions: { cloudflareCompatibilityDate: SHARED_VALIDATOR_STRING_036, effect: SHARED_VALIDATOR_STRING_039, - moduleFederation: '2.8.0', - node: '26.5.0', + moduleFederation: '2.9.0', + node: '26.7.0', pnpm: '11.25.0', }, }; -type FullStackVertical = - (typeof workspaceValidationContractDefinition.fullStackVerticals)[number] & { - readonly packageSuffix?: string; - }; +type FullStackVertical = (typeof workspaceValidationContractDefinition.fullStackVerticals)[number] & { + readonly packageSuffix?: string; +}; interface GeneratedSurfaceTarget { readonly excludePaths?: readonly string[]; readonly extensions?: readonly string[]; @@ -1968,27 +1964,22 @@ interface BridgeConfig { }[]; readonly workspacePackages: readonly { readonly pattern: string }[]; } -interface CompactConfig extends Omit< - CompactConfigDocument, - 'bridge' | 'packageSource' | 'topology' -> { +interface CompactConfig extends Omit { readonly bridge?: BridgeConfig; - readonly packageSource: CompactConfigDocument['packageSource'] & { readonly registry?: string }; + readonly packageSource: CompactConfigDocument['packageSource'] & { + readonly registry?: string; + }; readonly shells?: unknown; readonly topology: Omit & { readonly apps?: readonly CompactApp[]; }; } type DevelopmentOverlay = typeof developmentOverlayDocument; -type OverlayServerExecution = - DevelopmentOverlay['serverExecution'][keyof DevelopmentOverlay['serverExecution']]; +type OverlayServerExecution = DevelopmentOverlay['serverExecution'][keyof DevelopmentOverlay['serverExecution']]; type Ownership = typeof ownershipDocument; type ReferenceTopologyDocument = typeof referenceTopologyDocument; type ReferenceTopologyVerticalDocument = ReferenceTopologyDocument['verticals'][number]; -interface ReferenceTopologyVertical extends Omit< - ReferenceTopologyVerticalDocument, - 'api' | 'moduleFederation' -> { +interface ReferenceTopologyVertical extends Omit { readonly api?: ReferenceTopologyVerticalDocument['api'] & { readonly domainOperations?: object; }; @@ -2193,8 +2184,7 @@ const additionalShellCohortFields: readonly AdditionalShellCohortField[] = [ SHARED_VALIDATOR_STRING_040, ]; -const workspaceValidationContract: WorkspaceValidationContract = - workspaceValidationContractDefinition; +const workspaceValidationContract: WorkspaceValidationContract = workspaceValidationContractDefinition; const rootPackage: RootPackage = rootPackageDocument; const ultramodernConfig: CompactConfig = compactConfigDocument; const topology: ReferenceTopology = referenceTopologyDocument; @@ -2205,8 +2195,7 @@ const { packageScope } = workspaceValidationContract; const expectedNodeVersion = workspaceValidationContract.versions.node; const expectedEffectVersion = workspaceValidationContract.versions.effect; const expectedModuleFederationVersion = workspaceValidationContract.versions.moduleFederation; -const expectedCloudflareCompatibilityDate = - workspaceValidationContract.versions.cloudflareCompatibilityDate; +const expectedCloudflareCompatibilityDate = workspaceValidationContract.versions.cloudflareCompatibilityDate; const { tailwindEnabled } = workspaceValidationContract; const { fullStackVerticals } = workspaceValidationContract; // Backend-federation and Zerops runtime surfaces only exist when the workspace @@ -2246,8 +2235,7 @@ const expectedModernPackageSpecifier = (packageName: string): string | undefined const alias = `@${scope}/${prefix}${packageName.split('/').at(-1)}`; return `npm:${alias}@${specifier}`; }; -const expectedWorkerName = (packageSuffix: string): string => - `${packageScope}-${packageSuffix}`.slice(0, 63); +const expectedWorkerName = (packageSuffix: string): string => `${packageScope}-${packageSuffix}`.slice(0, 63); const expectedChunkLoadingGlobal = (mfName: string): string => `__ULTRAMODERN_${mfName .replaceAll(/(?[a-z0-9])(?[A-Z])/gu, '$_$') @@ -2255,32 +2243,16 @@ const expectedChunkLoadingGlobal = (mfName: string): string => .replaceAll(/^_+|_+$/gu, '') .toUpperCase()}_LOADED_CHUNKS__`; -const readText = (relativePath: string): string => - fs.readFileSync(path.join(root, relativePath), 'utf-8'); +const readText = (relativePath: string): string => fs.readFileSync(path.join(root, relativePath), 'utf-8'); const readJson = >( schema: DocumentSchema, relativePath: string, ): DocumentSchema['Type'] => - Result.getOrThrow( - Schema.decodeUnknownResult(Schema.fromJsonString(schema))(readText(relativePath)), - ); + Result.getOrThrow(Schema.decodeUnknownResult(Schema.fromJsonString(schema))(readText(relativePath))); type Assert = (condition: boolean, message: string) => void; -type AssertSelfCheck = ( - condition: boolean, - contract: string, - message: string, - fixArea: string, -) => void; -type AssertObject = ( - value: Value | null | undefined, - contract: string, - fixArea: string, -) => void; -type AssertArray = ( - value: readonly Value[] | undefined, - contract: string, - fixArea: string, -) => void; +type AssertSelfCheck = (condition: boolean, contract: string, message: string, fixArea: string) => void; +type AssertObject = (value: Value | null | undefined, contract: string, fixArea: string) => void; +type AssertArray = (value: readonly Value[] | undefined, contract: string, fixArea: string) => void; const assert: Assert = (condition, message) => { assertCondition(condition, message); @@ -2297,10 +2269,7 @@ const assertAnyOf = (relativePaths: readonly string[]): void => { `Missing one of: ${relativePaths.join(', ')}`, ); }; -const sortedCopy = ( - values: readonly Value[], - compare: (left: Value, right: Value) => number, -): Value[] => { +const sortedCopy = (values: readonly Value[], compare: (left: Value, right: Value) => number): Value[] => { const result: Value[] = []; for (const value of values ?? []) { const insertAt = result.findIndex((existing) => compare(value, existing) < 0); @@ -2308,10 +2277,8 @@ const sortedCopy = ( } return result; }; -const valueForKey = ( - entries: readonly (readonly [string, Value])[], - key: string, -): Value | undefined => entries.find(([candidate]) => candidate === key)?.[1]; +const valueForKey = (entries: readonly (readonly [string, Value])[], key: string): Value | undefined => + entries.find(([candidate]) => candidate === key)?.[1]; const canonicalizeJsonValue = (value: ComparableJson): ComparableJson => { if (isComparableJsonArray(value)) { const entries = value.map((entry) => canonicalizeJsonValue(entry) ?? null); @@ -2336,15 +2303,12 @@ const canonicalizeJsonValue = (value: ComparableJson): ComparableJson => { const canonicalizeJson = (value: Value): ComparableJson | undefined => value === undefined ? undefined - : canonicalizeJsonValue( - Result.getOrThrow(Schema.decodeUnknownResult(ComparableJsonSchema)(value)), - ); + : canonicalizeJsonValue(Result.getOrThrow(Schema.decodeUnknownResult(ComparableJsonSchema)(value))); const sameJson = (actual: Actual, expected: Expected): boolean => jsonEquivalent(canonicalizeJson(actual), canonicalizeJson(expected)); const formatJson = (value: Value): string => value === undefined ? 'undefined' : Inspectable.toStringUnknown(canonicalizeJson(value), 0); -const quoteYamlString = (value: string | number): string => - `'${String(value).replaceAll("'", "''")}'`; +const quoteYamlString = (value: string | number): string => `'${String(value).replaceAll("'", "''")}'`; const quoteShellValue = (value: string | number): string => `'${String(value).replaceAll("'", shellSingleQuoteEscape)}'`; const yamlListItemBlock = (source: string, key: string, value: string | number): string => { @@ -2363,9 +2327,7 @@ const yamlMappingBlock = (source: string, key: string, indent: number): string = if (start === -1) { return ''; } - const nextSibling = source - .slice(start + marker.length) - .search(new RegExp(`\\n${indentation}\\S`, 'u')); + const nextSibling = source.slice(start + marker.length).search(new RegExp(`\\n${indentation}\\S`, 'u')); const end = nextSibling === -1 ? undefined : start + marker.length + nextSibling; return source.slice(start, end); }; @@ -2396,12 +2358,7 @@ const assertObject: AssertObject = (value, contract, fixArea) => { ); }; const assertArray: AssertArray = (value, contract, fixArea) => { - assertSelfCheck( - Array.isArray(value), - contract, - `Expected JSON array, found ${formatJson(value)}`, - fixArea, - ); + assertSelfCheck(Array.isArray(value), contract, `Expected JSON array, found ${formatJson(value)}`, fixArea); }; const assertUniqueStrings = (values: readonly string[] | undefined, contract: string): void => { assert(Array.isArray(values), `${contract} must be an array`); @@ -2412,10 +2369,7 @@ const assertUniqueStrings = (values: readonly string[] | undefined, contract: st seen.add(value); } }; -const assertUniqueIdEntries = ( - entries: readonly IdentifierEntry[] | undefined, - contract: string, -): void => { +const assertUniqueIdEntries = (entries: readonly IdentifierEntry[] | undefined, contract: string): void => { assert(Array.isArray(entries), `${contract} must be an array`); const seen = new Set(); for (const entry of entries ?? []) { @@ -2439,10 +2393,7 @@ const assertSameIdCohort = ( fixArea, ); }; -const assertGeneratedSurfaceTarget = ( - rule: GeneratedSurfaceRule, - target: GeneratedSurfaceTarget, -): void => { +const assertGeneratedSurfaceTarget = (rule: GeneratedSurfaceRule, target: GeneratedSurfaceTarget): void => { assert( target.kind === 'file' || target.kind === 'directory', `generated surface policy ${rule.id} has an invalid target kind`, @@ -2452,30 +2403,18 @@ const assertGeneratedSurfaceTarget = ( `generated surface policy ${rule.id} target path is required`, ); if (target.kind === 'directory') { - assertUniqueStrings( - target.extensions, - `generated surface policy ${rule.id} directory extensions`, - ); - assertUniqueStrings( - target.excludePaths ?? [], - `generated surface policy ${rule.id} directory exclusions`, - ); + assertUniqueStrings(target.extensions, `generated surface policy ${rule.id} directory extensions`); + assertUniqueStrings(target.excludePaths ?? [], `generated surface policy ${rule.id} directory exclusions`); } }; -const assertGeneratedSurfacePattern = ( - rule: GeneratedSurfaceRule, - pattern: GeneratedSurfacePattern, -): void => { +const assertGeneratedSurfacePattern = (rule: GeneratedSurfaceRule, pattern: GeneratedSurfacePattern): void => { if (pattern.structuralMatcher === undefined) { const { expression, flags } = pattern; assert( isString(expression) && expression.length > 0, `generated surface policy ${rule.id}.${pattern.id} expression is required`, ); - assert( - flags === 'u', - `generated surface policy ${rule.id}.${pattern.id} must use deterministic Unicode matching`, - ); + assert(flags === 'u', `generated surface policy ${rule.id}.${pattern.id} must use deterministic Unicode matching`); if (expression !== undefined && flags !== undefined) { const compiledPattern = new RegExp(expression, flags); assert( @@ -2493,13 +2432,11 @@ const assertGeneratedSurfacePattern = ( `generated surface policy ${rule.id}.${pattern.id} has an unsupported structural matcher`, ); assert( - isString(pattern.structuralMatcher.elementName) && - pattern.structuralMatcher.elementName.length > 0, + isString(pattern.structuralMatcher.elementName) && pattern.structuralMatcher.elementName.length > 0, `generated surface policy ${rule.id}.${pattern.id} structural elementName is required`, ); assert( - isString(pattern.structuralMatcher.attributeName) && - pattern.structuralMatcher.attributeName.length > 0, + isString(pattern.structuralMatcher.attributeName) && pattern.structuralMatcher.attributeName.length > 0, `generated surface policy ${rule.id}.${pattern.id} structural attributeName is required`, ); } @@ -2518,10 +2455,7 @@ const assertGeneratedSurfaceRules = (contract: WorkspaceValidationContract): voi generatedSurfacePolicy.schemaVersion === 1, `Unsupported generated surface policy schemaVersion ${formatJson(generatedSurfacePolicy.schemaVersion)}; expected 1`, ); - assertUniqueIdEntries( - generatedSurfacePolicy.rules, - 'workspace validation contract generated surface policy rules', - ); + assertUniqueIdEntries(generatedSurfacePolicy.rules, 'workspace validation contract generated surface policy rules'); for (const rule of generatedSurfacePolicy.rules) { assertUniqueStrings( rule.paths.map((entry) => entry.path), @@ -2567,25 +2501,13 @@ const assertWorkspaceValidationContract = (contract: WorkspaceValidationContract ); } - assertUniqueStrings( - contract.cohort.modernPackages, - 'workspace validation contract Modern package cohort', - ); + assertUniqueStrings(contract.cohort.modernPackages, 'workspace validation contract Modern package cohort'); assertUniqueStrings(contract.cohort.appIds, 'workspace validation contract app cohort'); - assertUniqueStrings( - contract.cohort.backendAppIds, - 'workspace validation contract backend app cohort', - ); + assertUniqueStrings(contract.cohort.backendAppIds, 'workspace validation contract backend app cohort'); assertUniqueStrings(contract.cohort.verticalIds, 'workspace validation contract vertical cohort'); - assertUniqueStrings( - contract.cohort.sharedPackageIds, - 'workspace validation contract shared package cohort', - ); + assertUniqueStrings(contract.cohort.sharedPackageIds, 'workspace validation contract shared package cohort'); assertUniqueStrings(contract.cohort.ownerIds, 'workspace validation contract owner cohort'); - assertUniqueIdEntries( - contract.cohort.packageManifests, - 'workspace validation contract package manifests', - ); + assertUniqueIdEntries(contract.cohort.packageManifests, 'workspace validation contract package manifests'); assertUniqueStrings( contract.cohort.packageManifests.map((manifest) => manifest.path), 'workspace validation contract package manifest paths', @@ -2649,8 +2571,7 @@ const skipSourceComment = (source: string, start: number): number => { } return start; }; -const isSourceQuote = (character: string | undefined): boolean => - character !== undefined && '\'"`'.includes(character); +const isSourceQuote = (character: string | undefined): boolean => character !== undefined && '\'"`'.includes(character); const matchesJsxAttribute = (source: string, cursor: number, attributeName: string): boolean => { if ( !source.startsWith(attributeName, cursor) || @@ -2726,9 +2647,7 @@ const findGeneratedSurfacePolicyMatch = ( if (pattern.structuralMatcher?.kind === SHARED_VALIDATOR_STRING_074) { return findJsxAttribute(source, pattern.structuralMatcher); } - return pattern.expression === undefined - ? null - : new RegExp(pattern.expression, pattern.flags).exec(source); + return pattern.expression === undefined ? null : new RegExp(pattern.expression, pattern.flags).exec(source); }; const assertSingleShellDeclarations = (): void => { assert( @@ -2788,10 +2707,7 @@ const assertLegacyMetadataFields = (): void => { 'restore generated compact package-source metadata', ); for (const field of workspaceValidationContract.legacy.forbiddenCompactConfigFields) { - assert( - !Object.hasOwn(ultramodernConfig, field), - `Stale legacy field ${compactConfigPath}.${field} is forbidden`, - ); + assert(!Object.hasOwn(ultramodernConfig, field), `Stale legacy field ${compactConfigPath}.${field} is forbidden`); } for (const field of workspaceValidationContract.legacy.forbiddenPackageSourceFields) { assert( @@ -2810,15 +2726,9 @@ const assertMetadataPackageManifests = (): void => { for (const manifest of workspaceValidationContract.cohort.packageManifests) { assertExists(manifest.path); const packageJson = readJson(PackageJsonSchema, manifest.path); - assert( - packageJson.name === manifest.packageName, - `${manifest.path} package name must be ${manifest.packageName}`, - ); + assert(packageJson.name === manifest.packageName, `${manifest.path} package name must be ${manifest.packageName}`); if (manifest.role === 'shell' || manifest.role === 'vertical') { - assert( - packageJson.modernjs?.appId === manifest.id, - `${manifest.path} modernjs.appId must be ${manifest.id}`, - ); + assert(packageJson.modernjs?.appId === manifest.id, `${manifest.path} modernjs.appId must be ${manifest.id}`); } } if (expectedReleaseCohort !== undefined) { @@ -2859,15 +2769,10 @@ const assertStructuredWorkspaceMetadata = (): void => { for (const entry of observedMetadata) { assert(isMetadataDocument(entry.value), `${entry.contract.path} must contain a JSON object`); - assert( - Number.isInteger(entry.value.schemaVersion), - `${entry.contract.path} must declare an integer schemaVersion`, - ); + assert(Number.isInteger(entry.value.schemaVersion), `${entry.contract.path} must declare an integer schemaVersion`); } - const observedSchemaVersions = new Set( - observedMetadata.map((entry) => entry.value.schemaVersion), - ); + const observedSchemaVersions = new Set(observedMetadata.map((entry) => entry.value.schemaVersion)); assert( observedSchemaVersions.size === 1, `Mixed workspace metadata schema versions: ${observedMetadata @@ -2914,9 +2819,7 @@ const assertStructuredWorkspaceMetadata = (): void => { ); assertSameIdCohort( topology.shell?.moduleFederation?.remotes, - workspaceValidationContract.topology.referenceTopology.shell.moduleFederation.remotes.map( - (remote) => remote.id, - ), + workspaceValidationContract.topology.referenceTopology.shell.moduleFederation.remotes.map((remote) => remote.id), `${workspaceValidationContract.metadata.referenceTopology.path} shell.moduleFederation.remotes`, 'restore the complete generated shell remote cohort', ); @@ -2985,21 +2888,15 @@ const toCamelCase = (value: string): string => { const pascal = toPascalCase(value); return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`; }; -const toEnvSegment = (value: string): string => - toKebabCase(value).replaceAll('-', '_').toUpperCase(); +const toEnvSegment = (value: string): string => toKebabCase(value).replaceAll('-', '_').toUpperCase(); const packageNameFor = (scope: string, suffix: string): string => `@${scope}/${suffix}`; const normalizeRelativePath = (value: string | undefined): string => (value ?? '').replaceAll('\\', '/').replace(/^\.\/+/u, ''); -const appNamespace = (app: NormalizedApp): string => - app.kind === 'shell' ? 'shell' : (app.domain ?? app.id); +const appNamespace = (app: NormalizedApp): string => (app.kind === 'shell' ? 'shell' : (app.domain ?? app.id)); const tailwindPrefixFor = (app: NormalizedApp): string => app.kind === 'shell' ? 'shell' : tailwindPrefixForNamespace(app.domain ?? app.id); const buildMarkerFor = (app: NormalizedApp): string => - crypto - .createHash('sha256') - .update(`${packageScope}:${app.packageSuffix}:${app.id}:0.1.0`) - .digest('hex') - .slice(0, 16); + crypto.createHash('sha256').update(`${packageScope}:${app.packageSuffix}:${app.id}:0.1.0`).digest('hex').slice(0, 16); const deliveryUnitIdentityFixArea = 'regenerate vertical identity from delivery-unit record; do not hand-edit surface markers'; const deliveryUnitBlock = (record: DeliveryUnit | undefined) => ({ @@ -3015,18 +2912,9 @@ const expectedCompactAppFor = (id: string) => workspaceValidationContract.topology.compactConfig?.apps?.find((entry) => entry?.id === id); const expectedDeliveryUnitFor = (vertical: FullStackVertical): DeliveryUnit => { const expectedApp = expectedCompactAppFor(vertical.id); - return ( - expectedApp?.backendFederation?.deliveryUnit ?? - expectedApp?.deliveryUnit ?? - vertical.deliveryUnit - ); + return expectedApp?.backendFederation?.deliveryUnit ?? expectedApp?.deliveryUnit ?? vertical.deliveryUnit; }; -const assertBuildFacadeExport = ( - source: string, - exportName: string, - sourcePath: string, - contract: string, -): void => { +const assertBuildFacadeExport = (source: string, exportName: string, sourcePath: string, contract: string): void => { const escapedSourcePath = sourcePath.replaceAll('.', String.raw`\.`); const exportPattern = new RegExp(`export const ${exportName} = ${escapedSourcePath};`, 'u'); assertSelfCheck( @@ -3061,8 +2949,7 @@ const normalizedAppApi = ( return undefined; } return { - consumedBy: - rawApi.consumedBy === undefined ? [SHARED_VALIDATOR_STRING_131, id] : [...rawApi.consumedBy], + consumedBy: rawApi.consumedBy === undefined ? [SHARED_VALIDATOR_STRING_131, id] : [...rawApi.consumedBy], prefix: isString(rawApi.prefix) ? rawApi.prefix : `/${domain ?? id}-api`, protocol: rawApi.protocol === 'rpc' ? 'rpc' : 'rest', stem: isString(rawApi.stem) ? rawApi.stem : (domain ?? id), @@ -3079,10 +2966,7 @@ const normalizedAppMfName = ( } return kind === 'shell' ? SHARED_VALIDATOR_STRING_133 : `vertical${toPascalCase(domain ?? id)}`; }; -const normalizedAppPort = ( - configuredPort: number | undefined, - kind: NormalizedApp['kind'], -): number => { +const normalizedAppPort = (configuredPort: number | undefined, kind: NormalizedApp['kind']): number => { if (isNumber(configuredPort)) { return configuredPort; } @@ -3097,9 +2981,7 @@ const normalizedAppPortEnv = ( if (isString(configuredPortEnv)) { return configuredPortEnv; } - return kind === 'shell' - ? SHARED_VALIDATOR_STRING_130 - : `VERTICAL_${toEnvSegment(domain ?? id)}_PORT`; + return kind === 'shell' ? SHARED_VALIDATOR_STRING_130 : `VERTICAL_${toEnvSegment(domain ?? id)}_PORT`; }; const normalizedApiExports = ( appPath: string, @@ -3107,8 +2989,7 @@ const normalizedApiExports = ( ): Pick => { const packageExports = readJson(PackageJsonSchema, `${appPath}/package.json`).exports ?? {}; const apiContractExport = packageExports['./api'] === undefined ? undefined : './api'; - const clientExport = - api?.protocol === 'rpc' ? SHARED_VALIDATOR_STRING_003 : SHARED_VALIDATOR_STRING_002; + const clientExport = api?.protocol === 'rpc' ? SHARED_VALIDATOR_STRING_003 : SHARED_VALIDATOR_STRING_002; const apiClientExport = packageExports[clientExport] === undefined ? undefined : clientExport; return { apiClientExport, apiContractExport }; }; @@ -3116,9 +2997,7 @@ const normalizeCompactApp = (rawApp: CompactApp): NormalizedApp => { const { api: rawApi, domain: rawDomain, id, port: rawPort, portEnv: rawPortEnv } = rawApp; const kind = rawApp.kind === 'vertical' ? 'vertical' : 'shell'; const appPath = normalizedAppPath(rawApp, kind); - const packageSuffix = isString(rawApp.packageSuffix) - ? rawApp.packageSuffix - : (appPath.split('/').at(-1) ?? id); + const packageSuffix = isString(rawApp.packageSuffix) ? rawApp.packageSuffix : (appPath.split('/').at(-1) ?? id); const domain = normalizedAppDomain(rawDomain, kind, packageSuffix); const { moduleFederation } = rawApp; // Preserve the API protocol so the synthesized generated contract can branch @@ -3146,8 +3025,7 @@ const normalizeCompactApp = (rawApp: CompactApp): NormalizedApp => { path: appPath, port, portEnv, - verticalRefs: - moduleFederation.verticalRefs === undefined ? [] : [...moduleFederation.verticalRefs], + verticalRefs: moduleFederation.verticalRefs === undefined ? [] : [...moduleFederation.verticalRefs], }; }; const compactAppsFromConfig = (config: CompactConfig): NormalizedApp[] => @@ -3213,9 +3091,10 @@ const createLocalisedUrls = (app: NormalizedApp) => if (route.canonicalPath === '/') { return []; } - return [...new Set([route.canonicalPath, ...Object.values(route.localisedPaths)])].map( - (pathname) => [pathname, route.localisedPaths], - ); + return [...new Set([route.canonicalPath, ...Object.values(route.localisedPaths)])].map((pathname) => [ + pathname, + route.localisedPaths, + ]); }), ); const createPublicSurface = (app: NormalizedApp) => { @@ -3275,14 +3154,7 @@ const createPublicHead = () => ({ ssr: true, structuredData: { helperModule: './src/routes/ultramodern-jsonld', - helperTypes: [ - 'WebPage', - 'WebApplication', - 'SoftwareApplication', - 'BreadcrumbList', - 'FAQPage', - 'Organization', - ], + helperTypes: ['WebPage', 'WebApplication', 'SoftwareApplication', 'BreadcrumbList', 'FAQPage', 'Organization'], inference: false, optional: true, publicIndexableOnly: true, @@ -3302,8 +3174,7 @@ const createPublicHead = () => ({ const createCloudflareRoutes = (app: NormalizedApp) => { const hasRenderedSurface = app.kind === 'shell' || app.exposes.length > 0; return { - apiReadiness: - app.api?.protocol === 'rest' ? `${app.api.prefix}/${app.api.stem}/readiness` : undefined, + apiReadiness: app.api?.protocol === 'rest' ? `${app.api.prefix}/${app.api.stem}/readiness` : undefined, locale: hasRenderedSurface ? `/locales/en/${appNamespace(app)}.json` : undefined, mfManifest: SHARED_VALIDATOR_STRING_031, ssr: hasRenderedSurface ? '/en' : undefined, @@ -3455,10 +3326,7 @@ const createStylingContract = (app: NormalizedApp) => { dedupe: cssDedupe(), entrypoints: { css: [SHARED_VALIDATOR_STRING_138], - federationEntry: - app.kind === 'shell' || app.exposes.length === 0 - ? undefined - : SHARED_VALIDATOR_STRING_136, + federationEntry: app.kind === 'shell' || app.exposes.length === 0 ? undefined : SHARED_VALIDATOR_STRING_136, layoutImport: 'src/routes/layout.tsx', }, layers: { @@ -3474,10 +3342,7 @@ const createStylingContract = (app: NormalizedApp) => { ssr: { cloudflare: true, firstPaintRequired: true, - verticalCss: - app.kind === 'shell' - ? 'host-preloads-shell-and-shared-css' - : 'federated-manifest-owned-css', + verticalCss: app.kind === 'shell' ? 'host-preloads-shell-and-shared-css' : 'federated-manifest-owned-css', }, }, tailwind: tailwindEnabled, @@ -3509,8 +3374,7 @@ const createApiContract = (app: NormalizedApp) => { return { client: app.apiClientExport, contract: app.apiContractExport, - domainOperations: - app.apiContractExport === undefined ? undefined : createEffectDomainOperations(app), + domainOperations: app.apiContractExport === undefined ? undefined : createEffectDomainOperations(app), import: '@modern-js/plugin-bff/effect-edge', openapi: '/openapi.json', prefix: api.prefix, @@ -3642,22 +3506,14 @@ const createModernPackageAliases = ( return undefined; } const scope = packageSourceConfig.aliasScope.replace(/^@/u, ''); - const prefix = isString(packageSourceConfig.aliasPackageNamePrefix) - ? packageSourceConfig.aliasPackageNamePrefix - : ''; + const prefix = isString(packageSourceConfig.aliasPackageNamePrefix) ? packageSourceConfig.aliasPackageNamePrefix : ''; return Object.fromEntries( - modernPackageCohort.map((packageName) => [ - packageName, - `@${scope}/${prefix}${packageName.split('/').at(-1)}`, - ]), + modernPackageCohort.map((packageName) => [packageName, `@${scope}/${prefix}${packageName.split('/').at(-1)}`]), ); }; const createPackageSourceView = (config: CompactConfig) => { const source = config.packageSource; - assert( - isPackageSourceDocument(source), - `${compactConfigPath} packageSource must be a JSON object`, - ); + assert(isPackageSourceDocument(source), `${compactConfigPath} packageSource must be a JSON object`); assert( source.strategy === 'workspace' || source.strategy === 'install', `${compactConfigPath} packageSource.strategy must be workspace or install`, @@ -3669,8 +3525,7 @@ const createPackageSourceView = (config: CompactConfig) => { ); } const { strategy } = source; - const specifier = - strategy === 'install' ? source.modernPackageVersion : SHARED_VALIDATOR_STRING_169; + const specifier = strategy === 'install' ? source.modernPackageVersion : SHARED_VALIDATOR_STRING_169; const aliases = createModernPackageAliases(source); return { generatedWorkspacePackages: { @@ -3704,16 +3559,12 @@ const synthesizeGeneratedContractFromCompact = (config: CompactConfig) => { schemaVersion: 1, }; }; -const readGeneratedContractView = (config: CompactConfig) => - synthesizeGeneratedContractFromCompact(config); +const readGeneratedContractView = (config: CompactConfig) => synthesizeGeneratedContractFromCompact(config); const expectedManifestUrl = (vertical: FullStackVertical): string => `http://localhost:${vertical.port}/mf-manifest.json`; const expectedApiUrl = (vertical: FullStackVertical): string => - `http://localhost:${vertical.port}${vertical.apiPrefix}${ - vertical.apiProtocol === 'rpc' ? '/rpc' : '' - }`; -const expectedBackendFederationName = (vertical: FullStackVertical): string => - `${vertical.mfName}Backend`; + `http://localhost:${vertical.port}${vertical.apiPrefix}${vertical.apiProtocol === 'rpc' ? '/rpc' : ''}`; +const expectedBackendFederationName = (vertical: FullStackVertical): string => `${vertical.mfName}Backend`; const expectedBackendManifestUrl = (vertical: FullStackVertical): string => `http://localhost:${vertical.port}/backend-mf-manifest.json`; const expectedBackendContainerEntry = (vertical: FullStackVertical): string => @@ -3786,8 +3637,7 @@ const expectedBackendFederationSubset = (vertical: FullStackVertical) => ({ compatibility: { contractVersion: SHARED_VALIDATOR_STRING_079, }, - exposeReadiness: - vertical.apiProtocol === 'rpc' ? undefined : `${vertical.apiPrefix}/${vertical.stem}/readiness`, + exposeReadiness: vertical.apiProtocol === 'rpc' ? undefined : `${vertical.apiPrefix}/${vertical.stem}/readiness`, node: { containerEntry: expectedBackendContainerEntry(vertical), expose: SHARED_VALIDATOR_STRING_004, @@ -3798,10 +3648,7 @@ const expectedBackendFederationSubset = (vertical: FullStackVertical) => ({ remoteType: SHARED_VALIDATOR_STRING_063, }, versionBoundary: { - apiReadiness: - vertical.apiProtocol === 'rpc' - ? undefined - : `${vertical.apiPrefix}/${vertical.stem}/readiness`, + apiReadiness: vertical.apiProtocol === 'rpc' ? undefined : `${vertical.apiPrefix}/${vertical.stem}/readiness`, invariant: SHARED_VALIDATOR_STRING_168, uiManifestUrl: expectedManifestUrl(vertical), }, @@ -3824,9 +3671,7 @@ const expectedServerExecutionSubset = (vertical: FullStackVertical) => ({ nodeManifestUrl: expectedBackendManifestUrl(vertical), versionBoundary: SHARED_VALIDATOR_STRING_168, }); -const remoteContractSubset = ( - remote: Pick | undefined, -) => ({ +const remoteContractSubset = (remote: Pick | undefined) => ({ id: remote?.id, manifestUrl: remote?.manifestUrl, name: remote?.name, @@ -3868,10 +3713,7 @@ const requiredMicroVerticalPaths = (vertical: FullStackVertical): string[] => [ `${vertical.path}/src/routes/ultramodern-route-head.tsx`, `${vertical.path}/src/routes/ultramodern-route-metadata.ts`, ...(vertical.hasOwnerPage - ? [ - `${vertical.path}/src/routes/[lang]/page.tsx`, - `${vertical.path}/src/routes/ultramodern-jsonld.ts`, - ] + ? [`${vertical.path}/src/routes/[lang]/page.tsx`, `${vertical.path}/src/routes/ultramodern-jsonld.ts`] : []), ...(vertical.exposes.includes('./Widget') ? [`${vertical.path}/src/routes/[lang]/_mf/fragment/widget/page.tsx`] @@ -3991,8 +3833,7 @@ const assertTopologyVerticalDeliveryUnitContract = ( deliveryUnitIdentityFixArea, ); assertSelfCheck( - topologyEntry.backendFederation?.versionBoundary?.identityRoot === - SHARED_VALIDATOR_STRING_065, + topologyEntry.backendFederation?.versionBoundary?.identityRoot === SHARED_VALIDATOR_STRING_065, `topology/reference-topology.json verticals.${vertical.id}.backendFederation.versionBoundary.identityRoot`, `Expected "deliveryUnit", found ${formatJson(topologyEntry.backendFederation?.versionBoundary?.identityRoot)}`, deliveryUnitIdentityFixArea, @@ -4009,11 +3850,7 @@ const topologyVerticalFederationView = (topologyEntry: ReferenceTopologyVertical const assertTopologyVerticalContract = (vertical: FullStackVertical): void => { const topologyEntry = findById(topology.verticals, vertical.id); const expectedRefs = vertical.verticalRefs ?? []; - assertObject( - topologyEntry, - `topology/reference-topology.json verticals.${vertical.id}`, - SHARED_VALIDATOR_STRING_112, - ); + assertObject(topologyEntry, `topology/reference-topology.json verticals.${vertical.id}`, SHARED_VALIDATOR_STRING_112); if (topologyEntry === undefined) { return; } @@ -4063,11 +3900,7 @@ const assertTopologyVerticalContract = (vertical: FullStackVertical): void => { }; const assertVerticalOwnershipAndOverlay = (vertical: FullStackVertical): void => { const ownershipEntry = findById(ownership.owners, vertical.id); - assertObject( - ownershipEntry, - `topology/ownership.json owners.${vertical.id}`, - SHARED_VALIDATOR_STRING_111, - ); + assertObject(ownershipEntry, `topology/ownership.json owners.${vertical.id}`, SHARED_VALIDATOR_STRING_111); if (ownershipEntry === undefined) { return; } @@ -4099,9 +3932,7 @@ const assertVerticalOwnershipAndOverlay = (vertical: FullStackVertical): void => 'restore generated local API overlay', ); assertSameJson( - serverExecutionSubset( - valueForKey(Object.entries(overlay.serverExecution ?? {}), vertical.id), - ), + serverExecutionSubset(valueForKey(Object.entries(overlay.serverExecution ?? {}), vertical.id)), expectedServerExecutionSubset(vertical), `topology/local-overlays/development.json serverExecution.${vertical.id}`, 'restore generated local MicroVertical server execution overlay', @@ -4123,10 +3954,7 @@ const assertShellDependenciesForVertical = ( } if (composed) { assertSameJson( - valueForKey( - Object.entries(shellPackage[SHARED_VALIDATOR_STRING_173] ?? {}), - vertical.zephyrAlias, - ), + valueForKey(Object.entries(shellPackage[SHARED_VALIDATOR_STRING_173] ?? {}), vertical.zephyrAlias), `${vertical.packageName}@workspace:*`, `${SHARED_VALIDATOR_STRING_047}/package.json zephyr:dependencies.${vertical.zephyrAlias}`, 'restore shell Zephyr dependency metadata for the MicroVertical', @@ -4144,11 +3972,7 @@ const assertGeneratedVerticalContract = ( generatedContract: ReturnType, ): void => { const contractEntry = findById(generatedContract.apps, vertical.id); - assertObject( - contractEntry, - `${generatedContractLabel} apps.${vertical.id}`, - regenerateMicroVerticalContractFix, - ); + assertObject(contractEntry, `${generatedContractLabel} apps.${vertical.id}`, regenerateMicroVerticalContractFix); if (contractEntry === undefined) { return; } @@ -4229,29 +4053,17 @@ const assertGeneratedPrimaryShellContract = ( return true; }; -const assertMicroVerticalContractGraph = ( - generatedContract: ReturnType, -): void => { +const assertMicroVerticalContractGraph = (generatedContract: ReturnType): void => { const expectedVerticalIds = fullStackVerticals.map((vertical) => vertical.id); const expectedAppIds = [SHARED_VALIDATOR_STRING_131, ...expectedVerticalIds]; const expectedShellVerticalIds = expectedPrimaryShellVerticalIds; const expectedShellRemotes = expectedShellVerticalIds.flatMap((verticalId) => { const vertical = fullStackVerticals.find((candidate) => candidate.id === verticalId); - return vertical === undefined || vertical.exposes.length === 0 - ? [] - : [expectedRemoteContractSubset(vertical)]; + return vertical === undefined || vertical.exposes.length === 0 ? [] : [expectedRemoteContractSubset(vertical)]; }); - assertObject( - topology.shell, - 'topology/reference-topology.json shell', - 'restore generated topology shell metadata', - ); - assertArray( - topology.verticals, - 'topology/reference-topology.json verticals', - SHARED_VALIDATOR_STRING_112, - ); + assertObject(topology.shell, 'topology/reference-topology.json shell', 'restore generated topology shell metadata'); + assertArray(topology.verticals, 'topology/reference-topology.json verticals', SHARED_VALIDATOR_STRING_112); assertObject( topology.shell?.moduleFederation, 'topology/reference-topology.json shell.moduleFederation', @@ -4278,11 +4090,7 @@ const assertMicroVerticalContractGraph = ( 'topology/local-overlays/development.json ontosModuleManifests', 'restore generated OntOS module contract allowlist overlays', ); - assertObject( - overlay.apis, - 'topology/local-overlays/development.json apis', - 'restore generated local API overlays', - ); + assertObject(overlay.apis, 'topology/local-overlays/development.json apis', 'restore generated local API overlays'); assertArray( generatedContract.apps, `${generatedContractLabel} apps`, @@ -4314,13 +4122,7 @@ const assertMicroVerticalContractGraph = ( 'regenerate the generated contract after topology changes', ); - if ( - !assertGeneratedPrimaryShellContract( - generatedContract, - expectedShellVerticalIds, - expectedShellRemotes, - ) - ) { + if (!assertGeneratedPrimaryShellContract(generatedContract, expectedShellVerticalIds, expectedShellRemotes)) { return; } for (const vertical of fullStackVerticals) { @@ -4338,37 +4140,19 @@ const referenceFrom = (fromPath: string, toPath: string) => ({ path: toPosixPath(path.relative(fromPath, toPath)), }); const infrastructurePackagePaths = [SHARED_VALIDATOR_STRING_092]; -const sharedPackagePaths = [ - SHARED_VALIDATOR_STRING_177, - SHARED_VALIDATOR_STRING_094, - SHARED_VALIDATOR_STRING_096, -]; +const sharedPackagePaths = [SHARED_VALIDATOR_STRING_177, SHARED_VALIDATOR_STRING_094, SHARED_VALIDATOR_STRING_096]; const workspacePackagePaths = [...infrastructurePackagePaths, ...sharedPackagePaths]; -const tsgoCacheKey = (packagePath: string): string => - packagePath.replaceAll(/[^a-zA-Z0-9._-]+/gu, '__'); +const tsgoCacheKey = (packagePath: string): string => packagePath.replaceAll(/[^a-zA-Z0-9._-]+/gu, '__'); const assertProjectReferenceEmitConfig = (tsConfig: TsConfig, packagePath: string): void => { const compilerOptions = tsConfig.compilerOptions ?? {}; const relativeRoot = toPosixPath(path.relative(packagePath, '.')) ?? '.'; assert(compilerOptions.composite === true, `${packagePath} must stay a composite TS-Go project`); + assert(compilerOptions.declaration === true, `${packagePath} must emit declarations for TS-Go build mode`); + assert(compilerOptions.declarationMap === false, `${packagePath} must not emit declaration maps during checks`); + assert(compilerOptions.emitDeclarationOnly === true, `${packagePath} must only emit declarations during checks`); + assert(compilerOptions.noEmit === false, `${packagePath} must override root noEmit for TS-Go build mode`); assert( - compilerOptions.declaration === true, - `${packagePath} must emit declarations for TS-Go build mode`, - ); - assert( - compilerOptions.declarationMap === false, - `${packagePath} must not emit declaration maps during checks`, - ); - assert( - compilerOptions.emitDeclarationOnly === true, - `${packagePath} must only emit declarations during checks`, - ); - assert( - compilerOptions.noEmit === false, - `${packagePath} must override root noEmit for TS-Go build mode`, - ); - assert( - compilerOptions.outDir === - `${relativeRoot}/node_modules/.cache/tsgo/declarations/${tsgoCacheKey(packagePath)}`, + compilerOptions.outDir === `${relativeRoot}/node_modules/.cache/tsgo/declarations/${tsgoCacheKey(packagePath)}`, `${packagePath} must emit TS-Go declarations into the generated cache`, ); assert( @@ -4377,10 +4161,7 @@ const assertProjectReferenceEmitConfig = (tsConfig: TsConfig, packagePath: strin `${packagePath} must keep TS-Go build info in the generated cache`, ); }; -const expectedVerticalTypecheckIncludes = ( - vertical: FullStackVertical, - verticalPackage: PackageJson, -) => +const expectedVerticalTypecheckIncludes = (vertical: FullStackVertical, verticalPackage: PackageJson) => vertical.typecheckIncludes ?? [ 'src', SHARED_VALIDATOR_STRING_075, @@ -4393,10 +4174,7 @@ const expectedVerticalTypecheckIncludes = ( ]; const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void => { const verticalTsConfig = readJson(TsConfigSchema, `${vertical.path}/tsconfig.json`); - const verticalMfTypesTsConfig = readJson( - TsConfigSchema, - `${vertical.path}/tsconfig.mf-types.json`, - ); + const verticalMfTypesTsConfig = readJson(TsConfigSchema, `${vertical.path}/tsconfig.mf-types.json`); const verticalPackage = readJson(PackageJsonSchema, `${vertical.path}/package.json`); const sourceSpecifiers = ['api', 'shared', 'src'] .flatMap((sourceRoot) => { @@ -4412,9 +4190,7 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void .flatMap((sourcePath) => { const source = readText(sourcePath); return [ - ...source.matchAll( - /\b(?:import|export)\s+(?:type\s+)?[^;'"`]+?\s+from\s*['"](?[^'"]+)['"]/gu, - ), + ...source.matchAll(/\b(?:import|export)\s+(?:type\s+)?[^;'"`]+?\s+from\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\bimport\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\bimport\s*\(\s*['"](?[^'"]+)['"]/gu), ...source.matchAll(/\brequire\s*\(\s*['"](?[^'"]+)['"]/gu), @@ -4429,8 +4205,7 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void return []; } const importsCandidate = sourceSpecifiers.some( - (specifier) => - specifier === candidate.packageName || specifier.startsWith(`${candidate.packageName}/`), + (specifier) => specifier === candidate.packageName || specifier.startsWith(`${candidate.packageName}/`), ); const dependencyDeclared = valueForKey(Object.entries(verticalPackage.dependencies ?? {}), candidate.packageName) === @@ -4458,10 +4233,7 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void }); for (const exportKey of publishedOutboxContractExports(candidatePackage)) { const exportTarget = candidatePackage.exports?.[exportKey]; - assert( - isString(exportTarget), - `${candidate.packageName}${exportKey.slice(1)} must resolve to one source file`, - ); + assert(isString(exportTarget), `${candidate.packageName}${exportKey.slice(1)} must resolve to one source file`); if (exportTarget === undefined) { continue; } @@ -4480,9 +4252,7 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void ...sharedPackagePaths, ...(vertical.verticalRefs ?? []) .flatMap((verticalRef) => { - const referencedVertical = fullStackVerticals.find( - (candidate) => candidate.id === verticalRef, - ); + const referencedVertical = fullStackVerticals.find((candidate) => candidate.id === verticalRef); return referencedVertical === undefined ? [] : [referencedVertical]; }) .map((referencedVertical) => referencedVertical.path), @@ -4490,35 +4260,47 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void ]), ].map((referencePath) => referenceFrom(vertical.path, referencePath)); assertSameJson( - verticalTsConfig.references ?? [], - expectedVerticalReferences, + EffectArray.sort( + verticalTsConfig.references ?? [], + Order.mapInput(Order.String, (entry: { readonly path: string }) => entry.path), + ), + EffectArray.sort( + expectedVerticalReferences, + Order.mapInput(Order.String, (entry: { readonly path: string }) => entry.path), + ), `${vertical.path}/tsconfig.json references`, 'restore the generated MicroVertical project-reference graph', ); assertSameJson( - verticalTsConfig.include ?? [], - expectedVerticalTypecheckIncludes(vertical, verticalPackage), + EffectArray.sort(verticalTsConfig.include ?? [], Order.String), + EffectArray.sort(expectedVerticalTypecheckIncludes(vertical, verticalPackage), Order.String), `${vertical.path}/tsconfig.json include`, 'restore the generated MicroVertical typecheck boundary', ); assertProjectReferenceEmitConfig(verticalTsConfig, vertical.path); + const requiredMfTypeIncludes = + vertical.emitsUi && vertical.exposes.length > 0 + ? [ + SHARED_VALIDATOR_STRING_136, + ...vertical.componentPaths.map((componentPath) => componentPath.replace(`${vertical.path}/`, '')), + ...(vertical.emitsApi ? [vertical.apiContractPath] : []), + SHARED_VALIDATOR_STRING_137, + ] + : [SHARED_VALIDATOR_STRING_137]; assertSameJson( - verticalMfTypesTsConfig, { + ...verticalMfTypesTsConfig, + // Migration retains consumer includes. Every exposed entry must remain + // in the DTS boundary, independently of ordering or additional inputs. + include: EffectArray.sort( + (verticalMfTypesTsConfig.include ?? []).filter((include) => requiredMfTypeIncludes.includes(include)), + Order.String, + ), + }, + { + compilerOptions: { skipLibCheck: true }, extends: SHARED_VALIDATOR_STRING_001, - // A headless (api-only) unit exposes no Module Federation surface, so - // its DTS boundary only covers the ambient env declarations (G2a). - include: - vertical.emitsUi && vertical.exposes.length > 0 - ? [ - SHARED_VALIDATOR_STRING_136, - ...vertical.componentPaths.map((componentPath) => - componentPath.replace(`${vertical.path}/`, ''), - ), - ...(vertical.emitsApi ? [vertical.apiContractPath] : []), - SHARED_VALIDATOR_STRING_137, - ] - : [SHARED_VALIDATOR_STRING_137], + include: EffectArray.sort(requiredMfTypeIncludes, Order.String), }, `${vertical.path}/tsconfig.mf-types.json`, 'restore the generated MicroVertical Module Federation DTS boundary', @@ -4570,18 +4352,24 @@ const assertTsConfigReferenceGraph = () => { 'restore the generated root project-reference graph', ); assertSameJson( - shellTsConfig.references ?? [], - expectedShellReferences, + EffectArray.sort( + shellTsConfig.references ?? [], + Order.mapInput(Order.String, (reference: { readonly path: string }) => reference.path), + ), + EffectArray.sort( + expectedShellReferences, + Order.mapInput(Order.String, (reference: { readonly path: string }) => reference.path), + ), 'apps/shell-super-app/tsconfig.json references', 'restore the generated shell project-reference graph', ); - assert( - baseTsConfig.compilerOptions?.skipLibCheck !== true, - 'tsconfig.base.json must not use skipLibCheck', - ); + assert(baseTsConfig.compilerOptions?.skipLibCheck !== true, 'tsconfig.base.json must not use skipLibCheck'); assertSameJson( - shellTsConfig.include ?? [], - ['api', 'src', SHARED_VALIDATOR_STRING_075, SHARED_VALIDATOR_STRING_091, 'shared'], + EffectArray.sort(shellTsConfig.include ?? [], Order.String), + EffectArray.sort( + ['api', 'server', 'src', SHARED_VALIDATOR_STRING_075, SHARED_VALIDATOR_STRING_091, 'shared'], + Order.String, + ), 'apps/shell-super-app/tsconfig.json include', 'restore the generated shell typecheck boundary', ); @@ -4589,6 +4377,7 @@ const assertTsConfigReferenceGraph = () => { assertSameJson( shellMfTypesTsConfig, { + compilerOptions: { skipLibCheck: true }, extends: SHARED_VALIDATOR_STRING_001, include: [SHARED_VALIDATOR_STRING_137], }, @@ -4656,14 +4445,13 @@ const observeModernPackageDependencies = ( for (const packageName of modernDependencyNames(packageJson)) { observedModernPackageNames.add(packageName); if (!modernPackageNameSet.has(packageName)) { - const expected = valueForKey(Object.entries(standaloneModernTools), packageName); - assert( - expected !== undefined, - `${relativePath} declares ${packageName} outside package source metadata`, - ); - const declared = packageDependencySections.map( - (section) => packageJson[section]?.[packageName], - ); + const releasePackage = expectedReleaseCohort?.packages.find((entry) => entry.sourceName === packageName); + const expected = + releasePackage === undefined + ? valueForKey(Object.entries(standaloneModernTools), packageName) + : expectedModernPackageSpecifier(packageName); + assert(expected !== undefined, `${relativePath} declares ${packageName} outside package source metadata`); + const declared = packageDependencySections.map((section) => packageJson[section]?.[packageName]); assert( declared.every((specifier) => specifier === undefined || specifier === expected), `${relativePath} ${packageName} must match standalone package source metadata`, @@ -4702,10 +4490,7 @@ const assertModernPackageCohort = () => { for (const packageJsonPath of packageJsonFiles(root)) { const relativePath = path.relative(root, packageJsonPath).split(path.sep).join('/'); const packageJson = readJson(PackageJsonSchema, relativePath); - if ( - isString(packageJson.modernjs?.appId) && - !additionalShellAppIds.has(packageJson.modernjs.appId) - ) { + if (isString(packageJson.modernjs?.appId) && !additionalShellAppIds.has(packageJson.modernjs.appId)) { observedAppIds.push(packageJson.modernjs.appId); } observeModernPackageDependencies(packageJson, relativePath, observedModernPackageNames); @@ -4713,16 +4498,10 @@ const assertModernPackageCohort = () => { } for (const packageName of modernPackageNames) { - assert( - observedModernPackageNames.has(packageName), - `Modern package cohort is missing ${packageName}`, - ); + assert(observedModernPackageNames.has(packageName), `Modern package cohort is missing ${packageName}`); } for (const packageName of Object.keys(standaloneModernTools)) { - assert( - observedModernPackageNames.has(packageName), - `Standalone Modern tool metadata is missing ${packageName}`, - ); + assert(observedModernPackageNames.has(packageName), `Standalone Modern tool metadata is missing ${packageName}`); } assertUniqueStrings(observedAppIds, 'generated app package manifests'); // The app-id cohort is a SET: observedAppIds is discovered by a @@ -4732,17 +4511,12 @@ const assertModernPackageCohort = () => { // workspace whose insertion order differs from filesystem order still passes. assertSameJson( sortedCopy(observedAppIds, (left, right) => left.localeCompare(right)), - sortedCopy(workspaceValidationContract.cohort.appIds, (left, right) => - left.localeCompare(right), - ), + sortedCopy(workspaceValidationContract.cohort.appIds, (left, right) => left.localeCompare(right)), 'generated app package manifest cohort', 'restore every generated app package manifest', ); }; -const assertPublicSurfaceAssets = ( - appPath: string, - publicRoutes: ReturnType, -): void => { +const assertPublicSurfaceAssets = (appPath: string, publicRoutes: ReturnType): void => { for (const relativePath of publicSurfaceManagedSourceAssetPaths) { assertNotExists(`${appPath}/${relativePath}`); } @@ -4764,22 +4538,13 @@ const assertPublicSurfaceContract = ( publicSurface.generator === SHARED_VALIDATOR_STRING_117, `${appId} public surface generator script is incorrect`, ); - assert( - publicSurface.outputRoot === 'dist/public', - `${appId} public surface dist outputRoot is incorrect`, - ); + assert(publicSurface.outputRoot === 'dist/public', `${appId} public surface dist outputRoot is incorrect`); assert( publicSurface.cloudflareBuildOutputRoot === 'dist-cloudflare/public', `${appId} public surface Cloudflare build outputRoot is incorrect`, ); - assert( - !('cloudflareOutputRoot' in publicSurface), - `${appId} public surface must not target final .output directly`, - ); - assert( - !('staticRoot' in publicSurface), - `${appId} public surface must not point at source config/public`, - ); + assert(!('cloudflareOutputRoot' in publicSurface), `${appId} public surface must not target final .output directly`); + assert(!('staticRoot' in publicSurface), `${appId} public surface must not point at source config/public`); assert( publicSurface.files.includes(SHARED_VALIDATOR_STRING_113), `${appId} public surface must always emit robots.txt`, @@ -4800,10 +4565,7 @@ const assertPublicSurfaceContract = ( publicSurface.contentExpansion.indexablePolicy === 'omit-indexable-false', `${appId} public content expansion indexable policy is incorrect`, ); - assert( - Array.isArray(publicSurface.contentSources), - `${appId} public content sources must be an array`, - ); + assert(Array.isArray(publicSurface.contentSources), `${appId} public content sources must be an array`); if (publicSurface.publicRoutes.length === 0) { assert( !publicSurface.files.includes(SHARED_VALIDATOR_STRING_135), @@ -4834,36 +4596,21 @@ const assertPublicHeadContract = ( assert(false, `${appId} public head generator is incorrect`); return; } - assert( - publicHead.generator === './src/routes/ultramodern-route-head', - `${appId} public head generator is incorrect`, - ); - assert( - publicHead.renderer === '@modern-js/runtime/head Helmet', - `${appId} public head renderer is incorrect`, - ); + assert(publicHead.generator === './src/routes/ultramodern-route-head', `${appId} public head generator is incorrect`); + assert(publicHead.renderer === '@modern-js/runtime/head Helmet', `${appId} public head renderer is incorrect`); assert(publicHead.ssr, `${appId} public head must be SSR-rendered`); - assert( - publicHead.title.source === 'route.titleKey', - `${appId} public head title must come from route metadata`, - ); + assert(publicHead.title.source === 'route.titleKey', `${appId} public head title must come from route metadata`); assert( publicHead.description.source === 'route.descriptionKey', `${appId} public head description must come from route metadata`, ); - assert( - publicHead.canonical.publicIndexableOnly, - `${appId} canonical links must be public/indexable only`, - ); + assert(publicHead.canonical.publicIndexableOnly, `${appId} canonical links must be public/indexable only`); assert(publicHead.structuredData.optional, `${appId} structured data must be optional`); assert( publicHead.structuredData.source === 'route.jsonLd', `${appId} structured data must come from explicit route metadata`, ); - assert( - !publicHead.structuredData.inference, - `${appId} structured data inference must stay disabled`, - ); + assert(!publicHead.structuredData.inference, `${appId} structured data inference must stay disabled`); assert( publicHead.structuredData.sanitizesHtmlOpenBracket, `${appId} structured data must sanitize HTML open brackets`, @@ -4883,10 +4630,7 @@ const assertPublicHeadContract = ( assert(headModule.includes(snippet), `${appId} private API head is missing ${snippet}`); } for (const snippet of ['rel="canonical"', 'rel="alternate"', 'application/ld+json']) { - assert( - !headModule.includes(snippet), - `${appId} must not publish ${snippet} without an owner-rendered route`, - ); + assert(!headModule.includes(snippet), `${appId} must not publish ${snippet} without an owner-rendered route`); } return; } @@ -4906,10 +4650,13 @@ const assertPublicHeadContract = ( 'name="twitter:card"', 'application/ld+json', 'route?.jsonLd', - "replaceAll('<', String.raw`\\u003c`)", ]) { assert(headModule.includes(snippet), `${appId} route head module is missing ${snippet}`); } + assert( + /replaceAll\(\s*'<',\s*String\.raw`\\u003c`\s*,?\s*\)/u.test(headModule), + `${appId} route head module must escape HTML opening brackets in JSON-LD`, + ); }; const assertCloudflareQualityGates = ( appId: string, @@ -4927,38 +4674,23 @@ const assertCloudflareQualityGates = ( qualityGates.publicRoutes.requireRobotsSitemapConsistency, `${appId} quality gates must require robots/sitemap consistency`, ); - assert( - qualityGates.statusCodes.unknownRouteStatus === 404, - `${appId} quality gates must require 404 unknown routes`, - ); - assert( - qualityGates.indexing.previewNoindex, - `${appId} quality gates must require preview noindex`, - ); + assert(qualityGates.statusCodes.unknownRouteStatus === 404, `${appId} quality gates must require 404 unknown routes`); + assert(qualityGates.indexing.previewNoindex, `${appId} quality gates must require preview noindex`); assert( qualityGates.indexing.productionPublicRoutesIndexable, `${appId} quality gates must require production public routes to be indexable`, ); - assert( - qualityGates.assets.cssPreloadRequired, - `${appId} quality gates must require CSS preload evidence`, - ); + assert(qualityGates.assets.cssPreloadRequired, `${appId} quality gates must require CSS preload evidence`); assert( !qualityGates.assets.sourcemapsPubliclyReferenced, `${appId} quality gates must reject public sourcemap references`, ); - assert( - isNumber(qualityGates.budgets.ssrHtmlMaxBytes), - `${appId} quality gates must define SSR HTML byte budget`, - ); + assert(isNumber(qualityGates.budgets.ssrHtmlMaxBytes), `${appId} quality gates must define SSR HTML byte budget`); assert( isNumber(qualityGates.budgets.mfManifestMaxBytes), `${appId} quality gates must define MF manifest byte budget`, ); - assert( - qualityGates.csp.finalMode === SHARED_VALIDATOR_STRING_110, - `${appId} CSP final mode decision is missing`, - ); + assert(qualityGates.csp.finalMode === SHARED_VALIDATOR_STRING_110, `${appId} CSP final mode decision is missing`); }; const extractAssetPrefixExpression = (modernConfig: string): string => { const match = /const\s+assetPrefix\s*=\s*(?[\s\S]*?);/u.exec(modernConfig); @@ -4971,9 +4703,7 @@ const extractAssetPrefixExpression = (modernConfig: string): string => { const assertTargetIsolatedBuildArtifacts = (appId: string, modernConfig: string): void => { assert( modernConfig.includes("const buildTarget = cloudflareDeployEnabled ? 'cloudflare' : 'web';") && - modernConfig.includes( - "const buildOutputRoot = cloudflareDeployEnabled ? 'dist-cloudflare' : 'dist';", - ) && + modernConfig.includes("const buildOutputRoot = cloudflareDeployEnabled ? 'dist-cloudflare' : 'dist';") && modernConfig.includes( sourceFragment( 'const buildTempDirectory = `node_modules/.modern-', @@ -5039,19 +4769,14 @@ const stripYamlInlineComment = (value: string): string => { const normalizeYamlScalar = (value: string): string => { const trimmed = stripYamlInlineComment(value).trim(); if ( - ((trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) && trimmed.length >= 2 ) { return trimmed.slice(1, -1); } return trimmed; }; -const nextYamlBlockScalar = ( - lines: readonly string[], - startIndex: number, - parentIndent: number, -): string => { +const nextYamlBlockScalar = (lines: readonly string[], startIndex: number, parentIndent: number): string => { const nextLine = lines.slice(startIndex).find((line) => { const trimmed = line.trim(); return trimmed !== '' && !trimmed.startsWith('#'); @@ -5097,15 +4822,12 @@ const activeNodeVersion = process.versions.node; const minimumPnpmVersion = { major: 11, minor: 0, patch: 0 }; const minimumNodeVersion = { major: 26, minor: 0, patch: 0 }; const currentNodeVersion = parseSemver(activeNodeVersion); -const repositoryWorkflowPath = fs.existsSync( - path.join(root, '../.github/workflows/ultramodern-workspace-gates.yml'), -) +const repositoryWorkflowPath = fs.existsSync(path.join(root, '../.github/workflows/ultramodern-workspace-gates.yml')) ? '../.github/workflows/ultramodern-workspace-gates.yml' : '.github/workflows/ultramodern-workspace-gates.yml'; const assertActivePnpmVersion = (packageManagerUserAgent: string): void => { - const activePnpmVersion = /^pnpm\/(?\d+\.\d+\.\d+)/u.exec(packageManagerUserAgent) - ?.groups?.version; + const activePnpmVersion = /^pnpm\/(?\d+\.\d+\.\d+)/u.exec(packageManagerUserAgent)?.groups?.version; assert( isString(activePnpmVersion) && activePnpmVersion.length > 0, 'Validator must run through the workspace pnpm command', @@ -5128,7 +4850,6 @@ const requiredPaths = [ SHARED_VALIDATOR_STRING_103, `patches/@module-federation__modern-js-v3@${expectedModuleFederationVersion}.patch`, `patches/@module-federation__bridge-react@${expectedModuleFederationVersion}.patch`, - 'patches/effect-schema-sentinel.patch', 'tsconfig.json', 'tsconfig.base.json', 'oxlint.config.ts', @@ -5235,30 +4956,8 @@ assert( pnpmWorkspace.includes("'@vercel/nft@0.29.2': patches/@vercel__nft@0.29.2.patch"), 'pnpm-workspace.yaml must patch the deployment tracer for transient filesystem markers', ); -assert( - pnpmWorkspace.includes( - "'@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-app-tools@3.8.2-ultramodern.12.patch", - ), - 'pnpm-workspace.yaml must patch generated deploy entries for nested CommonJS defaults', -); -const modernAppToolsPatch = readText( - 'patches/@bleedingdev__modern-js-app-tools@3.8.2-ultramodern.12.patch', -); -assert( - modernAppToolsPatch.split( - sourceFragment( - 'typeof plugin_', - templatePlaceholderOpening, - "index}_ns.default?.default === 'function'", - ), - ).length - - 1 === - 3 && - modernAppToolsPatch.includes( - sourceFragment('plugin_', templatePlaceholderOpening, 'index}_ns.default.default'), - ), - 'Modern.js deploy entries must unwrap callable direct and nested plugin defaults in CJS and ESM generators', -); +// The published 3.9 cohort owns deploy-entry interop; no obsolete app-tools +// patch is required. Release output is exercised by the runtime script proofs. const vercelNftPatch = readText('patches/@vercel__nft@0.29.2.patch'); assert( vercelNftPatch.split('isBuildHostSystemPath').length - 1 >= 4 && @@ -5284,9 +4983,7 @@ const traceDeploymentSystemGlobs = Effect.gen(function* traceDeploymentSystemGlo const partyRegistryRequire = createRequire(path.join(root, SHARED_VALIDATOR_STRING_165)); const appToolsRequire = createRequire(partyRegistryRequire.resolve(SHARED_VALIDATOR_STRING_022)); const ndepeRequire = createRequire(appToolsRequire.resolve('ndepe')); - const { nodeFileTrace } = Result.getOrThrow( - Schema.decodeUnknownResult(NftModuleSchema)(ndepeRequire('@vercel/nft')), - ); + const { nodeFileTrace } = Result.getOrThrow(Schema.decodeUnknownResult(NftModuleSchema)(ndepeRequire('@vercel/nft'))); const tracerFixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ultramodern-system-glob-')); const tracerFixturePath = path.join(tracerFixtureDirectory, 'entry.cjs'); const tracerLogs: string[] = []; @@ -5347,14 +5044,21 @@ assert( ), 'pnpm-workspace.yaml must patch the generated Module Federation React bridge cohort', ); -assert( - [`'effect@${expectedEffectVersion}'`, `effect@${expectedEffectVersion}`].some((effectPatchKey) => - pnpmWorkspace.includes(`${effectPatchKey}: patches/effect-schema-sentinel.patch`), - ), - 'pnpm-workspace.yaml must patch the generated Effect declaration cohort', -); assertWorkspaceValidationContract(workspaceValidationContract); assertGeneratedSurfacePolicy(); +for (const appPath of [ + SHARED_VALIDATOR_STRING_047, + ...fullStackVerticals.filter((vertical) => vertical.emitsUi).map((vertical) => vertical.path), + ...(workspaceValidationContract.structuralShellPolicy?.shells ?? []) + .filter((shell) => shell.id !== SHARED_VALIDATOR_STRING_131) + .map((shell) => shell.packageDir), +]) { + const violation = moduleFederationBridgeViolation( + readText(`${appPath}/module-federation.config.ts`), + readJson(PackageJsonSchema, `${appPath}/package.json`), + ); + assert(violation === undefined, `${appPath}: ${violation}`); +} for (const oldRemotePath of oldRemotePaths) { assertNotExists(oldRemotePath); } @@ -5362,8 +5066,7 @@ for (const retiredMetadataPath of retiredMetadataPaths) { assertNotExists(retiredMetadataPath); } assertStructuredWorkspaceMetadata(); -const bridgeConfig = - ultramodernConfig?.bridge?.enabled === true ? ultramodernConfig.bridge : undefined; +const bridgeConfig = ultramodernConfig?.bridge?.enabled === true ? ultramodernConfig.bridge : undefined; const packageSource = createPackageSourceView(ultramodernConfig); const generatedContract = readGeneratedContractView(ultramodernConfig); @@ -5373,13 +5076,8 @@ assertTsConfigReferenceGraph(); assert(rootPackage.private, 'Root package must be private'); assert(isString(rootPackage.packageManager), 'Root must declare packageManager'); -const packageManagerPnpmVersionMatch = /^pnpm@(?\d+\.\d+\.\d+)$/u.exec( - rootPackage.packageManager, -); -assert( - packageManagerPnpmVersionMatch !== null, - 'Root packageManager must pin pnpm with a semver version', -); +const packageManagerPnpmVersionMatch = /^pnpm@(?\d+\.\d+\.\d+)$/u.exec(rootPackage.packageManager); +assert(packageManagerPnpmVersionMatch !== null, 'Root packageManager must pin pnpm with a semver version'); const packageManagerPnpmVersion = packageManagerPnpmVersionMatch?.groups?.version ?? ''; assert( compareSemver(parseSemver(packageManagerPnpmVersion), minimumPnpmVersion) >= 0, @@ -5391,14 +5089,8 @@ assert( generatedContract.node?.version === expectedNodeVersion, 'Generated contract must record the Node toolchain version', ); -assert( - generatedContract.node?.engineRange === '>=26', - 'Generated contract must record the Node engine range', -); -assert( - readText('.mise.toml').includes(`node = "${expectedNodeVersion}"`), - 'mise must pin the generated Node version', -); +assert(generatedContract.node?.engineRange === '>=26', 'Generated contract must record the Node engine range'); +assert(readText('.mise.toml').includes(`node = "${expectedNodeVersion}"`), 'mise must pin the generated Node version'); assert( readText('.mise.toml').includes(`pnpm = "${packageManagerPnpmVersion}"`), 'mise must pin the generated pnpm version', @@ -5455,9 +5147,7 @@ for (const [jobId, jobName] of [ } assert( workflowText.includes('docker compose up --detach --wait') && - workflowText.includes( - 'name: Remove the pre-seeded runtime role to prove deployment bootstrap ordering', - ) && + workflowText.includes('name: Remove the pre-seeded runtime role to prove deployment bootstrap ordering') && workflowText.includes('DROP ROLE ontos_runtime;') && workflowText.includes('mise exec -- pnpm db:migrate') && workflowText.includes('mise exec -- pnpm db:verify') && @@ -5477,24 +5167,16 @@ assert( workflowText.includes('mise exec -- pnpm node:proof') && workflowText.includes('mise exec -- pnpm cloudflare:build') && workflowText.includes('MODERN_PUBLIC_SITE_URL: https://shell-super-app.invalid') && - workflowText.includes( - 'ULTRAMODERN_PUBLIC_URL_PARTY_REGISTRY: https://party-registry.invalid', - ) && - workflowText.includes( - 'ULTRAMODERN_PUBLIC_URL_SHELL_SUPER_APP: https://shell-super-app.invalid', - ), + workflowText.includes('ULTRAMODERN_PUBLIC_URL_PARTY_REGISTRY: https://party-registry.invalid') && + workflowText.includes('ULTRAMODERN_PUBLIC_URL_SHELL_SUPER_APP: https://shell-super-app.invalid'), 'CI workflow must separately prove Node and Cloudflare/workerd runtime artifacts with explicit local proof URLs', ); assert( - workflowText.includes( - 'DATABASE_URL: postgresql://ontos_proof:ontos_proof@localhost:5432/ontos_proof', - ), + workflowText.includes('DATABASE_URL: postgresql://ontos_proof:ontos_proof@localhost:5432/ontos_proof'), 'CI Node artifact proof must provide a non-secret database URL so the readiness API layer can initialize without a service connection', ); assert( - rootPackage.scripts?.[SHARED_VALIDATOR_STRING_059]?.includes( - 'ULTRAMODERN_MF_TYPES_ARCHIVE=dist-cloudflare/@mf-types.zip pnpm mf:types', - ), + rootPackage.scripts?.[SHARED_VALIDATOR_STRING_059]?.includes('pnpm mf:types --target cloudflare'), 'Cloudflare builds must validate the Module Federation DTS archive from the Cloudflare output directory', ); assert( @@ -5505,9 +5187,7 @@ assert( 'Stage deployment must derive the exact-build authorization inventory, enforce the stage authorization gate, and summarize the topology-driven deployment impact planner', ); assert( - workflowText.includes( - 'needs: [workspace-gate, service-integration, node-runtime, cloudflare-runtime]', - ), + workflowText.includes('needs: [workspace-gate, service-integration, node-runtime, cloudflare-runtime]'), 'Stage deployment must depend on every fast, service-backed, Node, and Cloudflare required job', ); assert( @@ -5539,10 +5219,7 @@ assert( workflowText.includes('ZEROPS_SHELL_SERVICE_ID'), 'Topology Shell delivery unit must match the current Zerops setup and workflow service-variable convention', ); -assert( - rootPackage.modernjs?.preset === SHARED_VALIDATOR_STRING_104, - 'Root must declare presetUltramodern', -); +assert(rootPackage.modernjs?.preset === SHARED_VALIDATOR_STRING_104, 'Root must declare presetUltramodern'); assert( rootPackage.modernjs?.packageSource?.config === './.modernjs/ultramodern.json', 'Root must point at compact UltraModern config', @@ -5556,22 +5233,12 @@ assert( 'Package source strategy must be workspace or install', ); assert( - packageSource.strategy === 'install' || - packageSource.modernPackages?.specifier === SHARED_VALIDATOR_STRING_169, + packageSource.strategy === 'install' || packageSource.modernPackages?.specifier === SHARED_VALIDATOR_STRING_169, 'Workspace package source must be explicitly backed by workspace:*', ); assertModernPackageCohort(); -const isIdentifierChar = (character: string): boolean => - character !== '' && /[A-Za-z0-9_$]/u.test(character); -const SourceScannerState = Schema.Literals([ - 'block', - 'code', - 'double', - 'line', - 'regex', - 'single', - 'template', -]); +const isIdentifierChar = (character: string): boolean => character !== '' && /[A-Za-z0-9_$]/u.test(character); +const SourceScannerState = Schema.Literals(['block', 'code', 'double', 'line', 'regex', 'single', 'template']); type SourceScannerStateValue = typeof SourceScannerState.Type; interface SourceScanner { currentWord: string; @@ -5598,10 +5265,7 @@ const sourceRegexCanFollow = (scanner: SourceScanner): boolean => { if (scanner.regexPrecedingPunct.has(scanner.lastSignificant)) { return true; } - return ( - isIdentifierChar(scanner.lastSignificant) && - scanner.regexPrecedingKeywords.has(scanner.currentWord) - ); + return isIdentifierChar(scanner.lastSignificant) && scanner.regexPrecedingKeywords.has(scanner.currentWord); }; const scanSourceSlash = (scanner: SourceScanner, next: string): number | undefined => { if (next === '/') { @@ -5622,11 +5286,7 @@ const scanSourceSlash = (scanner: SourceScanner, next: string): number | undefin return undefined; }; const scanSourceInterpolation = (scanner: SourceScanner, character: string): boolean => { - if ( - character === '}' && - scanner.interpolations.length > 0 && - scanner.interpolations.at(-1) === 0 - ) { + if (character === '}' && scanner.interpolations.length > 0 && scanner.interpolations.at(-1) === 0) { scanner.interpolations.pop(); scanner.state = 'template'; scanner.result += character; @@ -5644,11 +5304,7 @@ const scanSourceInterpolation = (scanner: SourceScanner, character: string): boo } return false; }; -const scanSourceCodeCharacter = ( - scanner: SourceScanner, - character: string, - next: string, -): number => { +const scanSourceCodeCharacter = (scanner: SourceScanner, character: string, next: string): number => { if (character === '/') { const consumed = scanSourceSlash(scanner, next); if (consumed !== undefined) { @@ -5677,11 +5333,7 @@ const scanSourceLineComment = (scanner: SourceScanner, character: string): numbe } return 0; }; -const scanSourceBlockComment = ( - scanner: SourceScanner, - character: string, - next: string, -): number => { +const scanSourceBlockComment = (scanner: SourceScanner, character: string, next: string): number => { if (character === '*' && next === '/') { scanner.state = 'code'; return 1; @@ -5733,19 +5385,12 @@ const scanSourceTemplate = (scanner: SourceScanner, character: string, next: str scanner.result += character; return 0; }; -const scanSourceQuotedString = ( - scanner: SourceScanner, - character: string, - next: string, -): number => { +const scanSourceQuotedString = (scanner: SourceScanner, character: string, next: string): number => { if (character === '\\') { scanner.result += character + next; return 1; } - if ( - (scanner.state === 'single' && character === "'") || - (scanner.state === 'double' && character === '"') - ) { + if ((scanner.state === 'single' && character === "'") || (scanner.state === 'double' && character === '"')) { scanner.state = 'code'; scanner.lastSignificant = character; scanner.currentWord = ''; @@ -5763,11 +5408,8 @@ const sourceCharacterScanners = { single: scanSourceQuotedString, template: scanSourceTemplate, } satisfies Record; -const scanSourceCharacter: SourceCharacterScanner = ( - scanner: SourceScanner, - character: string, - next: string, -) => sourceCharacterScanners[scanner.state](scanner, character, next); +const scanSourceCharacter: SourceCharacterScanner = (scanner: SourceScanner, character: string, next: string) => + sourceCharacterScanners[scanner.state](scanner, character, next); const stripSourceComments = (code: string): string => { const scanner: SourceScanner = { currentWord: '', @@ -5789,25 +5431,7 @@ const stripSourceComments = (code: string): string => { 'await', 'case', ]), - regexPrecedingPunct: new Set([ - '(', - ',', - '=', - '[', - '{', - ';', - ':', - '!', - '&', - '|', - '?', - '+', - '-', - '*', - '%', - '^', - '~', - ]), + regexPrecedingPunct: new Set(['(', ',', '=', '[', '{', ';', ':', '!', '&', '|', '?', '+', '-', '*', '%', '^', '~']), result: '', state: 'code', }; @@ -5857,11 +5481,7 @@ const runtimeModuleSpecifiers = (source: string): string[] => { )) { const clause = match.groups?.clause; const specifier = match.groups?.specifier; - if ( - clause !== undefined && - specifier !== undefined && - !clause.trimStart().startsWith('type ') - ) { + if (clause !== undefined && specifier !== undefined && !clause.trimStart().startsWith('type ')) { specifiers.push(specifier); } } @@ -5878,18 +5498,14 @@ const remoteImplementationFor = ( const packageSubpath = specifier.startsWith(`${remote.packageName}/`) ? specifier.slice(remote.packageName.length + 1) : undefined; - if ( - specifier === remote.packageName || - (packageSubpath !== undefined && !packageSubpath.startsWith('api')) - ) { + if (specifier === remote.packageName || (packageSubpath !== undefined && !packageSubpath.startsWith('api'))) { return true; } const normalizedSpecifier = specifier.replaceAll('\\', '/'); return normalizedSpecifier.includes(`${remote.directory}/`); }); type StructuralShellPolicy = typeof workspaceValidationContractDefinition.structuralShellPolicy; -type FederatedCompositionSourcePolicy = - typeof workspaceValidationContractDefinition.federatedCompositionSourcePolicy; +type FederatedCompositionSourcePolicy = typeof workspaceValidationContractDefinition.federatedCompositionSourcePolicy; const assertThinShellPolicy = (policy: StructuralShellPolicy): void => { for (const shell of policy.shells) { for (const forbidden of policy.forbiddenPathClasses) { @@ -5980,10 +5596,7 @@ const assertStructuralShellPolicy = (): void => { }; assertStructuralShellPolicy(); const assertConfiguredDevelopmentPorts = (): void => { - const primaryShellConfig = findById( - ultramodernConfig.topology?.apps, - SHARED_VALIDATOR_STRING_131, - ); + const primaryShellConfig = findById(ultramodernConfig.topology?.apps, SHARED_VALIDATOR_STRING_131); const overlayPorts = overlay.ports ?? {}; const configuredPorts = [ ...Object.entries(overlayPorts).map(([id, port]) => ({ id, port })), @@ -5999,18 +5612,12 @@ const assertConfiguredDevelopmentPorts = (): void => { } assert(Number.isFinite(port), `Configured development port for ${id} must be finite`); const previous = portsByValue.get(port); - assert( - previous === undefined, - `Duplicate configured development port ${port} for ${previous} and ${id}`, - ); + assert(previous === undefined, `Duplicate configured development port ${port} for ${previous} and ${id}`); portsByValue.set(port, id); } }; assertConfiguredDevelopmentPorts(); -assert( - ultramodernConfig.shells === undefined, - 'Single-shell workspace must not declare config.shells', -); +assert(ultramodernConfig.shells === undefined, 'Single-shell workspace must not declare config.shells'); assert( rootPackage.devDependencies?.[SHARED_VALIDATOR_STRING_024] === expectedModernPackageSpecifier(SHARED_VALIDATOR_STRING_024), @@ -6056,18 +5663,12 @@ assert( packageSource.generatedWorkspacePackages?.specifier === SHARED_VALIDATOR_STRING_169, 'Generated workspace packages must keep workspace:* links', ); -assert( - rootPackage.scripts?.build === expectedBuildScript, - 'Root build script must build verticals before shell', -); +assert(rootPackage.scripts?.build === expectedBuildScript, 'Root build script must build verticals before shell'); assert( rootPackage.scripts?.[SHARED_VALIDATOR_STRING_059] === expectedCloudflareBuildScript, 'Root cloudflare:build script is incorrect', ); -assert( - !('ultramodern:check' in (rootPackage.scripts ?? {})), - 'Root must not expose ultramodern:check', -); +assert(!('ultramodern:check' in (rootPackage.scripts ?? {})), 'Root must not expose ultramodern:check'); if (bridgeConfig === undefined) { assert( rootPackage.scripts?.typecheck === SHARED_VALIDATOR_STRING_086, @@ -6079,10 +5680,7 @@ if (bridgeConfig === undefined) { 'pnpm -r --filter "./apps/*" --filter "./verticals/*" --filter "./packages/*" run typecheck', 'Bridge root typecheck must check generated package boundaries without building parent implementation sources', ); - assert( - Array.isArray(bridgeConfig.workspacePackages), - 'Bridge config must record workspace package patterns', - ); + assert(Array.isArray(bridgeConfig.workspacePackages), 'Bridge config must record workspace package patterns'); for (const workspacePackage of bridgeConfig.workspacePackages) { assert( rootPackage.workspaces?.includes(workspacePackage.pattern), @@ -6094,12 +5692,10 @@ if (bridgeConfig === undefined) { ); } for (const gate of bridgeConfig.gates ?? []) { - const workingDirectoryPrefix = - isString(gate.cwd) && gate.cwd.length > 0 ? `cd ${gate.cwd} && ` : ''; + const workingDirectoryPrefix = isString(gate.cwd) && gate.cwd.length > 0 ? `cd ${gate.cwd} && ` : ''; const expectedGateScript = `${workingDirectoryPrefix}${gate.command}`; assert( - valueForKey(Object.entries(rootPackage.scripts ?? {}), `bridge:${gate.name}`) === - expectedGateScript, + valueForKey(Object.entries(rootPackage.scripts ?? {}), `bridge:${gate.name}`) === expectedGateScript, `Bridge gate script bridge:${gate.name} is incorrect`, ); } @@ -6108,13 +5704,8 @@ if (bridgeConfig === undefined) { 'Bridge workspaces must expose bridge:check', ); } -assert( - rootPackage.scripts?.['contract:check'] === SHARED_VALIDATOR_STRING_087, - 'Root must expose contract:check', -); -for (const [scriptName, expectedCommand] of Object.entries( - workspaceValidationContract.ciEvidenceScripts, -)) { +assert(rootPackage.scripts?.['contract:check'] === SHARED_VALIDATOR_STRING_087, 'Root must expose contract:check'); +for (const [scriptName, expectedCommand] of Object.entries(workspaceValidationContract.ciEvidenceScripts)) { assert( valueForKey(Object.entries(rootPackage.scripts ?? {}), scriptName) === expectedCommand, `Root CI evidence command ${scriptName} is missing or incorrect`, @@ -6130,8 +5721,7 @@ assert( 'Core runtime must expose its complete service-backed integration test surface', ); assert( - rootPackage.scripts?.['module-entrypoints:check'] === - 'node ./scripts/check-module-entrypoint-boundaries.mts', + rootPackage.scripts?.['module-entrypoints:check'] === 'node ./scripts/check-module-entrypoint-boundaries.mts', 'Root must expose module-entrypoints:check', ); assert( @@ -6140,37 +5730,25 @@ assert( 'Root must expose the Codesmith MicroVertical Action-boundary command', ); assert( - rootPackage.scripts?.['scaffold:outbox-worker'] === - 'node ./scripts/scaffolding/cli.mts outbox-worker', + rootPackage.scripts?.['scaffold:outbox-worker'] === 'node ./scripts/scaffolding/cli.mts outbox-worker', 'Root must expose the Codesmith Outbox Worker command', ); -assert( - shellPackage.dependencies?.jose === '6.2.5', - 'Shell must own the exact EdDSA signing dependency', -); +assert(shellPackage.dependencies?.jose === '6.2.5', 'Shell must own the exact EdDSA signing dependency'); const sharedContractsPackage = readJson(PackageJsonSchema, SHARED_VALIDATOR_STRING_095); assert( sharedContractsPackage.dependencies?.effect === expectedEffectVersion && - sharedContractsPackage.dependencies?.[SHARED_VALIDATOR_STRING_017] === - SHARED_VALIDATOR_STRING_169, + sharedContractsPackage.dependencies?.[SHARED_VALIDATOR_STRING_017] === SHARED_VALIDATOR_STRING_169, 'Shared gateway contracts must use the generated Effect cohort and canonical Core context', ); -const gatewayPrincipalVerifierPackage = readJson( - PackageJsonSchema, - `${SHARED_VALIDATOR_STRING_177}/package.json`, -); -const gatewayPrincipalVerifierSource = readText( - 'packages/gateway-principal-verifier/src/server.ts', -); +const gatewayPrincipalVerifierPackage = readJson(PackageJsonSchema, `${SHARED_VALIDATOR_STRING_177}/package.json`); +const gatewayPrincipalVerifierSource = readText('packages/gateway-principal-verifier/src/server.ts'); assert( sameJson(gatewayPrincipalVerifierPackage.exports, { './server': './src/server.ts', }) && gatewayPrincipalVerifierPackage.dependencies?.jose === '6.2.5' && - gatewayPrincipalVerifierPackage.dependencies?.[SHARED_VALIDATOR_STRING_017] === - SHARED_VALIDATOR_STRING_169 && - gatewayPrincipalVerifierPackage.dependencies?.[SHARED_VALIDATOR_STRING_019] === - SHARED_VALIDATOR_STRING_169 && + gatewayPrincipalVerifierPackage.dependencies?.[SHARED_VALIDATOR_STRING_017] === SHARED_VALIDATOR_STRING_169 && + gatewayPrincipalVerifierPackage.dependencies?.[SHARED_VALIDATOR_STRING_019] === SHARED_VALIDATOR_STRING_169 && gatewayPrincipalVerifierSource.includes('bindGatewayPrincipalVerifier') && gatewayPrincipalVerifierSource.includes("algorithms: ['EdDSA']") && gatewayPrincipalVerifierSource.includes('decodeGatewayContextClaims') && @@ -6190,22 +5768,16 @@ assert( problemDetailsContractSource.includes("contentType: 'application/problem+json'"), 'Shared contracts must retain the versioned generic EdDSA gateway assertion protocol', ); -const installedVerticalSource = readText( - 'apps/shell-super-app/api/verticals/installed-verticals.ts', -); +const installedVerticalSource = readText('apps/shell-super-app/api/verticals/installed-verticals.ts'); const shellModernConfigSource = readText(SHARED_VALIDATOR_STRING_048); assert( - shellModernConfigSource.includes( - "new URL('../../topology/reference-topology.json', import.meta.url)", - ) && + shellModernConfigSource.includes("new URL('../../topology/reference-topology.json', import.meta.url)") && shellModernConfigSource.includes("readFileSync(referenceTopologyPath, 'utf-8')") && shellModernConfigSource.includes( 'Object.assign(globalThis, {\n ULTRAMODERN_GATEWAY_AUDIENCE_TOPOLOGY: referenceTopology', ) && shellModernConfigSource.includes('ULTRAMODERN_GATEWAY_AUDIENCE_TOPOLOGY: referenceTopology') && - installedVerticalSource.includes( - 'deriveInstalledVerticalIds(ULTRAMODERN_GATEWAY_AUDIENCE_TOPOLOGY)', - ) && + installedVerticalSource.includes('deriveInstalledVerticalIds(ULTRAMODERN_GATEWAY_AUDIENCE_TOPOLOGY)') && installedVerticalSource.includes("kind: Schema.Literal('vertical')"), 'Shell installed verticals must derive exclusively from authoritative topology verticals', ); @@ -6219,16 +5791,14 @@ assert( 'Root must expose api:check', ); assert( - rootPackage.scripts?.['i18n:boundaries'] === - 'node ./scripts/check-ultramodern-i18n-boundaries.mts', + rootPackage.scripts?.['i18n:boundaries'] === 'node ./scripts/check-ultramodern-i18n-boundaries.mts', 'Root must expose i18n:boundaries', ); assert( rootPackage.scripts?.['performance:readiness'] === SHARED_VALIDATOR_STRING_085, 'Root must expose default-on performance readiness diagnostics', ); -const actionAuthorizationProvisioningCommand = - 'node ./scripts/provision-current-action-authorization.mts'; +const actionAuthorizationProvisioningCommand = 'node ./scripts/provision-current-action-authorization.mts'; assert( rootPackage.scripts?.[SHARED_VALIDATOR_STRING_053] === actionAuthorizationProvisioningCommand, 'Root must expose the explicit current-Action authorization provisioning command', @@ -6239,23 +5809,14 @@ assert( !readText('scripts/initialize-local-development.mts').includes(SHARED_VALIDATOR_STRING_053), 'Ordinary local initialization must not provision Action authorization', ); -for (const startupPath of [ - 'scripts/locki-feature.sh', - 'docker-compose.yml', - 'scripts/run-zerops-spicedb.sh', -]) { +for (const startupPath of ['scripts/locki-feature.sh', 'docker-compose.yml', 'scripts/run-zerops-spicedb.sh']) { assert( !readText(startupPath).includes(SHARED_VALIDATOR_STRING_106) && !readText(startupPath).includes(SHARED_VALIDATOR_STRING_053), `${startupPath} must not provision Action authorization automatically`, ); } -for (const automaticScript of [ - 'dev', - 'build', - SHARED_VALIDATOR_STRING_059, - SHARED_VALIDATOR_STRING_060, -]) { +for (const automaticScript of ['dev', 'build', SHARED_VALIDATOR_STRING_059, SHARED_VALIDATOR_STRING_060]) { const automaticCommand = valueForKey(Object.entries(rootPackage.scripts ?? {}), automaticScript); assert( automaticCommand?.includes(SHARED_VALIDATOR_STRING_053) !== true && @@ -6265,8 +5826,7 @@ for (const automaticScript of [ } if (hasBackendSurfaces) { assert( - rootPackage.scripts?.['node:backend-federation:generate'] === - 'node ./scripts/generate-node-backend-federation.mts', + rootPackage.scripts?.['node:backend-federation:generate'] === 'node ./scripts/generate-node-backend-federation.mts', 'Root must expose local Node backend federation artifact generation', ); assert( @@ -6278,10 +5838,7 @@ if (hasBackendSurfaces) { rootPackage.scripts?.['node:backend-federation:generate'] === undefined, 'Root must not expose backend federation generation without an API surface', ); - assert( - rootPackage.scripts?.['node:proof'] === undefined, - 'Root must not expose node:proof without an API surface', - ); + assert(rootPackage.scripts?.['node:proof'] === undefined, 'Root must not expose node:proof without an API surface'); } if (hasDeliveryUnits) { assert( @@ -6314,10 +5871,12 @@ assert( rootPackage.scripts?.check?.includes(SHARED_VALIDATOR_STRING_101) && rootPackage.scripts.check.includes(SHARED_VALIDATOR_STRING_102) && !rootPackage.scripts.check.includes('pnpm node:proof') && - rootPackage.scripts.check.endsWith( - bridgeConfig - ? '&& pnpm performance:readiness && pnpm bridge:check && pnpm quality:check' - : '&& pnpm performance:readiness && pnpm quality:check', + ['pnpm performance:readiness', 'pnpm quality:check', ...(bridgeConfig ? ['pnpm bridge:check'] : [])].every( + (command) => + rootPackage.scripts?.check + ?.split('&&') + .map((part) => part.trim()) + .includes(command), ), 'Root check must remain static while running default-on performance readiness diagnostics and bridge gates when configured', ); @@ -6348,8 +5907,7 @@ if (hasDeliveryUnits) { }).length; assert(zeropsYaml.includes('zerops:'), 'Zerops manifest must include zerops services'); assert( - !zeropsYaml.includes(SHARED_VALIDATOR_STRING_106) && - !zeropsYaml.includes(SHARED_VALIDATOR_STRING_053), + !zeropsYaml.includes(SHARED_VALIDATOR_STRING_106) && !zeropsYaml.includes(SHARED_VALIDATOR_STRING_053), 'Zerops startup and deployment must not provision Action authorization automatically', ); assert( @@ -6369,15 +5927,12 @@ if (hasDeliveryUnits) { ); assert( zeropsYaml.includes(`base: ${quoteYamlString('nodejs@24')}`) && - zeropsYaml.includes( - `initCommands:\n - ZEROPS_NODE_ROOT=/var/www sh app/scripts/install-zerops-node.sh`, - ), + zeropsYaml.includes(`initCommands:\n - ZEROPS_NODE_ROOT=/var/www sh app/scripts/install-zerops-node.sh`), 'Zerops Node services must install pinned Node during container initialization without a custom runtime image', ); const installZeropsBuildToolchain = `sh /build/source/app/scripts/install-zerops-node.sh --with-pnpm ${packageManagerPnpmVersion}`; assert( - zeropsYaml.split(installZeropsBuildToolchain).length - 1 === - fullStackVerticals.length + 2 + workerDeliveryCount, + zeropsYaml.split(installZeropsBuildToolchain).length - 1 === fullStackVerticals.length + 2 + workerDeliveryCount, 'Every Zerops Node build must install the packageManager-pinned pnpm version in its cache key', ); assert( @@ -6397,63 +5952,48 @@ if (hasDeliveryUnits) { ), ) && !zeropsYaml.includes( - sourceFragment( - 'DATABASE_ADMIN_URL: ', - templatePlaceholderOpening, - 'db18_connectionString}', - ), + sourceFragment('DATABASE_ADMIN_URL: ', templatePlaceholderOpening, 'db18_connectionString}'), ), 'Zerops migrator must use the PostgreSQL administrative identity for role and database bootstrap', ); - assert( - zeropsYaml.includes('deployFiles:'), - 'Zerops manifest must deploy package-pruned runtime directories', - ); + assert(zeropsYaml.includes('deployFiles:'), 'Zerops manifest must deploy package-pruned runtime directories'); const migratorSetup = yamlListItemBlock(zeropsYaml, 'setup', 'migrator'); assert( migratorSetup.includes("- 'app/tsconfig.base.json'"), 'Zerops migrator must deploy the root TypeScript config extended by migration packages', ); const localVirtualStoreInstall = - 'PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm'; + 'PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm'; assert( - zeropsYaml.split(localVirtualStoreInstall).length - 1 === - fullStackVerticals.length + 2 + workerDeliveryCount, + zeropsYaml.split(localVirtualStoreInstall).length - 1 === fullStackVerticals.length + 2 + workerDeliveryCount, 'Every Zerops Node build must install dependencies into a project-local virtual store', ); const cleanWorkspaceDependencies = 'node scripts/reset-workspace-dependencies.mjs'; assert( - zeropsYaml.split(cleanWorkspaceDependencies).length - 1 === - fullStackVerticals.length + 2 + workerDeliveryCount, + zeropsYaml.split(cleanWorkspaceDependencies).length - 1 === fullStackVerticals.length + 2 + workerDeliveryCount, 'Every Zerops Node build must remove cached dependency links before installing', ); - const expectedZeropsPnpmCommands = - 1 + 3 * (fullStackVerticals.length + 1) + 2 * workerDeliveryCount; + const expectedZeropsPnpmCommands = 1 + 3 * (fullStackVerticals.length + 1) + 2 * workerDeliveryCount; assert( - zeropsYaml.split('--config.enable-global-virtual-store=false').length - 1 === - expectedZeropsPnpmCommands, + zeropsYaml.split('--config.enable-global-virtual-store=false').length - 1 === expectedZeropsPnpmCommands, 'Every Zerops pnpm command must override higher-priority host global-virtual-store configuration', ); assert( - zeropsYaml.split('PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false').length - 1 === - expectedZeropsPnpmCommands, + zeropsYaml.split('PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false').length - 1 === expectedZeropsPnpmCommands, 'Every Zerops pnpm command must propagate local virtual-store configuration to child processes', ); assert( - zeropsYaml.split('NODE_OPTIONS=--max-old-space-size=4096').length - 1 === - fullStackVerticals.length + 1, + zeropsYaml.split('NODE_OPTIONS=--max-old-space-size=4096').length - 1 === fullStackVerticals.length + 1, 'Every Modern.js Zerops deployment build must reserve enough Node.js heap for dependency tracing', ); assert( zeropsYaml.includes( - `start: sh -c ${quoteYamlString('cd app/.zerops/runtime/shell-super-app && PATH="/var/www/.local/node-26.5.0/bin:$PATH" exec npm run serve')}`, + `start: sh -c ${quoteYamlString('cd app/.zerops/runtime/shell-super-app && PATH="/var/www/.local/node-26.7.0/bin:$PATH" exec npm run serve')}`, ), 'Zerops shell service must start from materialized runtime package', ); const allDeclaredPortsPresent = [ - workspaceValidationContract.topology.compactConfig.apps.find( - (app) => app.id === SHARED_VALIDATOR_STRING_131, - ), + workspaceValidationContract.topology.compactConfig.apps.find((app) => app.id === SHARED_VALIDATOR_STRING_131), ...fullStackVerticals, ].every((app) => app !== undefined && zeropsYaml.includes(`PORT: '${app.port}'`)); assert( @@ -6476,16 +6016,13 @@ if (hasDeliveryUnits) { 'Zerops SpiceDB deploys must avoid overlapping database connection pools', ); assert( - !zeropsMigrator.includes("run('pnpm'") && - zeropsMigrator.includes("'node_modules', '.bin', 'drizzle-kit'"), + !zeropsMigrator.includes("run('pnpm'") && zeropsMigrator.includes("'node_modules', '.bin', 'drizzle-kit'"), 'Zerops migrator must execute the relocated dependency tree without invoking pnpm runtime verification', ); - const runtimeRoleBootstrapCall = - "yield* runAppScript('scripts/postgres/bootstrap-runtime-role.mts');"; + const runtimeRoleBootstrapCall = "yield* runAppScript('scripts/postgres/bootstrap-runtime-role.mts');"; assert( zeropsMigrator.indexOf(runtimeRoleBootstrapCall) < zeropsMigrator.indexOf('yield* migrate(') && - zeropsMigrator.lastIndexOf(runtimeRoleBootstrapCall) > - zeropsMigrator.lastIndexOf('yield* migrate('), + zeropsMigrator.lastIndexOf(runtimeRoleBootstrapCall) > zeropsMigrator.lastIndexOf('yield* migrate('), 'Zerops migrator must provision the runtime role before RLS migrations and refresh grants afterward', ); for (const migrationPackagePath of [ @@ -6499,10 +6036,7 @@ if (hasDeliveryUnits) { migrationScript.matchAll(/drizzle-kit migrate --config (?[^\s&]+)/gu), (match) => match.groups?.migrationConfig, ).filter((config): config is string => config !== undefined); - assert( - migrationConfigs.length > 0, - `${migrationPackagePath} must declare at least one Drizzle migration config`, - ); + assert(migrationConfigs.length > 0, `${migrationPackagePath} must declare at least one Drizzle migration config`); let previousMigrationIndex = -1; for (const migrationConfig of migrationConfigs) { const migrationCall = `yield* migrate(${quoteYamlString(migrationPackagePath)}, ${quoteYamlString(migrationConfig)});`; @@ -6515,22 +6049,17 @@ if (hasDeliveryUnits) { } } for (const vertical of fullStackVerticals) { - assert( - zeropsYaml.includes(`setup: ${quoteYamlString(vertical.id)}`), - `${vertical.id} must have a Zerops service`, - ); + assert(zeropsYaml.includes(`setup: ${quoteYamlString(vertical.id)}`), `${vertical.id} must have a Zerops service`); assert( zeropsYaml.includes( - `PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app ${quoteShellValue(vertical.id)} --package ${quoteShellValue(vertical.packageName)} --package-dir ${quoteShellValue(vertical.path)}`, + `PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app ${quoteShellValue(vertical.id)} --package ${quoteShellValue(vertical.packageName)} --package-dir ${quoteShellValue(vertical.path)}`, ), `${vertical.id} Zerops service must materialize its runtime package`, ); // Only REST-backed (or headless-less UI) units expose an HTTP readiness // probe; the RPC surface has no REST readiness endpoint (G7a). if (!vertical.emitsApi || vertical.apiProtocol === 'rest') { - const zeropsReadinessPath = vertical.emitsApi - ? `${vertical.apiPrefix}/${vertical.stem}/readiness` - : '/'; + const zeropsReadinessPath = vertical.emitsApi ? `${vertical.apiPrefix}/${vertical.stem}/readiness` : '/'; assert( zeropsYaml.includes(`path: ${quoteYamlString(zeropsReadinessPath)}`), `${vertical.id} BFF readiness path must use generated API prefix`, @@ -6544,9 +6073,7 @@ if (hasDeliveryUnits) { ]) { const deliveryUnitPackage = readJson(PackageJsonSchema, `${deliveryUnitPath}/package.json`); assert( - deliveryUnitPackage.scripts?.build?.includes( - 'MODERNJS_DEPLOY=node modern deploy --skip-build', - ) ?? false, + deliveryUnitPackage.scripts?.build?.includes('MODERNJS_DEPLOY=node modern deploy --skip-build') ?? false, `${deliveryUnitPath} build must produce the Modern.js Node output before Zerops materialization`, ); } @@ -6601,8 +6128,7 @@ const assertToolWrapper = (scriptPath: string, command: string): void => { const source = readText(scriptPath); assert( hasUltramodernDispatch(source, command, readText('scripts/shared/ultramodern-command.mts')) || - (command === 'skills' && - hasUltramodernSkillsDispatch(source, readText('scripts/shared/ultramodern-launch.mts'))), + (command === 'skills' && hasUltramodernSkillsDispatch(source, readText('scripts/shared/ultramodern-launch.mts'))), `${scriptPath} must delegate ${command} through the override-aware UltraModern runner`, ); }; @@ -6610,10 +6136,7 @@ assert( performanceReadinessConfig.includes('UltramodernPerformanceReadinessDiagnosticsConfig'), 'Performance readiness config must carry the typed opt-out surface', ); -assert( - performanceReadinessConfig.includes('enabled: true'), - 'Performance readiness diagnostics must be default-on', -); +assert(performanceReadinessConfig.includes('enabled: true'), 'Performance readiness diagnostics must be default-on'); assert( performanceReadinessConfig.includes("failOn: 'framework-invariant'"), 'Performance readiness diagnostics must only fail framework invariants by default', @@ -6622,14 +6145,10 @@ assertToolWrapper(SHARED_VALIDATOR_STRING_122, 'performance-readiness'); const i18nBoundaryScript = readText('scripts/check-ultramodern-i18n-boundaries.mts'); assertToolWrapper(SHARED_VALIDATOR_STRING_123, 'typecheck'); assert( - i18nBoundaryScript.includes("from '@modern-js/code-tools'") && - i18nBoundaryScript.includes('runWorkspaceSourceCheck'), + i18nBoundaryScript.includes("from '@modern-js/code-tools'") && i18nBoundaryScript.includes('runWorkspaceSourceCheck'), 'Root i18n boundary script must call @modern-js/code-tools', ); -assert( - rootPackage.scripts?.['mf:types'] === SHARED_VALIDATOR_STRING_082, - 'Root must expose mf:types', -); +assert(rootPackage.scripts?.['mf:types'] === SHARED_VALIDATOR_STRING_082, 'Root must expose mf:types'); assert( rootPackage.scripts?.[SHARED_VALIDATOR_STRING_060] === expectedCloudflareDeployScript, 'Root must expose cloudflare:deploy', @@ -6651,8 +6170,7 @@ assert( 'Root must expose skills:check', ); assert( - rootPackage.scripts?.postinstall === - "node ./scripts/bootstrap-agent-skills.mts --postinstall && oxfmt . '!repos/**'", + rootPackage.scripts?.postinstall === 'node ./scripts/bootstrap-agent-skills.mts --postinstall && oxfmt .', 'Root postinstall must run the default-on Codex skills bootstrap, format installed skills, and leave reference repository installs explicit', ); assert( @@ -6677,28 +6195,20 @@ assert( ); const agentReferenceRepoSetup = readText('scripts/setup-agent-reference-repos.mts'); assert( - agentReferenceRepoSetup.includes("['commit', '--no-verify', '-m', message]"), - 'Agent reference repo installer commits must skip hooks during postinstall', + agentReferenceRepoSetup.includes("['commit', '-m', message]") && !agentReferenceRepoSetup.includes('--no-verify'), + 'Agent reference repo installer commits must run normal Git hooks', ); assert( agentReferenceRepoSetup.includes("commitInstallerChanges('Initialize UltraModern workspace')"), 'Initial agent reference repo commit must use the installer commit helper', ); assert( - agentReferenceRepoSetup.includes( - "commitInstallerChanges('Record agent reference repo manifest')", - ), + agentReferenceRepoSetup.includes("commitInstallerChanges('Record agent reference repo manifest')"), 'Agent reference repo manifest commit must use the installer commit helper', ); -const expectedAppIds = [ - SHARED_VALIDATOR_STRING_131, - ...fullStackVerticals.map((vertical) => vertical.id), -]; -const expectedCloudflareCompatibilityFlags = [ - SHARED_VALIDATOR_STRING_089, - SHARED_VALIDATOR_STRING_070, -]; +const expectedAppIds = [SHARED_VALIDATOR_STRING_131, ...fullStackVerticals.map((vertical) => vertical.id)]; +const expectedCloudflareCompatibilityFlags = [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070]; assert( sameJson( generatedContract.apps?.map((app) => app.id), @@ -6723,15 +6233,11 @@ assert( 'Shared design tokens must declare their CSS custom property prefix', ); assert( - generatedContract.cssFederation?.sharedDesignTokens?.layers?.owned?.includes( - SHARED_VALIDATOR_STRING_149, - ), + generatedContract.cssFederation?.sharedDesignTokens?.layers?.owned?.includes(SHARED_VALIDATOR_STRING_149), 'Shared design tokens must own the shared token CSS layer', ); assert( - generatedContract.cssFederation?.sharedDesignTokens?.entrypoints?.css?.includes( - SHARED_VALIDATOR_STRING_097, - ), + generatedContract.cssFederation?.sharedDesignTokens?.entrypoints?.css?.includes(SHARED_VALIDATOR_STRING_097), 'Shared design tokens must declare their CSS entrypoint', ); assert( @@ -6787,29 +6293,12 @@ const shellModuleFederationConfig = readText(SHARED_VALIDATOR_STRING_049); const shellModernAppEnv = readText('apps/shell-super-app/src/modern-app-env.d.ts'); const gitignore = readText('.gitignore'); const shellRouteHead = readText('apps/shell-super-app/src/routes/ultramodern-route-head.tsx'); -const shellRouteMetadata = readText( - 'apps/shell-super-app/src/routes/ultramodern-route-metadata.ts', -); -assert( - /^\.mf\/$/mu.test(gitignore), - 'Generated .gitignore must ignore root Module Federation diagnostics', -); -assert( - /^\*\*\/\.mf\/$/mu.test(gitignore), - 'Generated .gitignore must ignore per-app Module Federation diagnostics', -); -assert( - /^dist-cloudflare\/$/mu.test(gitignore), - 'Generated .gitignore must ignore Cloudflare build output', -); -assert( - /^\.output\/$/mu.test(gitignore), - 'Generated .gitignore must ignore root final deployment output', -); -assert( - /^\*\*\/\.output\/$/mu.test(gitignore), - 'Generated .gitignore must ignore per-app final deployment output', -); +const shellRouteMetadata = readText('apps/shell-super-app/src/routes/ultramodern-route-metadata.ts'); +assert(/^\.mf\/$/mu.test(gitignore), 'Generated .gitignore must ignore root Module Federation diagnostics'); +assert(/^\*\*\/\.mf\/$/mu.test(gitignore), 'Generated .gitignore must ignore per-app Module Federation diagnostics'); +assert(/^dist-cloudflare\/$/mu.test(gitignore), 'Generated .gitignore must ignore Cloudflare build output'); +assert(/^\.output\/$/mu.test(gitignore), 'Generated .gitignore must ignore root final deployment output'); +assert(/^\*\*\/\.output\/$/mu.test(gitignore), 'Generated .gitignore must ignore per-app final deployment output'); assert( /^\*\*\/src\/modern-tanstack\/$/mu.test(gitignore), 'Generated .gitignore must ignore framework-owned TanStack router output', @@ -6835,13 +6324,11 @@ assert( 'Shell app env must not redeclare framework-owned css asset modules', ); assert( - shellRouteMetadata.includes('@generated by @modern-js/create'), + shellRouteMetadata.includes('@generated by @modern-js/ultramodern-create'), 'Shell route metadata compatibility manifest must be marked generated', ); assert( - shellRouteMetadata.includes( - 'Author route metadata in colocated src/routes/**/route.meta.ts files.', - ), + shellRouteMetadata.includes('Author route metadata in colocated src/routes/**/route.meta.ts files.'), 'Shell route metadata manifest must advertise colocated authoring', ); const expectedZephyrDependencies = Object.fromEntries( @@ -6851,9 +6338,7 @@ const expectedZephyrDependencies = Object.fromEntries( if (vertical === undefined) { return []; } - return vertical.exposes.length === 0 - ? [] - : [[vertical.zephyrAlias, `${vertical.packageName}@workspace:*`]]; + return vertical.exposes.length === 0 ? [] : [[vertical.zephyrAlias, `${vertical.packageName}@workspace:*`]]; }), ); assert( @@ -6905,20 +6390,14 @@ assert( 'Shell Cloudflare compatibilityDate is incorrect', ); assert( - sameJson( - shellContract?.deploy?.cloudflare?.compatibilityFlags, - expectedCloudflareCompatibilityFlags, - ), + sameJson(shellContract?.deploy?.cloudflare?.compatibilityFlags, expectedCloudflareCompatibilityFlags), 'Shell Cloudflare compatibility flags are incorrect', ); assert( sameJson(shellContract?.deploy?.cloudflare?.security, expectedCloudflareSecurity), 'Shell Cloudflare security contract is incorrect', ); -assertCloudflareQualityGates( - SHARED_VALIDATOR_STRING_131, - shellContract?.deploy?.cloudflare?.qualityGates, -); +assertCloudflareQualityGates(SHARED_VALIDATOR_STRING_131, shellContract?.deploy?.cloudflare?.qualityGates); assert( shellContract?.deploy?.worker?.compatibilityDate === expectedCloudflareCompatibilityDate, 'Shell worker compatibilityDate is incorrect', @@ -6928,23 +6407,16 @@ assert( 'Shell worker name is incorrect', ); assert( - shellModernConfig.includes( - `const cloudflareWorkerName = '${expectedWorkerName(SHARED_VALIDATOR_STRING_131)}'`, - ), + shellModernConfig.includes(`const cloudflareWorkerName = '${expectedWorkerName(SHARED_VALIDATOR_STRING_131)}'`), 'Shell modern.config.ts must define the Cloudflare worker name', ); -assert( - shellModernConfig.includes('name: cloudflareWorkerName'), - 'Shell modern.config.ts must wire deploy.worker.name', -); +assert(shellModernConfig.includes('name: cloudflareWorkerName'), 'Shell modern.config.ts must wire deploy.worker.name'); assert( shellModernConfig.includes('const assetPrefix ='), 'Shell modern.config.ts must derive a dedicated asset prefix', ); assert( - shellModernConfig.includes( - "const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX')", - ), + shellModernConfig.includes("const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX')"), 'Shell asset prefix must support ULTRAMODERN_ASSET_PREFIX', ); assert( @@ -6957,8 +6429,8 @@ assert( ); const shellAssetPrefixExpression = extractAssetPrefixExpression(shellModernConfig); assert( - shellAssetPrefixExpression.includes( - 'configuredModernAssetPrefix || configuredUltramodernAssetPrefix || defaultAssetPrefix', + /configuredModernAssetPrefix\s*\|\|\s*configuredUltramodernAssetPrefix\s*\|\|\s*defaultAssetPrefix/u.test( + shellAssetPrefixExpression, ), 'Shell asset prefix fallback order is incorrect', ); @@ -6977,18 +6449,12 @@ assert( !shellAssetPrefixExpression.includes(SHARED_VALIDATOR_STRING_145), 'Shell asset prefix must not infer workers.dev URLs', ); -assert( - shellModernConfig.includes("assetPrefix: '/'"), - 'Shell modern.config.ts must keep dev assets origin-relative', -); +assert(shellModernConfig.includes("assetPrefix: '/'"), 'Shell modern.config.ts must keep dev assets origin-relative'); assert( shellModernConfig.includes('assetPrefix,'), 'Shell modern.config.ts must wire output.assetPrefix to the derived asset prefix', ); -assert( - shellContract?.config?.dev?.assetPrefix === '/', - 'Shell dev asset prefix must stay origin-relative', -); +assert(shellContract?.config?.dev?.assetPrefix === '/', 'Shell dev asset prefix must stay origin-relative'); assert( shellContract?.config?.output?.assetPrefix?.default === '/', 'Shell asset prefix must default to origin-relative paths', @@ -7013,8 +6479,7 @@ assert( 'Shell performance readiness diagnostics must only fail framework invariants by default', ); assert( - shellContract?.config?.performance?.readinessDiagnostics?.optOut?.env === - SHARED_VALIDATOR_STRING_146, + shellContract?.config?.performance?.readinessDiagnostics?.optOut?.env === SHARED_VALIDATOR_STRING_146, 'Shell performance readiness env opt-out is incorrect', ); assert( @@ -7031,8 +6496,7 @@ assert( 'Shell Rspack uniqueName is incorrect', ); assert( - shellContract?.config?.rspack?.output?.chunkLoadingGlobal === - expectedChunkLoadingGlobal(SHARED_VALIDATOR_STRING_133), + shellContract?.config?.rspack?.output?.chunkLoadingGlobal === expectedChunkLoadingGlobal(SHARED_VALIDATOR_STRING_133), 'Shell Rspack chunkLoadingGlobal is incorrect', ); assert( @@ -7055,18 +6519,12 @@ assert( shellContract?.styling?.federation?.owner?.id === SHARED_VALIDATOR_STRING_131, 'Shell CSS federation owner is missing', ); -assert( - shellContract?.styling?.federation?.role === 'shell-base-overlay', - 'Shell must own base and overlay CSS', -); +assert(shellContract?.styling?.federation?.role === 'shell-base-overlay', 'Shell must own base and overlay CSS'); assert( shellContract?.styling?.federation?.rootSelector === '[data-app-id="shell-super-app"]', 'Shell CSS root selector is incorrect', ); -assert( - shellContract?.styling?.federation?.classPrefix === 'shell:', - 'Shell CSS class prefix is incorrect', -); +assert(shellContract?.styling?.federation?.classPrefix === 'shell:', 'Shell CSS class prefix is incorrect'); assert( shellContract?.styling?.federation?.layers?.owned?.includes(SHARED_VALIDATOR_STRING_150) ?? false, 'Shell must own the base CSS layer', @@ -7076,8 +6534,7 @@ assert( 'Shell must own the overlay CSS layer', ); assert( - shellContract?.styling?.federation?.entrypoints?.css?.includes(SHARED_VALIDATOR_STRING_138) ?? - false, + shellContract?.styling?.federation?.entrypoints?.css?.includes(SHARED_VALIDATOR_STRING_138) ?? false, 'Shell CSS entrypoint is missing', ); assert( @@ -7099,10 +6556,7 @@ assert( shellContract?.routes?.metadataAuthoring === SHARED_VALIDATOR_STRING_062, 'Shell route metadata authoring mode is incorrect', ); -assert( - shellContract?.routes?.generatedManifest ?? false, - 'Shell route metadata manifest must be generated', -); +assert(shellContract?.routes?.generatedManifest ?? false, 'Shell route metadata manifest must be generated'); assert( shellContract?.routes?.publicnessDefault === SHARED_VALIDATOR_STRING_105, 'Shell route publicness default is incorrect', @@ -7111,11 +6565,7 @@ assert( sameJson(shellContract?.routes?.publicRoutes ?? [], []), 'Shell must not expose generated public routes by default', ); -assertPublicHeadContract( - SHARED_VALIDATOR_STRING_131, - shellContract?.routes?.publicHead, - shellRouteHead, -); +assertPublicHeadContract(SHARED_VALIDATOR_STRING_131, shellContract?.routes?.publicHead, shellRouteHead); assertPublicSurfaceContract(SHARED_VALIDATOR_STRING_131, shellContract?.routes?.publicSurface); assert( (shellContract?.routes?.owned ?? []).every( @@ -7132,21 +6582,10 @@ assert( topology.shell?.verticalRefs?.join(',') === expectedPrimaryShellVerticalIds.join(','), 'Topology shell verticalRefs must match generated verticals', ); -assert( - topology.verticals?.length === fullStackVerticals.length, - 'Topology must contain only generated verticals', -); -const legacyTopologyFields = Result.getOrThrow( - Schema.decodeUnknownResult(LegacyTopologyFieldsSchema)(topology), -); -assert( - legacyTopologyFields.remotes === undefined, - 'Topology must not expose legacy remotes; use verticals', -); -assert( - legacyTopologyFields.effectServices === undefined, - 'Default APIs must be vertical-owned, not effectServices', -); +assert(topology.verticals?.length === fullStackVerticals.length, 'Topology must contain only generated verticals'); +const legacyTopologyFields = Result.getOrThrow(Schema.decodeUnknownResult(LegacyTopologyFieldsSchema)(topology)); +assert(legacyTopologyFields.remotes === undefined, 'Topology must not expose legacy remotes; use verticals'); +assert(legacyTopologyFields.effectServices === undefined, 'Default APIs must be vertical-owned, not effectServices'); for (const vertical of fullStackVerticals) { const packageJson = readJson(PackageJsonSchema, `${vertical.path}/package.json`); @@ -7173,9 +6612,7 @@ for (const vertical of fullStackVerticals) { assert( actionPrincipal.includes("from '@app/gateway-principal-verifier/server'") && actionPrincipal.includes('bindGatewayPrincipalVerifier(ACTION_GATEWAY_AUDIENCE)') && - !/(?:createLocalJWKSet|decodeProtectedHeader|jwtVerify|PublicVerificationKeySchema)/u.test( - actionPrincipal, - ), + !/(?:createLocalJWKSet|decodeProtectedHeader|jwtVerify|PublicVerificationKeySchema)/u.test(actionPrincipal), `${actionPrincipalPath} must be a thin audience-bound shared verifier adapter`, ); for (const [dependency, version] of Object.entries({ @@ -7197,21 +6634,12 @@ for (const vertical of fullStackVerticals) { const modernConfig = readText(`${vertical.path}/modern.config.ts`); // The browser Module Federation config and colocated route surfaces only // exist for UI-emitting units; a headless api-only unit emits none (G2a/P4). - const moduleFederationConfig = vertical.emitsUi - ? readText(`${vertical.path}/module-federation.config.ts`) - : ''; + const moduleFederationConfig = vertical.emitsUi ? readText(`${vertical.path}/module-federation.config.ts`) : ''; const modernAppEnv = readText(`${vertical.path}/src/modern-app-env.d.ts`); - const routeHead = vertical.emitsUi - ? readText(`${vertical.path}/src/routes/ultramodern-route-head.tsx`) - : ''; - const routeMetadata = vertical.emitsUi - ? readText(`${vertical.path}/src/routes/ultramodern-route-metadata.ts`) - : ''; + const routeHead = vertical.emitsUi ? readText(`${vertical.path}/src/routes/ultramodern-route-head.tsx`) : ''; + const routeMetadata = vertical.emitsUi ? readText(`${vertical.path}/src/routes/ultramodern-route-metadata.ts`) : ''; const ultramodernBuildSource = readText(`${vertical.path}/shared/ultramodern-build.ts`); - const ultramodernBuildArtifact = readJson( - BuildArtifactSchema, - `${vertical.path}/shared/ultramodern-build.json`, - ); + const ultramodernBuildArtifact = readJson(BuildArtifactSchema, `${vertical.path}/shared/ultramodern-build.json`); if (vertical.deliveryUnit !== undefined) { const expectedDeliveryUnit = deliveryUnitBlock(expectedDeliveryUnitFor(vertical)); const buildLabel = `${vertical.path}/shared/ultramodern-build.json deliveryUnit`; @@ -7277,13 +6705,11 @@ for (const vertical of fullStackVerticals) { ); if (vertical.emitsUi) { assert( - routeMetadata.includes('@generated by @modern-js/create'), + routeMetadata.includes('@generated by @modern-js/ultramodern-create'), `${vertical.id} route metadata compatibility manifest must be marked generated`, ); assert( - routeMetadata.includes( - 'Author route metadata in colocated src/routes/**/route.meta.ts files.', - ), + routeMetadata.includes('Author route metadata in colocated src/routes/**/route.meta.ts files.'), `${vertical.id} route metadata manifest must advertise colocated authoring`, ); } @@ -7376,10 +6802,7 @@ for (const vertical of fullStackVerticals) { ); const contractEntry = generatedContract.apps?.find((app) => app.id === vertical.id); - assert( - contractEntry?.path === vertical.path, - `${vertical.id} generated contract path is incorrect`, - ); + assert(contractEntry?.path === vertical.path, `${vertical.id} generated contract path is incorrect`); assert(contractEntry?.kind === 'vertical', `${vertical.id} generated contract kind is incorrect`); assert( contractEntry?.deploy?.cloudflare?.workerName === expectedWorkerName(vertical.id), @@ -7395,10 +6818,7 @@ for (const vertical of fullStackVerticals) { `${vertical.id} Cloudflare compatibilityDate is incorrect`, ); assert( - sameJson( - contractEntry?.deploy?.cloudflare?.compatibilityFlags, - expectedCloudflareCompatibilityFlags, - ), + sameJson(contractEntry?.deploy?.cloudflare?.compatibilityFlags, expectedCloudflareCompatibilityFlags), `${vertical.id} Cloudflare compatibility flags are incorrect`, ); assert( @@ -7427,9 +6847,7 @@ for (const vertical of fullStackVerticals) { `${vertical.id} modern.config.ts must derive a dedicated asset prefix`, ); assert( - modernConfig.includes( - "const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX')", - ), + modernConfig.includes("const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX')"), `${vertical.id} asset prefix must support ULTRAMODERN_ASSET_PREFIX`, ); assert( @@ -7446,11 +6864,11 @@ for (const vertical of fullStackVerticals) { ); const verticalAssetPrefixExpression = extractAssetPrefixExpression(modernConfig); assert( - verticalAssetPrefixExpression.includes( - 'configuredModernAssetPrefix || configuredUltramodernAssetPrefix || defaultAssetPrefix', + /configuredModernAssetPrefix\s*\|\|\s*configuredUltramodernAssetPrefix\s*\|\|\s*defaultAssetPrefix/u.test( + verticalAssetPrefixExpression, ) || - verticalAssetPrefixExpression.includes( - 'configuredModernAssetPrefix ?? configuredUltramodernAssetPrefix ?? defaultAssetPrefix', + /configuredModernAssetPrefix\s*\?\?\s*configuredUltramodernAssetPrefix\s*\?\?\s*defaultAssetPrefix/u.test( + verticalAssetPrefixExpression, ), `${vertical.id} asset prefix fallback order is incorrect`, ); @@ -7460,9 +6878,10 @@ for (const vertical of fullStackVerticals) { `${vertical.id} asset prefix must not fall back to MODERN_PUBLIC_SITE_URL`, ); assert( - modernConfig.includes( - `envValue('ULTRAMODERN_PUBLIC_URL_${vertical.id.replaceAll('-', '_').toUpperCase()}')`, - ), + new RegExp( + `envValue\\(\\s*'ULTRAMODERN_PUBLIC_URL_${vertical.id.replaceAll('-', '_').toUpperCase()}'\\s*,?\\s*\\)`, + 'u', + ).test(modernConfig), `${vertical.id} asset prefix must read its per-app public URL`, ); assert( @@ -7503,13 +6922,11 @@ for (const vertical of fullStackVerticals) { `${vertical.id} performance readiness diagnostics must be default-on`, ); assert( - contractEntry?.config?.performance?.readinessDiagnostics?.failOn === - SHARED_VALIDATOR_STRING_069, + contractEntry?.config?.performance?.readinessDiagnostics?.failOn === SHARED_VALIDATOR_STRING_069, `${vertical.id} performance readiness diagnostics must only fail framework invariants by default`, ); assert( - contractEntry?.config?.performance?.readinessDiagnostics?.optOut?.config === - SHARED_VALIDATOR_STRING_121, + contractEntry?.config?.performance?.readinessDiagnostics?.optOut?.config === SHARED_VALIDATOR_STRING_121, `${vertical.id} performance readiness opt-out config is incorrect`, ); if (vertical.emitsApi) { @@ -7522,8 +6939,7 @@ for (const vertical of fullStackVerticals) { ); } else { assert( - contractEntry?.deploy?.cloudflare?.routes?.apiReadiness === - `${vertical.apiPrefix}/${vertical.stem}/readiness`, + contractEntry?.deploy?.cloudflare?.routes?.apiReadiness === `${vertical.apiPrefix}/${vertical.stem}/readiness`, `${vertical.id} Cloudflare proof readiness route is incorrect`, ); } @@ -7533,14 +6949,10 @@ for (const vertical of fullStackVerticals) { `${vertical.id} Rspack uniqueName is incorrect`, ); assert( - contractEntry?.config?.rspack?.output?.chunkLoadingGlobal === - expectedChunkLoadingGlobal(vertical.mfName), + contractEntry?.config?.rspack?.output?.chunkLoadingGlobal === expectedChunkLoadingGlobal(vertical.mfName), `${vertical.id} Rspack chunkLoadingGlobal is incorrect`, ); - assert( - contractEntry?.moduleFederation?.name === vertical.mfName, - `${vertical.id} MF name is incorrect`, - ); + assert(contractEntry?.moduleFederation?.name === vertical.mfName, `${vertical.id} MF name is incorrect`); assert( sameJson(contractEntry?.moduleFederation?.exposes, vertical.exposes), `${vertical.id} MF exposes are incorrect`, @@ -7574,42 +6986,26 @@ for (const vertical of fullStackVerticals) { ); // API contract surface is only present for API-bearing units. if (vertical.emitsApi) { - assert( - contractEntry?.api?.prefix === vertical.apiPrefix, - `${vertical.id} API prefix is incorrect`, - ); + assert(contractEntry?.api?.prefix === vertical.apiPrefix, `${vertical.id} API prefix is incorrect`); assert(contractEntry?.api?.group === vertical.group, `${vertical.id} API group is incorrect`); assert(contractEntry?.api?.runtime === 'effect', `${vertical.id} API runtime must be Effect`); - assert( - contractEntry?.api?.strictEffectApproach ?? false, - `${vertical.id} strictEffectApproach must be enabled`, - ); + assert(contractEntry?.api?.strictEffectApproach ?? false, `${vertical.id} strictEffectApproach must be enabled`); assert( contractEntry?.api?.contract === vertical.apiContractExport, `${vertical.id} API contract export is incorrect`, ); - assert( - contractEntry?.api?.client === vertical.apiClientExport, - `${vertical.id} API client export is incorrect`, - ); + assert(contractEntry?.api?.client === vertical.apiClientExport, `${vertical.id} API client export is incorrect`); if (vertical.apiProtocol === 'rpc') { // An `rpc` unit records the `/rpc` route/serialization; it must not carry // REST readiness or domain-operation semantics. - assert( - contractEntry?.api?.protocol === 'rpc', - `${vertical.id} generated contract API protocol must be rpc`, - ); - assert( - contractEntry?.api?.rpc?.path === '/rpc', - `${vertical.id} generated contract RPC route path is incorrect`, - ); + assert(contractEntry?.api?.protocol === 'rpc', `${vertical.id} generated contract API protocol must be rpc`); + assert(contractEntry?.api?.rpc?.path === '/rpc', `${vertical.id} generated contract RPC route path is incorrect`); assert( contractEntry?.api?.rpcPath === `${vertical.apiPrefix}/rpc`, `${vertical.id} generated contract RPC path is incorrect`, ); } else { - const restApi = - contractEntry?.api && !('rpc' in contractEntry.api) ? contractEntry.api : undefined; + const restApi = contractEntry?.api && !('rpc' in contractEntry.api) ? contractEntry.api : undefined; assert( restApi?.readiness?.endpoint === `/${vertical.stem}/readiness`, `${vertical.id} readiness endpoint is incorrect`, @@ -7635,34 +7031,22 @@ for (const vertical of fullStackVerticals) { (contractEntry?.i18n?.languages?.includes('cs') ?? false), `${vertical.id} must declare i18n languages`, ); - assert( - contractEntry?.i18n?.namespace === vertical.namespace, - `${vertical.id} i18n namespace is incorrect`, - ); + assert(contractEntry?.i18n?.namespace === vertical.namespace, `${vertical.id} i18n namespace is incorrect`); assert( sameJson(contractEntry?.i18n?.localisedUrls, vertical.localisedUrls), `${vertical.id} localisedUrls must come from route metadata`, ); - assert( - contractEntry?.routes?.source === 'route-owned', - `${vertical.id} routes must be route-owned`, - ); + assert(contractEntry?.routes?.source === 'route-owned', `${vertical.id} routes must be route-owned`); assert( contractEntry?.routes?.metadataAuthoring === SHARED_VALIDATOR_STRING_062, `${vertical.id} route metadata authoring mode is incorrect`, ); - assert( - contractEntry?.routes?.generatedManifest ?? false, - `${vertical.id} route metadata manifest must be generated`, - ); + assert(contractEntry?.routes?.generatedManifest ?? false, `${vertical.id} route metadata manifest must be generated`); assert( contractEntry?.routes?.metadataExport === SHARED_VALIDATOR_STRING_006, `${vertical.id} route metadata export is incorrect`, ); - assert( - contractEntry?.routes?.privateByDefault ?? false, - `${vertical.id} routes must be private by default`, - ); + assert(contractEntry?.routes?.privateByDefault ?? false, `${vertical.id} routes must be private by default`); assert( contractEntry?.routes?.publicnessDefault === SHARED_VALIDATOR_STRING_105, `${vertical.id} route publicness default is incorrect`, @@ -7674,12 +7058,7 @@ for (const vertical of fullStackVerticals) { // Public head/surface and owned browser routes only exist for UI-emitting // units; a headless api-only unit renders no route head or public surface. if (vertical.emitsUi) { - assertPublicHeadContract( - vertical.id, - contractEntry?.routes?.publicHead, - routeHead, - vertical.hasOwnerPage, - ); + assertPublicHeadContract(vertical.id, contractEntry?.routes?.publicHead, routeHead, vertical.hasOwnerPage); assertPublicSurfaceContract(vertical.id, contractEntry?.routes?.publicSurface); assert( (contractEntry?.routes?.owned ?? []).every( @@ -7701,10 +7080,7 @@ for (const vertical of fullStackVerticals) { contractEntry?.styling?.federation?.owner?.id === vertical.id, `${vertical.id} CSS federation owner is missing`, ); - assert( - contractEntry?.styling?.federation?.role === 'vertical-css', - `${vertical.id} must own only vertical CSS`, - ); + assert(contractEntry?.styling?.federation?.role === 'vertical-css', `${vertical.id} must own only vertical CSS`); assert( contractEntry?.styling?.federation?.rootSelector === `[data-app-id="${vertical.id}"]`, `${vertical.id} CSS root selector is incorrect`, @@ -7714,16 +7090,11 @@ for (const vertical of fullStackVerticals) { `${vertical.id} CSS class prefix is incorrect`, ); assert( - contractEntry?.styling?.federation?.layers?.owned?.includes( - `ultramodern-vertical-${vertical.domain}`, - ) ?? false, + contractEntry?.styling?.federation?.layers?.owned?.includes(`ultramodern-vertical-${vertical.domain}`) ?? false, `${vertical.id} vertical CSS layer is missing`, ); assert( - !( - contractEntry?.styling?.federation?.layers?.owned?.includes(SHARED_VALIDATOR_STRING_150) ?? - false - ), + !(contractEntry?.styling?.federation?.layers?.owned?.includes(SHARED_VALIDATOR_STRING_150) ?? false), `${vertical.id} must not own shell base CSS`, ); assert( @@ -7747,22 +7118,14 @@ for (const vertical of fullStackVerticals) { ); } - const topologyEntry = topology.verticals?.find( - (verticalEntry) => verticalEntry.id === vertical.id, - ); + const topologyEntry = topology.verticals?.find((verticalEntry) => verticalEntry.id === vertical.id); assert(topologyEntry?.kind === 'vertical', `${vertical.id} topology kind is incorrect`); - assert( - topologyEntry?.package === vertical.packageName, - `${vertical.id} topology package is incorrect`, - ); + assert(topologyEntry?.package === vertical.packageName, `${vertical.id} topology package is incorrect`); assert( topologyEntry?.cloudflare?.workerName === expectedWorkerName(vertical.id), `${vertical.id} topology Cloudflare workerName is incorrect`, ); - assert( - topologyEntry?.moduleFederation?.name === vertical.mfName, - `${vertical.id} topology MF name is incorrect`, - ); + assert(topologyEntry?.moduleFederation?.name === vertical.mfName, `${vertical.id} topology MF name is incorrect`); assert( sameJson(topologyEntry?.moduleFederation?.exposes, vertical.exposes), `${vertical.id} topology exposes are incorrect`, @@ -7774,10 +7137,7 @@ for (const vertical of fullStackVerticals) { // API/BFF topology metadata only exists for API-bearing units; and the REST // readiness/domain-operation surface is absent for the RPC protocol (G7a). if (vertical.emitsApi) { - assert( - topologyEntry?.api?.bff?.prefix === vertical.apiPrefix, - `${vertical.id} topology API prefix is incorrect`, - ); + assert(topologyEntry?.api?.bff?.prefix === vertical.apiPrefix, `${vertical.id} topology API prefix is incorrect`); assert( topologyEntry?.api?.bff?.strictEffectApproach ?? false, `${vertical.id} topology strictEffectApproach is incorrect`, @@ -7801,15 +7161,11 @@ for (const vertical of fullStackVerticals) { if (vertical.deliveryUnit !== undefined) { const expectedDeliveryUnit = deliveryUnitBlock(expectedDeliveryUnitFor(vertical)); - const compactAppEntry = ultramodernConfig.topology?.apps?.find( - (entry) => entry?.id === vertical.id, - ); + const compactAppEntry = ultramodernConfig.topology?.apps?.find((entry) => entry?.id === vertical.id); // The backend-federation delivery-unit mirror only exists for API-bearing // units; a UI-only vertical carries just the app-level delivery unit. if (vertical.emitsApi) { - const compactBackendDeliveryUnit = deliveryUnitBlock( - compactAppEntry?.backendFederation?.deliveryUnit, - ); + const compactBackendDeliveryUnit = deliveryUnitBlock(compactAppEntry?.backendFederation?.deliveryUnit); assertSameJson( deliveryUnitBlock(compactAppEntry?.deliveryUnit), compactBackendDeliveryUnit, @@ -7823,8 +7179,7 @@ for (const vertical of fullStackVerticals) { deliveryUnitIdentityFixArea, ); assertSelfCheck( - compactAppEntry?.backendFederation?.versionBoundary?.identityRoot === - SHARED_VALIDATOR_STRING_065, + compactAppEntry?.backendFederation?.versionBoundary?.identityRoot === SHARED_VALIDATOR_STRING_065, `${compactConfigPath} topology.apps.${vertical.id}.backendFederation.versionBoundary.identityRoot`, `Expected "deliveryUnit", found ${formatJson(compactAppEntry?.backendFederation?.versionBoundary?.identityRoot)}`, deliveryUnitIdentityFixArea, @@ -7854,9 +7209,7 @@ for (const vertical of fullStackVerticals) { ); if (vertical.emitsUi) { assert( - valueForKey(Object.entries(overlay.manifests ?? {}), vertical.id)?.includes( - SHARED_VALIDATOR_STRING_031, - ) ?? false, + valueForKey(Object.entries(overlay.manifests ?? {}), vertical.id)?.includes(SHARED_VALIDATOR_STRING_031) ?? false, `${vertical.id} development manifest is missing`, ); } @@ -7888,9 +7241,7 @@ for (const expectedApp of workspaceValidationContract.topology.compactConfig?.ap deliveryUnitIdentityFixArea, ); - const compactAppEntry = ultramodernConfig.topology?.apps?.find( - (entry) => entry?.id === expectedApp.id, - ); + const compactAppEntry = ultramodernConfig.topology?.apps?.find((entry) => entry?.id === expectedApp.id); assertSameJson( compactAppEntry?.deploy?.cloudflare, expectedApp.deploy?.cloudflare, @@ -7905,9 +7256,7 @@ for (const expectedApp of workspaceValidationContract.topology.compactConfig?.ap ); const topologyUnitEntry = - expectedApp.kind === 'shell' - ? topology.shell - : topology.verticals?.find((entry) => entry?.id === expectedApp.id); + expectedApp.kind === 'shell' ? topology.shell : topology.verticals?.find((entry) => entry?.id === expectedApp.id); const topologyUnitLabel = expectedApp.kind === 'shell' ? 'shell' : `verticals.${expectedApp.id}`; assertSameJson( deliveryUnitBlock(topologyUnitEntry?.deliveryUnit), @@ -7953,40 +7302,26 @@ const legacyIdentityAllowlist = new Set([ ]); const legacyIdentityToken = /(?:^|[^A-Za-z])(?:crm|CRM|Crm)/u; for (const legacyIdentityProbe of ['crm', 'CRM', 'Crm', 'crmClient', 'CrmApi', 'CRM_SERVICE']) { - assert( - legacyIdentityToken.test(legacyIdentityProbe), - `Legacy identity guard does not reject ${legacyIdentityProbe}`, - ); + assert(legacyIdentityToken.test(legacyIdentityProbe), `Legacy identity guard does not reject ${legacyIdentityProbe}`); } -assert( - !legacyIdentityToken.test('scrm'), - 'Legacy identity guard must not match the CRM letters inside another word', -); -const staleNameScanPaths = [ - '.', - '../README.md', - '../CONTEXT-MAP.md', - '../docs', - '../.github/workflows', -]; -const gitExecutable = '/usr/bin/git'; +assert(!legacyIdentityToken.test('scrm'), 'Legacy identity guard must not match the CRM letters inside another word'); +const staleNameScanRoot = path.resolve(root, '..'); +const staleNameScanPaths = [path.basename(root), 'README.md', 'CONTEXT-MAP.md', 'docs', '.github/workflows']; +const gitExecutable = 'git'; const trackedAndUntrackedFiles = [ execFileSync(gitExecutable, ['ls-files', ...staleNameScanPaths], { - cwd: root, + cwd: staleNameScanRoot, + encoding: 'utf-8', + }), + execFileSync(gitExecutable, ['ls-files', '--others', '--exclude-standard', ...staleNameScanPaths], { + cwd: staleNameScanRoot, encoding: 'utf-8', }), - execFileSync( - gitExecutable, - ['ls-files', '--others', '--exclude-standard', ...staleNameScanPaths], - { - cwd: root, - encoding: 'utf-8', - }, - ), ] .join('\n') .split('\n') - .filter((filePath) => filePath.length > 0); + .filter((filePath) => filePath.length > 0) + .map((filePath) => path.relative(root, path.resolve(staleNameScanRoot, filePath))); const legacyIdentityViolations = trackedAndUntrackedFiles.filter((filePath) => { const normalizedPath = filePath.replace(/^\.\//u, ''); if ( diff --git a/app/scripts/verify-application-db-schema.mts b/app/scripts/verify-application-db-schema.mts index ba1c888ee..f8ca41851 100644 --- a/app/scripts/verify-application-db-schema.mts +++ b/app/scripts/verify-application-db-schema.mts @@ -1,6 +1,7 @@ import { Console, Effect, Exit, Schema } from 'effect'; import { Client } from 'pg'; import type { QueryResult, QueryResultRow } from 'pg'; + import { loadDatabaseConnectionPair } from '../packages/core-runtime/src/db/config.ts'; const EXPECTED_APPLICATION_SCHEMAS = ['auth', 'contacts', 'core', 'party'] as const; @@ -19,10 +20,7 @@ class ApplicationDatabaseVerificationError extends Schema.TaggedError +const verificationFailure = (reason: string, cause?: unknown): ApplicationDatabaseVerificationError => new ApplicationDatabaseVerificationError(cause === undefined ? { reason } : { cause, reason }); const query = ( @@ -94,13 +92,14 @@ const main = Effect.gen(function* verifyApplicationDatabase() { yield* Effect.acquireUseRelease( Effect.gen(function* acquireAdministrativeClient() { const client = yield* Effect.try({ - catch: (cause) => - verificationFailure('Unable to create the administrative PostgreSQL client', cause), - try: () => new Client({ connectionString: configuration.admin.connectionString }), + catch: (cause) => verificationFailure('Unable to create the administrative PostgreSQL client', cause), + try: () => + new Client({ + connectionString: configuration.admin.connectionString, + }), }); yield* Effect.tryPromise({ - catch: (cause) => - verificationFailure('Unable to connect to the administrative PostgreSQL database', cause), + catch: (cause) => verificationFailure('Unable to connect to the administrative PostgreSQL database', cause), try: async () => await client.connect(), }); return client; @@ -108,8 +107,7 @@ const main = Effect.gen(function* verifyApplicationDatabase() { verifyApplicationCatalog, (client) => Effect.tryPromise({ - catch: (cause) => - verificationFailure('Unable to close the administrative PostgreSQL connection', cause), + catch: (cause) => verificationFailure('Unable to close the administrative PostgreSQL connection', cause), try: async () => await client.end(), }), ); diff --git a/app/scripts/verify-cloudflare-output.mts b/app/scripts/verify-cloudflare-output.mts index 8a31a17fb..65666c9db 100644 --- a/app/scripts/verify-cloudflare-output.mts +++ b/app/scripts/verify-cloudflare-output.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; import { Effect } from 'effect'; + import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; diff --git a/app/specs/chore-add-external-http-adapter-generator.md b/app/specs/chore-add-external-http-adapter-generator.md index 2f1c1b9d8..e6163467e 100644 --- a/app/specs/chore-add-external-http-adapter-generator.md +++ b/app/specs/chore-add-external-http-adapter-generator.md @@ -8,51 +8,31 @@ created: 2026-08-17 ## Chore Description -Add a reusable Codesmith command for creating the required starting point for one private, -server-side external HTTP adapter owned by an existing OntOS MicroVertical. The command must encode -the repository's Effect-first dependency seam, safe owner-local placement, generated-file marker, -and mutation guarantees without inventing provider-specific schemas, authentication, status -classification, retry, timeout, cache, coalescing, concurrency, or business mapping rules. +Add a reusable Codesmith command for creating the required starting point for one private, server-side external HTTP adapter owned by an existing OntOS MicroVertical. The command must encode the repository's Effect-first dependency seam, safe owner-local placement, generated-file marker, and mutation guarantees without inventing provider-specific schemas, authentication, status classification, retry, timeout, cache, coalescing, concurrency, or business mapping rules. -The first concrete consumer is the CRM ARES subject adapter in -`specs/feature-crm-ares-adapter.md`. This chore creates and validates only the generator; the ARES -feature will invoke it and adapt its generated output in a separate implementation task. +The first concrete consumer is the CRM ARES subject adapter in `specs/feature-crm-ares-adapter.md`. This chore creates and validates only the generator; the ARES feature will invoke it and adapt its generated output in a separate implementation task. ## Relevant Files Use these files to accomplish the chore: -- `package.json` — exposes every supported Codesmith command through a repository-managed pnpm - script. -- `scripts/scaffolding/cli.mts` — owns the typed command union, help, flags, validation, generator - dispatch, and result/config unions. -- `scripts/scaffolding/shared.mts` — owns generator headers, typed configs/results, canonical slug - validation, OntOS MicroVertical discovery, contained-path resolution, formatting, overwrite - refusal, and mutation-plan application. +- `package.json` — exposes every supported Codesmith command through a repository-managed pnpm script. +- `scripts/scaffolding/cli.mts` — owns the typed command union, help, flags, validation, generator dispatch, and result/config unions. +- `scripts/scaffolding/shared.mts` — owns generator headers, typed configs/results, canonical slug validation, OntOS MicroVertical discovery, contained-path resolution, formatting, overwrite refusal, and mutation-plan application. - `scripts/scaffolding/generator-adapter.mts` — adapts a mutation planner to Codesmith execution. -- `scripts/scaffolding/action-service/scaffold.mts` — nearest small owner-local Effect service - generator and its create-only mutation pattern. -- `scripts/scaffolding/tests/scaffold-generators.test.mts` — disposable-workspace tests for command - help, exact output, invalid input, traversal, overwrite, atomicity, formatting, composition, and - generated-source compilation. +- `scripts/scaffolding/action-service/scaffold.mts` — nearest small owner-local Effect service generator and its create-only mutation pattern. +- `scripts/scaffolding/tests/scaffold-generators.test.mts` — disposable-workspace tests for command help, exact output, invalid input, traversal, overwrite, atomicity, formatting, composition, and generated-source compilation. - `scripts/scaffolding/tsconfig.json` — strict typecheck surface for generator implementation. -- `AGENTS.md` — authoritative in-application Codesmith rules and the command developers must run - before adding a private external HTTP adapter. -- `docs/architecture/ULTRAMODERN.md` — Effect-first I/O, generated starting-point, and private - implementation rules. -- `docs/architecture/MICROVERTICALS.md` — strict ownership and deployment seams that keep an adapter - inside its owning MicroVertical. -- `docs/architecture/MODULE_ENTRYPOINTS.md` — distinguishes private implementations from governed - public module entrypoints and forbids accidental public registration or exports. -- `docs/architecture/ERRORS.md` — requires provider failures to remain tagged and typed until a - later public endpoint maps them to its declared contract. -- `specs/feature-crm-ares-adapter.md` — first consumer and proof that the generated shape has a - concrete reuse case. +- `AGENTS.md` — authoritative in-application Codesmith rules and the command developers must run before adding a private external HTTP adapter. +- `docs/architecture/ULTRAMODERN.md` — Effect-first I/O, generated starting-point, and private implementation rules. +- `docs/architecture/MICROVERTICALS.md` — strict ownership and deployment seams that keep an adapter inside its owning MicroVertical. +- `docs/architecture/MODULE_ENTRYPOINTS.md` — distinguishes private implementations from governed public module entrypoints and forbids accidental public registration or exports. +- `docs/architecture/ERRORS.md` — requires provider failures to remain tagged and typed until a later public endpoint maps them to its declared contract. +- `specs/feature-crm-ares-adapter.md` — first consumer and proof that the generated shape has a concrete reuse case. ### New Files -- `scripts/scaffolding/external-http-adapter/scaffold.mts` — Codesmith mutation planner and - compile-safe template for one private MicroVertical-owned external HTTP adapter. +- `scripts/scaffolding/external-http-adapter/scaffold.mts` — Codesmith mutation planner and compile-safe template for one private MicroVertical-owned external HTTP adapter. ## Step by Step Tasks @@ -60,104 +40,50 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Lock the command contract with generator tests -- [x] Extend `scripts/scaffolding/tests/scaffold-generators.test.mts` before implementation with the - command `external-http-adapter` and the required flags `--vertical`, `--provider`, and - `--operation`. Prove that `--vertical crm --provider ares --operation subject` targets exactly - `verticals/crm/src/integrations/ares/ares-subject.service.ts`, uses deterministic provider- and - operation-derived symbols, and changes no manifest, runtime registration, package export, Module - Federation exposure, locale, or Shell file. -- [x] In the same disposable fixtures, cover write-free `--help`, missing/unknown/duplicate flags, - invalid or reserved provider and operation slugs, traversal attempts, a missing or malformed - generated OntOS MicroVertical, an existing target file, and a planner failure. Assert that every - rejected run leaves the complete fixture tree unchanged. -- [x] Add generated-output assertions proving the source has a versioned Codesmith marker, uses - Effect and the Effect `HttpClient` context seam, is formatted deterministically, compiles against - the repository's pinned Effect cohort, and composes with the existing generator suite without - weakening its overwrite or path-containment guarantees. +- [x] Extend `scripts/scaffolding/tests/scaffold-generators.test.mts` before implementation with the command `external-http-adapter` and the required flags `--vertical`, `--provider`, and `--operation`. Prove that `--vertical crm --provider ares --operation subject` targets exactly `verticals/crm/src/integrations/ares/ares-subject.service.ts`, uses deterministic provider- and operation-derived symbols, and changes no manifest, runtime registration, package export, Module Federation exposure, locale, or Shell file. +- [x] In the same disposable fixtures, cover write-free `--help`, missing/unknown/duplicate flags, invalid or reserved provider and operation slugs, traversal attempts, a missing or malformed generated OntOS MicroVertical, an existing target file, and a planner failure. Assert that every rejected run leaves the complete fixture tree unchanged. +- [x] Add generated-output assertions proving the source has a versioned Codesmith marker, uses Effect and the Effect `HttpClient` context seam, is formatted deterministically, compiles against the repository's pinned Effect cohort, and composes with the existing generator suite without weakening its overwrite or path-containment guarantees. ### 2. Implement the external HTTP adapter mutation planner -- [x] Add typed external-adapter config/result declarations and a versioned generator header to - `scripts/scaffolding/shared.mts`. Validate `provider` and `operation` independently with the - existing canonical lower-kebab slug rule, and reuse `discoverOntosModule`, - `resolveContainedPath`, and `createMutation` rather than adding parallel discovery, validation, - formatting, or filesystem behavior. -- [x] Implement `scripts/scaffolding/external-http-adapter/scaffold.mts` with - `createCodesmithGenerator`. Generate exactly one create-only source file at - `verticals//src/integrations//-.service.ts`; refuse to - overwrite any existing file and return its absolute path in the typed result. -- [x] Render a compile-safe, fail-closed Effect starting point with deterministic Pascal/camel-case - names derived from provider plus operation, an owner-local module interface, an injected Effect - `HttpClient` seam captured by its construction/live layer, and a tagged typed placeholder failure - that must be replaced when business behavior is implemented. Do not expose a raw HTTP client to - callers, execute a request, use global/ad hoc `fetch`, add a browser import, or publish the - implementation. -- [x] Keep provider policy out of the template: do not generate URLs, credentials, headers, - request/response schemas beyond the minimal fail-closed scaffold, status mappings, - retry/backoff, timeout values, cache/coalescing, concurrency limits, logging fields, or domain - mapping. Those belong to each generated adapter's implementation and tests. +- [x] Add typed external-adapter config/result declarations and a versioned generator header to `scripts/scaffolding/shared.mts`. Validate `provider` and `operation` independently with the existing canonical lower-kebab slug rule, and reuse `discoverOntosModule`, `resolveContainedPath`, and `createMutation` rather than adding parallel discovery, validation, formatting, or filesystem behavior. +- [x] Implement `scripts/scaffolding/external-http-adapter/scaffold.mts` with `createCodesmithGenerator`. Generate exactly one create-only source file at `verticals//src/integrations//-.service.ts`; refuse to overwrite any existing file and return its absolute path in the typed result. +- [x] Render a compile-safe, fail-closed Effect starting point with deterministic Pascal/camel-case names derived from provider plus operation, an owner-local module interface, an injected Effect `HttpClient` seam captured by its construction/live layer, and a tagged typed placeholder failure that must be replaced when business behavior is implemented. Do not expose a raw HTTP client to callers, execute a request, use global/ad hoc `fetch`, add a browser import, or publish the implementation. +- [x] Keep provider policy out of the template: do not generate URLs, credentials, headers, request/response schemas beyond the minimal fail-closed scaffold, status mappings, retry/backoff, timeout values, cache/coalescing, concurrency limits, logging fields, or domain mapping. Those belong to each generated adapter's implementation and tests. ### 3. Wire the supported Codesmith command -- [x] Update `scripts/scaffolding/cli.mts` to import and dispatch the new generator, extend its - command/config/result unions, accept only `provider`, `operation`, and `vertical`, and provide - complete write-free help with the exact usage and an ARES example. -- [x] Add `scaffold:external-http-adapter` to `package.json`, invoking the existing scaffolding CLI - in the same way as other commands. Do not add dependencies or a second command runner. +- [x] Update `scripts/scaffolding/cli.mts` to import and dispatch the new generator, extend its command/config/result unions, accept only `provider`, `operation`, and `vertical`, and provide complete write-free help with the exact usage and an ARES example. +- [x] Add `scaffold:external-http-adapter` to `package.json`, invoking the existing scaffolding CLI in the same way as other commands. Do not add dependencies or a second command runner. ### 4. Document ownership and mandatory use -- [x] Update `AGENTS.md` and `docs/architecture/ULTRAMODERN.md` so a private third-party HTTP adapter - inside any `verticals/*` package must start with the documented - `scaffold:external-http-adapter` command using `--vertical`, `--provider`, and `--operation`. - State that the output remains owner-local and must not patch or appear in a module manifest, - runtime registration, package export, Module Federation exposure, generated BFF client, or Shell - surface. -- [x] Document that the generated Effect `HttpClient` seam is the substitution point for - deterministic tests, while every provider-specific input/result schema, tagged error union, - request construction, resilience policy, diagnostics, and business mapping remains the owning - adapter's responsibility. +- [x] Update `AGENTS.md` and `docs/architecture/ULTRAMODERN.md` so a private third-party HTTP adapter inside any `verticals/*` package must start with the documented `scaffold:external-http-adapter` command using `--vertical`, `--provider`, and `--operation`. State that the output remains owner-local and must not patch or appear in a module manifest, runtime registration, package export, Module Federation exposure, generated BFF client, or Shell surface. +- [x] Document that the generated Effect `HttpClient` seam is the substitution point for deterministic tests, while every provider-specific input/result schema, tagged error union, request construction, resilience policy, diagnostics, and business mapping remains the owning adapter's responsibility. ### 5. Run all validation commands -- [x] Execute every command in `Validation Commands` in order and resolve only failures introduced - by this generator chore. +- [x] Execute every command in `Validation Commands` in order and resolve only failures introduced by this generator chore. ## Testing Strategy -Extend the existing disposable Codesmith fixture suite rather than creating a parallel runner. -Tests must exercise the public CLI interface and the resulting filesystem tree, including exact -generated output, format and compilation stability, OntOS MicroVertical ownership validation, -write-free failures, overwrite protection, path containment, and composition with the other -commands. The generator does not create a business test file: each consumer must author tests for -its real provider contract and failure paths after adapting the generated production starting -point. +Extend the existing disposable Codesmith fixture suite rather than creating a parallel runner. Tests must exercise the public CLI interface and the resulting filesystem tree, including exact generated output, format and compilation stability, OntOS MicroVertical ownership validation, write-free failures, overwrite protection, path containment, and composition with the other commands. The generator does not create a business test file: each consumer must author tests for its real provider contract and failure paths after adapting the generated production starting point. ## Acceptance Criteria -- [x] Running the documented command for `crm`, `ares`, and `subject` creates only - `verticals/crm/src/integrations/ares/ares-subject.service.ts`. -- [x] Generated output is a private, compile-safe, fail-closed Effect module whose external HTTP - dependency is substituted through Effect `HttpClient` and is not exposed through its caller - interface. -- [x] The generator validates an existing generated OntOS MicroVertical, canonical provider and - operation names, workspace containment, and no-overwrite behavior before applying mutations. -- [x] No generated or generator-side behavior publishes the adapter, registers a module - entrypoint, crosses a MicroVertical seam, or supplies provider-specific runtime policy. -- [x] Help, failure, atomicity, formatting, compilation, and composition behavior are covered by - deterministic generator tests. -- [x] The documented generator is a valid mandatory starting point for the private CRM ARES - adapter, removing the current no-generator blocker from `specs/feature-crm-ares-adapter.md`. +- [x] Running the documented command for `crm`, `ares`, and `subject` creates only `verticals/crm/src/integrations/ares/ares-subject.service.ts`. +- [x] Generated output is a private, compile-safe, fail-closed Effect module whose external HTTP dependency is substituted through Effect `HttpClient` and is not exposed through its caller interface. +- [x] The generator validates an existing generated OntOS MicroVertical, canonical provider and operation names, workspace containment, and no-overwrite behavior before applying mutations. +- [x] No generated or generator-side behavior publishes the adapter, registers a module entrypoint, crosses a MicroVertical seam, or supplies provider-specific runtime policy. +- [x] Help, failure, atomicity, formatting, compilation, and composition behavior are covered by deterministic generator tests. +- [x] The documented generator is a valid mandatory starting point for the private CRM ARES adapter, removing the current no-generator blocker from `specs/feature-crm-ares-adapter.md`. ## Validation Commands Execute every command to validate the chore with zero regressions. -- `mise exec -- node --test scripts/scaffolding/tests/scaffold-generators.test.mts` — validate the - new command's output, safety failures, formatting, compilation, and composition with existing - generators. -- `mise exec -- pnpm typecheck` — validate generator, CLI, shared types, and generated fixture - contracts against the repository TypeScript and Effect cohort. +- `mise exec -- node --test scripts/scaffolding/tests/scaffold-generators.test.mts` — validate the new command's output, safety failures, formatting, compilation, and composition with existing generators. +- `mise exec -- pnpm typecheck` — validate generator, CLI, shared types, and generated fixture contracts against the repository TypeScript and Effect cohort. - `mise exec -- pnpm lint` — validate source and architecture lint rules. - `mise exec -- pnpm format:check` — validate deterministic repository formatting. - `mise exec -- pnpm check` — Run the final repository quality gate. @@ -171,16 +97,11 @@ Execute every command to validate the chore with zero regressions. ## Notes -- This chore is the prerequisite for `specs/feature-crm-ares-adapter.md`; implement and integrate - the generator before resuming that feature. -- The chore must not invoke the new generator against CRM or implement ARES. The ARES task will run - it as its first implementation step and then adapt the output. -- `external-http-adapter` is intentionally transport-specific. Do not add generic SDK, database, - queue, authentication, or provider-profile flags without a second concrete use case. -- The existing Effect `HttpClient` context is the true-external test seam. Do not add a second - repository-wide port or shared external-integration framework for this single concrete need. -- Tests may be authored directly because they are verification artifacts; the mandatory generator - governs the initial production business file. +- This chore is the prerequisite for `specs/feature-crm-ares-adapter.md`; implement and integrate the generator before resuming that feature. +- The chore must not invoke the new generator against CRM or implement ARES. The ARES task will run it as its first implementation step and then adapt the output. +- `external-http-adapter` is intentionally transport-specific. Do not add generic SDK, database, queue, authentication, or provider-profile flags without a second concrete use case. +- The existing Effect `HttpClient` context is the true-external test seam. Do not add a second repository-wide port or shared external-integration framework for this single concrete need. +- Tests may be authored directly because they are verification artifacts; the mandatory generator governs the initial production business file. - No unresolved developer decision blocks implementation. ## Implementation Evidence @@ -188,8 +109,7 @@ Execute every command to validate the chore with zero regressions. ### Summary - Added the supported `scaffold:external-http-adapter` Codesmith command and create-only planner. -- Generated adapters are private, fail closed with a tagged typed error, capture the Effect - `HttpClient` context seam in their live layer, and contain no provider runtime policy. +- Generated adapters are private, fail closed with a tagged typed error, capture the Effect `HttpClient` context seam in their live layer, and contain no provider runtime policy. - Documented the command as the mandatory starting point for owner-local third-party HTTP adapters. ### Changed Files @@ -198,37 +118,22 @@ Execute every command to validate the chore with zero regressions. ### Tests Written or Updated -- `scripts/scaffolding/tests/scaffold-generators.test.mts` — proves exact CRM/ARES output and the - single-file mutation boundary; write-free help and rejected runs; missing, unknown, duplicate, - invalid, reserved, traversal, missing-owner, malformed-owner, overwrite, and planner failures; - formatter stability; generated-source compilation; and deterministic generator composition. +- `scripts/scaffolding/tests/scaffold-generators.test.mts` — proves exact CRM/ARES output and the single-file mutation boundary; write-free help and rejected runs; missing, unknown, duplicate, invalid, reserved, traversal, missing-owner, malformed-owner, overwrite, and planner failures; formatter stability; generated-source compilation; and deterministic generator composition. ### Validation -- `mise exec -- node --test scripts/scaffolding/tests/scaffold-generators.test.mts` — passed, 40/40 - tests. +- `mise exec -- node --test scripts/scaffolding/tests/scaffold-generators.test.mts` — passed, 40/40 tests. - `mise exec -- pnpm typecheck` — passed. - `mise exec -- pnpm lint` — passed. - `mise exec -- pnpm format:check` — passed. -- `mise exec -- pnpm check` — passed, including format, lint, Action unit tests, typecheck, skills, - i18n, API, database-access, module-entrypoint, module-contract, workspace-contract, and - performance-readiness gates. -- `mise exec -- pnpm build` — not run; the plan does not require a build and the change affects only - scaffolding infrastructure, tests, command metadata, and documentation rather than runtime or - bundled application output. +- `mise exec -- pnpm check` — passed, including format, lint, Action unit tests, typecheck, skills, i18n, API, database-access, module-entrypoint, module-contract, workspace-contract, and performance-readiness gates. +- `mise exec -- pnpm build` — not run; the plan does not require a build and the change affects only scaffolding infrastructure, tests, command metadata, and documentation rather than runtime or bundled application output. - Runtime/browser validation — not run; this chore has no user-visible runtime behavior. ### Review -- Re-read and reviewed against `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, - `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, - `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, - `docs/architecture/MODULE_MANIFESTS.md`, the first-consumer ARES specification and integration - context, and relevant repository architecture context. -- Inspected status, whitespace errors, stats, all task-relevant tracked changes, both new files, and - the final generated output. The review found and fixed one documentation placement issue and - added a distinct atomic planner-failure case. The affected suite and final quality gate passed - afterward. +- Re-read and reviewed against `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, `docs/architecture/MODULE_MANIFESTS.md`, the first-consumer ARES specification and integration context, and relevant repository architecture context. +- Inspected status, whitespace errors, stats, all task-relevant tracked changes, both new files, and the final generated output. The review found and fixed one documentation placement issue and added a distinct atomic planner-failure case. The affected suite and final quality gate passed afterward. - No UI work or screenshots were applicable. ### Deviations and Follow-ups diff --git a/app/specs/chore-ci-current-system-contracts.md b/app/specs/chore-ci-current-system-contracts.md index a370d85c2..699491fa9 100644 --- a/app/specs/chore-ci-current-system-contracts.md +++ b/app/specs/chore-ci-current-system-contracts.md @@ -8,70 +8,38 @@ created: 2026-09-02 ## Chore Description -Implement [GitHub issue #170](https://github.com/TechsioCZ/ontos/issues/170) by replacing the stale, -hand-maintained deployment-impact logic in the repository workflow with a fail-closed plan derived -from the current topology and ownership metadata, and by expanding required CI evidence to cover the -important test and runtime surfaces that are presently omitted. - -The issue was written while the business MicroVertical was named Projects. Canonical `origin/main` -now contains the later Contacts rename, so implementation must use the current `shell-super-app` and -`contacts` identities. The deployment planner must derive application identities and owner paths -from `topology/reference-topology.json` and `topology/ownership.json` rather than embedding either -Projects or Contacts as the next hand-maintained workflow special case. - -A green workflow must separately identify failures in static checks, generated contracts, -Codesmith generators, ordinary unit tests, service-backed integration tests, Node artifacts, and -Cloudflare/workerd artifacts. PostgreSQL and SpiceDB evidence must run against fresh CI-owned -services initialized from the tracked repository contracts, never against a developer machine or a -persisted shared database. +Implement [GitHub issue #170](https://github.com/TechsioCZ/ontos/issues/170) by replacing the stale, hand-maintained deployment-impact logic in the repository workflow with a fail-closed plan derived from the current topology and ownership metadata, and by expanding required CI evidence to cover the important test and runtime surfaces that are presently omitted. + +The issue was written while the business MicroVertical was named Projects. Canonical `origin/main` now contains the later Contacts rename, so implementation must use the current `shell-super-app` and `contacts` identities. The deployment planner must derive application identities and owner paths from `topology/reference-topology.json` and `topology/ownership.json` rather than embedding either Projects or Contacts as the next hand-maintained workflow special case. + +A green workflow must separately identify failures in static checks, generated contracts, Codesmith generators, ordinary unit tests, service-backed integration tests, Node artifacts, and Cloudflare/workerd artifacts. PostgreSQL and SpiceDB evidence must run against fresh CI-owned services initialized from the tracked repository contracts, never against a developer machine or a persisted shared database. ## Relevant Files Use these files to accomplish the chore: -- `../.github/workflows/ultramodern-workspace-gates.yml` — GitHub's repository-root workflow, - required check matrix, isolated service setup, deployment-impact planning, and ordered stage - deployment. -- `package.json` — root command surface for unit, integration, repository-script, generator, - deployment-planner, Node, and Cloudflare evidence. -- `packages/core-runtime/package.json` — missing complete Core unit and integration command surfaces - needed by root CI commands. -- `docker-compose.yml` — tracked PostgreSQL 17 and SpiceDB 1.56 service definitions and health - checks to reuse for isolated service-backed CI jobs. -- `topology/reference-topology.json` — authoritative Shell, MicroVertical, delivery-unit, dependency, - runtime, and readiness identities consumed by the deployment planner. -- `topology/ownership.json` — authoritative owner paths used to map changed files to delivery units - and reject unknown application owners. -- `topology/local-overlays/development.json` — generated local runtime identities that the workspace - contract already reconciles with the reference topology. -- `zerops.yaml` — current `migrator`, `spicedb`, `contacts`, and `shellsuperapp` stage setup names and - ordered deployment targets that must agree with topology and CI. -- `scripts/validate-ultramodern-workspace.mts` — existing exact topology, ownership, generated - metadata, Zerops, toolchain, and workflow contract validator; extend its CI assertions rather than - creating a second validator. -- `scripts/scaffolding/tests/module-contract-generator.test.mts` — existing module-contract generator - regression suite currently outside required CI. -- `scripts/scaffolding/tests/scaffold-generators.test.mts` — existing Codesmith generator regression - suite currently outside required CI. -- `scripts/tests/*.test.mts` — existing repository tooling, boundary, local initialization, Locki, - Contacts authorization migration, and module-entrypoint regression suites currently outside the - workflow matrix. -- `packages/core-runtime/tests/integration/*.test.ts` — live PostgreSQL, RLS, SpiceDB authorization, - Action, identity, Outbox, tenant-isolation, module-state, and migration evidence. -- `apps/shell-super-app/tests/integration/*.test.ts` — Shell/Auth and generated-owner isolation - evidence across Core, PostgreSQL, SpiceDB, and generated BFF seams. -- `verticals/contacts/tests/integration/*.test.ts` — owner-local Contacts database, governed - operation, and BFF evidence. +- `../.github/workflows/ultramodern-workspace-gates.yml` — GitHub's repository-root workflow, required check matrix, isolated service setup, deployment-impact planning, and ordered stage deployment. +- `package.json` — root command surface for unit, integration, repository-script, generator, deployment-planner, Node, and Cloudflare evidence. +- `packages/core-runtime/package.json` — missing complete Core unit and integration command surfaces needed by root CI commands. +- `docker-compose.yml` — tracked PostgreSQL 17 and SpiceDB 1.56 service definitions and health checks to reuse for isolated service-backed CI jobs. +- `topology/reference-topology.json` — authoritative Shell, MicroVertical, delivery-unit, dependency, runtime, and readiness identities consumed by the deployment planner. +- `topology/ownership.json` — authoritative owner paths used to map changed files to delivery units and reject unknown application owners. +- `topology/local-overlays/development.json` — generated local runtime identities that the workspace contract already reconciles with the reference topology. +- `zerops.yaml` — current `migrator`, `spicedb`, `contacts`, and `shellsuperapp` stage setup names and ordered deployment targets that must agree with topology and CI. +- `scripts/validate-ultramodern-workspace.mts` — existing exact topology, ownership, generated metadata, Zerops, toolchain, and workflow contract validator; extend its CI assertions rather than creating a second validator. +- `scripts/scaffolding/tests/module-contract-generator.test.mts` — existing module-contract generator regression suite currently outside required CI. +- `scripts/scaffolding/tests/scaffold-generators.test.mts` — existing Codesmith generator regression suite currently outside required CI. +- `scripts/tests/*.test.mts` — existing repository tooling, boundary, local initialization, Locki, Contacts authorization migration, and module-entrypoint regression suites currently outside the workflow matrix. +- `packages/core-runtime/tests/integration/*.test.ts` — live PostgreSQL, RLS, SpiceDB authorization, Action, identity, Outbox, tenant-isolation, module-state, and migration evidence. +- `apps/shell-super-app/tests/integration/*.test.ts` — Shell/Auth and generated-owner isolation evidence across Core, PostgreSQL, SpiceDB, and generated BFF seams. +- `verticals/contacts/tests/integration/*.test.ts` — owner-local Contacts database, governed operation, and BFF evidence. - `scripts/proof-node-backend-federation.mts` — existing Node backend-federation artifact proof. -- `scripts/proof-workerd-ssr.mts` and `scripts/verify-cloudflare-output.mts` — existing - Cloudflare/workerd runtime and generated-output proofs. +- `scripts/proof-workerd-ssr.mts` and `scripts/verify-cloudflare-output.mts` — existing Cloudflare/workerd runtime and generated-output proofs. ### New Files -- `scripts/plan-deployment-impact.mts` — topology- and ownership-driven changed-path planner with a - CLI suitable for GitHub output and a deterministic JSON summary. -- `scripts/tests/plan-deployment-impact.test.mts` — focused regression coverage for change mapping, - rename safety, dependency expansion, unknown-owner rejection, and safe full-deploy fallbacks. +- `scripts/plan-deployment-impact.mts` — topology- and ownership-driven changed-path planner with a CLI suitable for GitHub output and a deterministic JSON summary. +- `scripts/tests/plan-deployment-impact.test.mts` — focused regression coverage for change mapping, rename safety, dependency expansion, unknown-owner rejection, and safe full-deploy fallbacks. ## Step by Step Tasks @@ -79,128 +47,60 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Add a fail-closed deployment-impact planner -- [x] Create `scripts/plan-deployment-impact.mts` as an infrastructure tool that reads the reference - topology and ownership files, accepts a base/head revision for production use and injectable - changed paths for tests, and emits a deterministic ordered plan plus GitHub outputs. -- [x] Derive Shell and MicroVertical IDs, package paths, Shell dependency edges, and owner paths from - tracked metadata. Keep only the fixed infrastructure phases (`migrator` and `spicedb`) as - infrastructure concepts; do not encode `crm`, `projects`, or `contacts` as a workflow registry. -- [x] Implement the change-impact rules from `docs/architecture/DEPLOYMENT.md`: owner-local changes - affect their delivery unit; migration/schema/config/verifier changes include the migrator; shared - runtime or public-contract changes expand to consumers; SpiceDB schema/bootstrap/runtime changes - include SpiceDB and its consumers; Shell changes include Shell; and common lockfile, workspace, - runtime-materializer, Node-installer, deployment-manifest, topology, or ownership changes expand - conservatively. -- [x] Fail with a targeted message when a changed path below `apps/*`, `verticals/*`, or `packages/*` - cannot be mapped to an authoritative owner, when topology and ownership identities disagree, or - when a topology delivery unit has no supported stage setup. Treat an all-zero, unavailable, or - non-ancestor base revision as a safe full deployment rather than a false no-op. -- [x] Add `scripts/tests/plan-deployment-impact.test.mts` beside this behavior. Cover Shell-only, - current Contacts-only, owner migration, shared-package, SpiceDB, lockfile/toolchain, topology, - docs-only, deletion/rename, unknown new vertical, missing ownership, and invalid base cases. Prove - that changing a topology ID in a fixture updates the plan without editing planner source. +- [x] Create `scripts/plan-deployment-impact.mts` as an infrastructure tool that reads the reference topology and ownership files, accepts a base/head revision for production use and injectable changed paths for tests, and emits a deterministic ordered plan plus GitHub outputs. +- [x] Derive Shell and MicroVertical IDs, package paths, Shell dependency edges, and owner paths from tracked metadata. Keep only the fixed infrastructure phases (`migrator` and `spicedb`) as infrastructure concepts; do not encode `crm`, `projects`, or `contacts` as a workflow registry. +- [x] Implement the change-impact rules from `docs/architecture/DEPLOYMENT.md`: owner-local changes affect their delivery unit; migration/schema/config/verifier changes include the migrator; shared runtime or public-contract changes expand to consumers; SpiceDB schema/bootstrap/runtime changes include SpiceDB and its consumers; Shell changes include Shell; and common lockfile, workspace, runtime-materializer, Node-installer, deployment-manifest, topology, or ownership changes expand conservatively. +- [x] Fail with a targeted message when a changed path below `apps/*`, `verticals/*`, or `packages/*` cannot be mapped to an authoritative owner, when topology and ownership identities disagree, or when a topology delivery unit has no supported stage setup. Treat an all-zero, unavailable, or non-ancestor base revision as a safe full deployment rather than a false no-op. +- [x] Add `scripts/tests/plan-deployment-impact.test.mts` beside this behavior. Cover Shell-only, current Contacts-only, owner migration, shared-package, SpiceDB, lockfile/toolchain, topology, docs-only, deletion/rename, unknown new vertical, missing ownership, and invalid base cases. Prove that changing a topology ID in a fixture updates the plan without editing planner source. ### 2. Expose complete, composable test commands -- [x] Add complete `test:unit` and `test:integration` scripts to - `packages/core-runtime/package.json` using the existing Node test layouts; keep the focused Action - and Outbox commands for area-specific local use. -- [x] Add root scripts in `package.json` for all workspace unit tests, all service-backed integration - tests, repository tooling tests, Codesmith/generation tests, and the deployment-impact planner - tests. Use recursive `--if-present` package execution where it accurately follows existing - package scripts, and keep generator and repository-script suites separate so their failures are - identifiable. -- [x] Keep `action:test:unit`, add the existing Core Action integration surface to the root command - set, and retain `outbox:test`. Do not make the local aggregate `check` run service-backed tests; - the workflow jobs own fresh-service lifecycle and the README already requires generated CI to run - separate gates. -- [x] Update the validation contract in `scripts/validate-ultramodern-workspace.mts` to require these - root/package command surfaces, preventing a later package rename or script removal from silently - reducing required evidence. +- [x] Add complete `test:unit` and `test:integration` scripts to `packages/core-runtime/package.json` using the existing Node test layouts; keep the focused Action and Outbox commands for area-specific local use. +- [x] Add root scripts in `package.json` for all workspace unit tests, all service-backed integration tests, repository tooling tests, Codesmith/generation tests, and the deployment-impact planner tests. Use recursive `--if-present` package execution where it accurately follows existing package scripts, and keep generator and repository-script suites separate so their failures are identifiable. +- [x] Keep `action:test:unit`, add the existing Core Action integration surface to the root command set, and retain `outbox:test`. Do not make the local aggregate `check` run service-backed tests; the workflow jobs own fresh-service lifecycle and the README already requires generated CI to run separate gates. +- [x] Update the validation contract in `scripts/validate-ultramodern-workspace.mts` to require these root/package command surfaces, preventing a later package rename or script removal from silently reducing required evidence. ### 3. Make the workspace contract validate CI and deployment agreement -- [x] Extend `scripts/validate-ultramodern-workspace.mts` to assert that the repository workflow uses - the pinned Node/pnpm toolchain, invokes the deployment planner, contains every required command - category, starts isolated service-backed evidence, and gates stage deployment on all required - jobs. -- [x] Reuse the validator's existing exact checks for reference topology, ownership, local overlay, - generated contract, package scripts, and `zerops.yaml`. Add focused cross-checks that every - topology delivery unit maps to the expected current Zerops setup/service-variable convention and - that workflow source contains no stale `crm` or `projects` deployment branch. -- [x] Require clear stable workflow job/step names for format, lint, typecheck, contracts, unit, - Action, repository tooling, generation, database/migration/RLS, authorization/Outbox, Node, and - Cloudflare evidence. Assertion failures must name the missing or disagreeing area. +- [x] Extend `scripts/validate-ultramodern-workspace.mts` to assert that the repository workflow uses the pinned Node/pnpm toolchain, invokes the deployment planner, contains every required command category, starts isolated service-backed evidence, and gates stage deployment on all required jobs. +- [x] Reuse the validator's existing exact checks for reference topology, ownership, local overlay, generated contract, package scripts, and `zerops.yaml`. Add focused cross-checks that every topology delivery unit maps to the expected current Zerops setup/service-variable convention and that workflow source contains no stale `crm` or `projects` deployment branch. +- [x] Require clear stable workflow job/step names for format, lint, typecheck, contracts, unit, Action, repository tooling, generation, database/migration/RLS, authorization/Outbox, Node, and Cloudflare evidence. Assertion failures must name the missing or disagreeing area. ### 4. Expand the fast required CI matrix -- [x] Update `../.github/workflows/ultramodern-workspace-gates.yml` so independent fast checks run in - parallel with descriptive names: formatting, lint, typecheck, skills, i18n/API/database-access/ - module-entrypoint/module-contract/workspace contracts, complete unit tests, Action unit tests, - repository tooling tests, and Codesmith/generation tests. -- [x] Run every application command from `app/` through `mise exec -- pnpm`, retain the frozen - install, pinned Node and pnpm versions, read-only checkout credentials, runner hardening, bounded - timeouts, and `fail-fast: false` where a matrix is retained. -- [x] Keep each failure attributable to one command category; do not replace the matrix with the - local aggregate `pnpm check` or hide several unrelated checks in one opaque step. +- [x] Update `../.github/workflows/ultramodern-workspace-gates.yml` so independent fast checks run in parallel with descriptive names: formatting, lint, typecheck, skills, i18n/API/database-access/ module-entrypoint/module-contract/workspace contracts, complete unit tests, Action unit tests, repository tooling tests, and Codesmith/generation tests. +- [x] Run every application command from `app/` through `mise exec -- pnpm`, retain the frozen install, pinned Node and pnpm versions, read-only checkout credentials, runner hardening, bounded timeouts, and `fail-fast: false` where a matrix is retained. +- [x] Keep each failure attributable to one command category; do not replace the matrix with the local aggregate `pnpm check` or hide several unrelated checks in one opaque step. ### 5. Add isolated PostgreSQL and SpiceDB evidence -- [x] Add one or more required Linux jobs that start the tracked `docker-compose.yml` services only - after checkout, with explicit CI-only admin/runtime database URLs and SpiceDB settings matching - `.env.example`, wait for both health checks, and always tear down containers and volumes. -- [x] On a fresh PostgreSQL volume, run `db:migrate` followed by `db:verify` before tests. This must - prove the Core, Auth, and Contacts migration journals, exact schemas, and runtime-role grants; do - not use `db:generate` as a substitute for applying and verifying migrations. -- [x] Run the complete workspace integration surface against those services so Core tenant/RLS, - Action, authorization, identity, Outbox, module-state, migration, Shell/Auth, generated-owner, - Contacts database, governed operation, and BFF tests are required evidence rather than tests that - depend on a developer's local containers. -- [x] Use descriptive setup and test step names, preserve test-runner failure output, and print only - bounded container diagnostics on failure. Never print credentials or read a local `.env` file. +- [x] Add one or more required Linux jobs that start the tracked `docker-compose.yml` services only after checkout, with explicit CI-only admin/runtime database URLs and SpiceDB settings matching `.env.example`, wait for both health checks, and always tear down containers and volumes. +- [x] On a fresh PostgreSQL volume, run `db:migrate` followed by `db:verify` before tests. This must prove the Core, Auth, and Contacts migration journals, exact schemas, and runtime-role grants; do not use `db:generate` as a substitute for applying and verifying migrations. +- [x] Run the complete workspace integration surface against those services so Core tenant/RLS, Action, authorization, identity, Outbox, module-state, migration, Shell/Auth, generated-owner, Contacts database, governed operation, and BFF tests are required evidence rather than tests that depend on a developer's local containers. +- [x] Use descriptive setup and test step names, preserve test-runner failure output, and print only bounded container diagnostics on failure. Never print credentials or read a local `.env` file. ### 6. Require both supported runtime proofs -- [x] Add a required Node runtime job that performs the repository build and then runs `node:proof`, - with the source revision and public-site inputs already used by generated builds. Keep the Node - proof distinct from source-level contract and type checks. -- [x] Add a required Cloudflare runtime job that runs `cloudflare:build`. This command must continue - to build every topology app, verify generated Cloudflare output, and execute the Miniflare/workerd - SSR proof; no real Cloudflare deployment or public URL is required in pull-request CI. -- [x] Give both runtime jobs separate bounded timeouts and artifact-specific failure names so a Node - backend-federation failure cannot be confused with a Cloudflare/workerd failure. +- [x] Add a required Node runtime job that performs the repository build and then runs `node:proof`, with the source revision and public-site inputs already used by generated builds. Keep the Node proof distinct from source-level contract and type checks. +- [x] Add a required Cloudflare runtime job that runs `cloudflare:build`. This command must continue to build every topology app, verify generated Cloudflare output, and execute the Miniflare/workerd SSR proof; no real Cloudflare deployment or public URL is required in pull-request CI. +- [x] Give both runtime jobs separate bounded timeouts and artifact-specific failure names so a Node backend-federation failure cannot be confused with a Cloudflare/workerd failure. ### 7. Drive stage deployment from the reviewed impact plan -- [x] Replace the workflow's hand-written changed-path `case` block with - `plan-deployment-impact.mts`, record its ordered JSON in the GitHub step summary, and expose only - the booleans/data needed by later steps. A docs-only change may produce a reviewed no-op; an - unknown application path must stop deployment planning. -- [x] Resolve stage service IDs through the planner's current unit identities and the existing - environment-variable convention without logging the values. Preserve the required order: - verified migrations, SpiceDB when affected, every affected provider MicroVertical, then Shell. -- [x] Generalize failure-log collection to report the failed planned unit with bounded output, and - keep the migrator stop step unconditional after any attempted migration run. -- [x] Make stage deployment depend on every fast, service-backed, Node, and Cloudflare required job, - so no partial green matrix can promote a revision. +- [x] Replace the workflow's hand-written changed-path `case` block with `plan-deployment-impact.mts`, record its ordered JSON in the GitHub step summary, and expose only the booleans/data needed by later steps. A docs-only change may produce a reviewed no-op; an unknown application path must stop deployment planning. +- [x] Resolve stage service IDs through the planner's current unit identities and the existing environment-variable convention without logging the values. Preserve the required order: verified migrations, SpiceDB when affected, every affected provider MicroVertical, then Shell. +- [x] Generalize failure-log collection to report the failed planned unit with bounded output, and keep the migrator stop step unconditional after any attempted migration run. +- [x] Make stage deployment depend on every fast, service-backed, Node, and Cloudflare required job, so no partial green matrix can promote a revision. ### 8. Run the complete validation sequence -- [x] Execute every command in `Validation Commands` from `app/`, inspect the final diff for stale - CRM/Projects workflow assumptions and accidental generated output, and confirm that only issue - #170 infrastructure, test-command, and validation files changed. +- [x] Execute every command in `Validation Commands` from `app/`, inspect the final diff for stale CRM/Projects workflow assumptions and accidental generated output, and confirm that only issue #170 infrastructure, test-command, and validation files changed. ## Testing Strategy -Add deterministic Node tests for the deployment planner and run every existing test family through -explicit root scripts. Planner fixtures must prove ordinary mapping, dependency expansion, current -identity derivation, fail-closed topology/ownership disagreement, and full-deploy fallback behavior. -The workspace contract validator supplies regression coverage for required workflow commands, -service-backed jobs, toolchain pins, stage dependencies, Zerops setup agreement, and stale names. +Add deterministic Node tests for the deployment planner and run every existing test family through explicit root scripts. Planner fixtures must prove ordinary mapping, dependency expansion, current identity derivation, fail-closed topology/ownership disagreement, and full-deploy fallback behavior. The workspace contract validator supplies regression coverage for required workflow commands, service-backed jobs, toolchain pins, stage dependencies, Zerops setup agreement, and stale names. -CI integration coverage must create fresh PostgreSQL and SpiceDB services, apply and verify all -migrations, and then run the existing cross-boundary suites. Node and Cloudflare jobs must execute -the generated runtime artifact proofs rather than stopping after compilation. +CI integration coverage must create fresh PostgreSQL and SpiceDB services, apply and verify all migrations, and then run the existing cross-boundary suites. Node and Cloudflare jobs must execute the generated runtime artifact proofs rather than stopping after compilation. Important failure cases include: @@ -214,46 +114,28 @@ Important failure cases include: ## Acceptance Criteria -- [x] Deployment impact uses the current `shell-super-app` and `contacts` topology without a - hard-coded CRM, Projects, or Contacts changed-path registry. -- [x] A changed application path that cannot be mapped through topology and ownership fails CI with - the exact unknown path and area; invalid comparison bases conservatively request a full deploy. -- [x] Required CI jobs clearly cover format, lint, typecheck, skills, boundaries/contracts, complete - unit tests, Action tests, repository scripts, Codesmith/generation tests, Node proof, and - Cloudflare/workerd proof. -- [x] Fresh isolated PostgreSQL and SpiceDB services back required integration jobs, and their - lifecycle neither reads a developer `.env` nor reuses persisted data. -- [x] The required evidence applies and verifies Core, Auth, and Contacts migrations and exercises - database constraints, tenant/legal-entity RLS, authorization, Actions, identity, Outbox, - module-state, generated-owner isolation, and Contacts governed operations. -- [x] `contract:check` fails with a targeted message when workflow commands, deployment setup, - topology, ownership, local overlay, package scripts, or generated contracts disagree. -- [x] Stage deployment consumes the reviewed ordered impact plan and cannot run unless every fast, - service-backed, Node, and Cloudflare required job succeeds. -- [x] Job and step names identify the failed area, while failure diagnostics remain bounded and do - not expose credentials. -- [x] Existing application behavior, public contracts, MicroVertical boundaries, and generated - business files are unchanged. +- [x] Deployment impact uses the current `shell-super-app` and `contacts` topology without a hard-coded CRM, Projects, or Contacts changed-path registry. +- [x] A changed application path that cannot be mapped through topology and ownership fails CI with the exact unknown path and area; invalid comparison bases conservatively request a full deploy. +- [x] Required CI jobs clearly cover format, lint, typecheck, skills, boundaries/contracts, complete unit tests, Action tests, repository scripts, Codesmith/generation tests, Node proof, and Cloudflare/workerd proof. +- [x] Fresh isolated PostgreSQL and SpiceDB services back required integration jobs, and their lifecycle neither reads a developer `.env` nor reuses persisted data. +- [x] The required evidence applies and verifies Core, Auth, and Contacts migrations and exercises database constraints, tenant/legal-entity RLS, authorization, Actions, identity, Outbox, module-state, generated-owner isolation, and Contacts governed operations. +- [x] `contract:check` fails with a targeted message when workflow commands, deployment setup, topology, ownership, local overlay, package scripts, or generated contracts disagree. +- [x] Stage deployment consumes the reviewed ordered impact plan and cannot run unless every fast, service-backed, Node, and Cloudflare required job succeeds. +- [x] Job and step names identify the failed area, while failure diagnostics remain bounded and do not expose credentials. +- [x] Existing application behavior, public contracts, MicroVertical boundaries, and generated business files are unchanged. ## Validation Commands Execute every command to validate the chore with zero regressions. -- `mise exec -- pnpm test:deployment-impact` — Verify topology-driven impact mapping, dependency - expansion, rename safety, and fail-closed behavior. +- `mise exec -- pnpm test:deployment-impact` — Verify topology-driven impact mapping, dependency expansion, rename safety, and fail-closed behavior. - `mise exec -- pnpm test:scripts` — Run repository tooling and boundary regression suites. - `mise exec -- pnpm test:generation` — Run the Codesmith and module-contract generator suites. -- `mise exec -- pnpm test:unit` — Run complete unit/component coverage across packages, Shell, and - Contacts. -- `mise exec -- pnpm contract:check` — Reconcile workflow, toolchain, topology, ownership, generated - metadata, package scripts, and Zerops deployment contracts. -- `mise exec -- pnpm db:migrate && mise exec -- pnpm db:verify && mise exec -- pnpm test:integration` - — Against fresh tracked PostgreSQL and SpiceDB services, apply and verify migrations and run the - complete service-backed integration evidence. -- `mise exec -- pnpm build && mise exec -- pnpm node:proof` — Build and prove the Node deployment and - backend-federation artifact shape. -- `mise exec -- pnpm cloudflare:build` — Build, verify, and execute the Cloudflare/workerd artifact - shape locally. +- `mise exec -- pnpm test:unit` — Run complete unit/component coverage across packages, Shell, and Contacts. +- `mise exec -- pnpm contract:check` — Reconcile workflow, toolchain, topology, ownership, generated metadata, package scripts, and Zerops deployment contracts. +- `mise exec -- pnpm db:migrate && mise exec -- pnpm db:verify && mise exec -- pnpm test:integration` — Against fresh tracked PostgreSQL and SpiceDB services, apply and verify migrations and run the complete service-backed integration evidence. +- `mise exec -- pnpm build && mise exec -- pnpm node:proof` — Build and prove the Node deployment and backend-federation artifact shape. +- `mise exec -- pnpm cloudflare:build` — Build, verify, and execute the Cloudflare/workerd artifact shape locally. - `mise exec -- pnpm check` — Run the final repository quality gate. ## Review Checklist @@ -265,77 +147,41 @@ Execute every command to validate the chore with zero regressions. ## Notes -- Issue #170's Projects wording is historical. The Projects rename merged on 2026-08-28; canonical - `origin/main` subsequently renamed that MicroVertical to Contacts on 2026-09-01. This plan follows - the issue's intent—CI must describe the current system—by deriving names from topology and using - Contacts only as the current fixture/evidence identity. -- The checkout used to write this plan was two commits behind `origin/main` and already contained - unrelated changes to `docs/contexts/ontos/CONTEXT.md` plus an untracked Contacts-rename spec. - Implementation must start from current `main` and must not absorb or overwrite those unrelated - changes. -- GitHub branch-protection settings are outside the tracked repository surfaces inspected for this - plan. The workflow will expose stable, descriptive required job names; selecting those names as - protected required checks remains repository administration if it is not already configured. -- Browser E2E and live stage smoke expansion are not added by this bounded CI-reliability chore. - Existing Node, workerd, database, authorization, contract, and integration proofs satisfy issue - #170; the broader authenticated post-deploy smoke suite remains governed by - `docs/architecture/DEPLOYMENT.md`. +- Issue #170's Projects wording is historical. The Projects rename merged on 2026-08-28; canonical `origin/main` subsequently renamed that MicroVertical to Contacts on 2026-09-01. This plan follows the issue's intent—CI must describe the current system—by deriving names from topology and using Contacts only as the current fixture/evidence identity. +- The checkout used to write this plan was two commits behind `origin/main` and already contained unrelated changes to `docs/contexts/ontos/CONTEXT.md` plus an untracked Contacts-rename spec. Implementation must start from current `main` and must not absorb or overwrite those unrelated changes. +- GitHub branch-protection settings are outside the tracked repository surfaces inspected for this plan. The workflow will expose stable, descriptive required job names; selecting those names as protected required checks remains repository administration if it is not already configured. +- Browser E2E and live stage smoke expansion are not added by this bounded CI-reliability chore. Existing Node, workerd, database, authorization, contract, and integration proofs satisfy issue #170; the broader authenticated post-deploy smoke suite remains governed by `docs/architecture/DEPLOYMENT.md`. ## Implementation Evidence ### Summary -- Added a deterministic, fail-closed deployment-impact planner derived from reference topology, - ownership, and current Zerops setups, with ordered migrator, SpiceDB, provider, and Shell phases. -- Expanded the required GitHub workflow into attributable fast, isolated service-integration, Node - artifact, Cloudflare/workerd artifact, and reviewed stage-deployment gates. -- Added complete root and Core test command surfaces and exact workspace-contract assertions so - removing a required command, job, setup, dependency, or current identity fails validation. +- Added a deterministic, fail-closed deployment-impact planner derived from reference topology, ownership, and current Zerops setups, with ordered migrator, SpiceDB, provider, and Shell phases. +- Expanded the required GitHub workflow into attributable fast, isolated service-integration, Node artifact, Cloudflare/workerd artifact, and reviewed stage-deployment gates. +- Added complete root and Core test command surfaces and exact workspace-contract assertions so removing a required command, job, setup, dependency, or current identity fails validation. ### Changed Files -- CI and planning: `../.github/workflows/ultramodern-workspace-gates.yml`, - `scripts/plan-deployment-impact.mts`, and `scripts/tests/plan-deployment-impact.test.mts`. -- Command and contract surfaces: `package.json`, `packages/core-runtime/package.json`, - `scripts/validate-ultramodern-workspace.mts`, and `scripts/check-ultramodern-api-boundaries.mts`. -- Runtime-proof compatibility: Shell and Contacts `modern.config.ts`, `scripts/proof-workerd-ssr.mts`, - the worker-only Shell remote stub, Modern app-tools/BFF patches, `pnpm-workspace.yaml`, and - `pnpm-lock.yaml`. -- Generator repair: `scripts/generate-ontos-module-contract.mts` now uses the current Effect endpoint - identifier field when deriving operation keys. +- CI and planning: `../.github/workflows/ultramodern-workspace-gates.yml`, `scripts/plan-deployment-impact.mts`, and `scripts/tests/plan-deployment-impact.test.mts`. +- Command and contract surfaces: `package.json`, `packages/core-runtime/package.json`, `scripts/validate-ultramodern-workspace.mts`, and `scripts/check-ultramodern-api-boundaries.mts`. +- Runtime-proof compatibility: Shell and Contacts `modern.config.ts`, `scripts/proof-workerd-ssr.mts`, the worker-only Shell remote stub, Modern app-tools/BFF patches, `pnpm-workspace.yaml`, and `pnpm-lock.yaml`. +- Generator repair: `scripts/generate-ontos-module-contract.mts` now uses the current Effect endpoint identifier field when deriving operation keys. ### Tests and Validation - `mise exec -- pnpm test:deployment-impact` — passed 22 tests. - `mise exec -- pnpm test:scripts` — passed 37 tests. - `mise exec -- pnpm test:generation` — passed 47 tests. -- `mise exec -- pnpm test:unit` — passed the complete Core, shared-package, Shell, Contacts unit, and - component surfaces. -- `mise exec -- pnpm contract:check` — passed exact workflow, toolchain, topology, ownership, - generated-metadata, package-script, and Zerops reconciliation. -- Fresh tracked PostgreSQL and SpiceDB services with CI-only environment values: - `mise exec -- pnpm db:migrate && mise exec -- pnpm db:verify && mise exec -- pnpm test:integration` - — passed migration, schema/grant, Core, Auth, Shell, Contacts, RLS, Action, authorization, identity, - Outbox, module-state, and governed-operation evidence. -- `mise exec -- pnpm build && mise exec -- pnpm node:proof` — passed Node build and backend-federation - artifact proof. -- `mise exec -- pnpm cloudflare:build` — passed all topology app builds, output verification, native - Shell and Contacts workerd SSR, and direct/service-bound Contacts readiness evidence. +- `mise exec -- pnpm test:unit` — passed the complete Core, shared-package, Shell, Contacts unit, and component surfaces. +- `mise exec -- pnpm contract:check` — passed exact workflow, toolchain, topology, ownership, generated-metadata, package-script, and Zerops reconciliation. +- Fresh tracked PostgreSQL and SpiceDB services with CI-only environment values: `mise exec -- pnpm db:migrate && mise exec -- pnpm db:verify && mise exec -- pnpm test:integration` — passed migration, schema/grant, Core, Auth, Shell, Contacts, RLS, Action, authorization, identity, Outbox, module-state, and governed-operation evidence. +- `mise exec -- pnpm build && mise exec -- pnpm node:proof` — passed Node build and backend-federation artifact proof. +- `mise exec -- pnpm cloudflare:build` — passed all topology app builds, output verification, native Shell and Contacts workerd SSR, and direct/service-bound Contacts readiness evidence. - `mise exec -- pnpm check` — passed the final repository quality gate. ### Review -- Final status and diff review found no tracked build output, generated business-file changes, - stale CRM/Projects workflow branches, credential logging, unrelated application behavior changes, - or public-contract expansion. -- The mandated Cloudflare gate exposed latent current-stack incompatibilities: Node builtin - external interop, Effect-BFF worker-source propagation, loader retention, Effect finalizer setup, - and verifier knowledge of supported workerd imports. The narrow config and dependency patches are - required to make the existing runtime proof execute real worker artifacts rather than browser - proxies or stubs. -- The same gate exposed two existing validation defects: the module-contract generator read the - obsolete endpoint name field, and the API-boundary scanner included generated Cloudflare output - and orphan package-link directories. Both were repaired and covered by the existing generation, - contract, and final quality gates. -- No blocker remains. No browser E2E or live stage deployment was performed, as explicitly excluded - by this specification. +- Final status and diff review found no tracked build output, generated business-file changes, stale CRM/Projects workflow branches, credential logging, unrelated application behavior changes, or public-contract expansion. +- The mandated Cloudflare gate exposed latent current-stack incompatibilities: Node builtin external interop, Effect-BFF worker-source propagation, loader retention, Effect finalizer setup, and verifier knowledge of supported workerd imports. The narrow config and dependency patches are required to make the existing runtime proof execute real worker artifacts rather than browser proxies or stubs. +- The same gate exposed two existing validation defects: the module-contract generator read the obsolete endpoint name field, and the API-boundary scanner included generated Cloudflare output and orphan package-link directories. Both were repaired and covered by the existing generation, contract, and final quality gates. +- No blocker remains. No browser E2E or live stage deployment was performed, as explicitly excluded by this specification. diff --git a/app/specs/chore-crm-customer-business-fields-regression.md b/app/specs/chore-crm-customer-business-fields-regression.md index 7f4b3c827..a8c3de8e3 100644 --- a/app/specs/chore-crm-customer-business-fields-regression.md +++ b/app/specs/chore-crm-customer-business-fields-regression.md @@ -8,10 +8,7 @@ created: 2026-08-17 ## Chore Description -Complete and reconcile the Czech/English copy, fixtures, architecture assertions, migration checks, -and end-to-end regression coverage after the ten Customer/ARES feature specs are implemented. This -is a final verification task, not a place to defer behavior-specific tests already required by each -feature. +Complete and reconcile the Czech/English copy, fixtures, architecture assertions, migration checks, and end-to-end regression coverage after the ten Customer/ARES feature specs are implemented. This is a final verification task, not a place to defer behavior-specific tests already required by each feature. ## Relevant Files @@ -58,10 +55,7 @@ IMPORTANT: Execute every step in order, top to bottom. ## Testing Strategy -Behavior-specific unit, component, and integration tests remain beside their owning changes. This -chore adds only cross-flow and architecture regression coverage, then runs the complete CRM and -repository gates to catch mismatched fixtures, locales, generated contracts, schema output, or -deployment builds. +Behavior-specific unit, component, and integration tests remain beside their owning changes. This chore adds only cross-flow and architecture regression coverage, then runs the complete CRM and repository gates to catch mismatched fixtures, locales, generated contracts, schema output, or deployment builds. ## Acceptance Criteria @@ -105,33 +99,21 @@ Execute every command to validate the chore with zero regressions. ### Summary -- Reconciled all ten completed Customer/ARES specifications into one cross-cutting regression gate - for the canonical flat Customer fields, Czech/English copy, complete/null fixtures, browser/BFF - boundaries, ARES server ownership, and excluded address/metadata/activity scope. -- Extended the existing governed integration scenario only for the missing ARES-prefill followed by - edit/detail/list flow; the already-owned manual-create, duplicate-IČO, archive/unarchive, tenant, - and null-clearing proofs remain in their feature tests. -- Strengthened physical schema verification to reject any unexpected CRM Customer/Contact column, - and corrected the contact-detail route boundary exposed by a clean final typecheck. +- Reconciled all ten completed Customer/ARES specifications into one cross-cutting regression gate for the canonical flat Customer fields, Czech/English copy, complete/null fixtures, browser/BFF boundaries, ARES server ownership, and excluded address/metadata/activity scope. +- Extended the existing governed integration scenario only for the missing ARES-prefill followed by edit/detail/list flow; the already-owned manual-create, duplicate-IČO, archive/unarchive, tenant, and null-clearing proofs remain in their feature tests. +- Strengthened physical schema verification to reject any unexpected CRM Customer/Contact column, and corrected the contact-detail route boundary exposed by a clean final typecheck. ### Changed Files -- Seven files changed with 498 insertions and 24 deletions: five tracked implementation, - documentation, fixture, and integration-test files plus one new architecture regression test and - this completed specification. +- Seven files changed with 498 insertions and 24 deletions: five tracked implementation, documentation, fixture, and integration-test files plus one new architecture regression test and this completed specification. ### Tests Written or Updated -- `verticals/crm/tests/unit/customer-business-fields-regression.test.ts` — proves locale/fixture - parity, leading-zero/date-only fixtures, generated Effect-client browser access, server-only ARES, - generated Read ownership, presentation isolation, and absence of excluded business scope. -- `verticals/crm/tests/integration/customer-contact-operations.test.ts` — extends the governed - runtime proof through leading-zero ARES lookup, reviewed create, edit, detail, and list parity. -- `verticals/crm/tests/support/e2e-customers.ts` — makes complete and nullable business fields - explicit in both typed fixtures and database seed SQL. +- `verticals/crm/tests/unit/customer-business-fields-regression.test.ts` — proves locale/fixture parity, leading-zero/date-only fixtures, generated Effect-client browser access, server-only ARES, generated Read ownership, presentation isolation, and absence of excluded business scope. +- `verticals/crm/tests/integration/customer-contact-operations.test.ts` — extends the governed runtime proof through leading-zero ARES lookup, reviewed create, edit, detail, and list parity. +- `verticals/crm/tests/support/e2e-customers.ts` — makes complete and nullable business fields explicit in both typed fixtures and database seed SQL. - `verticals/crm/scripts/verify-db-schema.mts` — validates the exact physical CRM column inventory. -- Existing component tests cover the contact-detail route behavior; typecheck and the production - build prove its corrected router-component contract. +- Existing component tests cover the contact-detail route behavior; typecheck and the production build prove its corrected router-component contract. ### Validation @@ -150,23 +132,12 @@ Execute every command to validate the chore with zero regressions. ### Review -- Re-read and reviewed the final diff against `../AGENTS.md`, `AGENTS.md`, the full specification, - `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, `DATABASE.md`, `DATA_ACCESS.md`, - `MODULE_ENTRYPOINTS.md`, `MODULE_MANIFESTS.md`, `ULTRAMODERN.md`, `FRONTEND.md`, and the ARES - integration guidance. -- Fixed the review findings: lint-safe parallel architecture scans, exact schema-column validation, - an internally consistent exact-eight-digit ARES policy, and a prop-free default contact-detail - route export. The final diff has no remaining blocker, dead code, unrelated change, boundary - violation, accidental API expansion, or untested behavior. -- No browser screenshot was retained because this chore changes regression infrastructure and a - router boundary without changing the rendered UI; deterministic component tests and the final - production build provide the relevant evidence. +- Re-read and reviewed the final diff against `../AGENTS.md`, `AGENTS.md`, the full specification, `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, `DATABASE.md`, `DATA_ACCESS.md`, `MODULE_ENTRYPOINTS.md`, `MODULE_MANIFESTS.md`, `ULTRAMODERN.md`, `FRONTEND.md`, and the ARES integration guidance. +- Fixed the review findings: lint-safe parallel architecture scans, exact schema-column validation, an internally consistent exact-eight-digit ARES policy, and a prop-free default contact-detail route export. The final diff has no remaining blocker, dead code, unrelated change, boundary violation, accidental API expansion, or untested behavior. +- No browser screenshot was retained because this chore changes regression infrastructure and a router boundary without changing the rendered UI; deterministic component tests and the final production build provide the relevant evidence. ### Deviations and Follow-ups -- A fresh worktree required dependency installation, database migration, and no-check declaration - materialization for referenced packages before the exact CRM package typecheck could run. The - final exact command passed without weakening its checks. -- The literal dirty-worktree build compiled successfully but its release-envelope guard correctly - rejected `sourceRevision "workspace"`; the same final source passed with the immutable base revision. +- A fresh worktree required dependency installation, database migration, and no-check declaration materialization for referenced packages before the exact CRM package typecheck could run. The final exact command passed without weakening its checks. +- The literal dirty-worktree build compiled successfully but its release-envelope guard correctly rejected `sourceRevision "workspace"`; the same final source passed with the immutable base revision. - No product or architecture follow-up remains. diff --git a/app/specs/chore-extend-microvertical-page-generator.md b/app/specs/chore-extend-microvertical-page-generator.md index 705cfbd1c..f1f86bdf0 100644 --- a/app/specs/chore-extend-microvertical-page-generator.md +++ b/app/specs/chore-extend-microvertical-page-generator.md @@ -8,28 +8,11 @@ created: 2026-08-13 ## Chore Description -Extend the existing Codesmith `scaffold:microvertical-page` command so a developer can generate a -named page for an existing MicroVertical at an explicit root-relative URL. The current generator -already accepts `--vertical` and `--page`, creates governed page and Shell wiring, and defaults to a -private, non-indexable route. It does not accept a URL independently of the page identifier, and its -starter renders a description and empty-state message in addition to the title. - -Keep `--page` as the stable lower-kebab page name used for component, locale, entrypoint, and Module -Federation identities. Add `--url ` as a supported customization. When it is -omitted, derive the canonical path as `//` from the discovered MicroVertical -slug and page name, so `--vertical crm --page customers` produces `/crm/customers`. The i18n router -adds the active locale at runtime, yielding `/cs/crm/customers` or `/en/crm/customers`; locale text -must never be embedded in the generator input or canonical manifest path. Use an explicit URL to -override the complete canonical path. Use the resulting URL to choose the nested TanStack route -directories and every canonical/localized/Shell contribution path while continuing to use the page -name for stable identifiers. The generated page's only visible content must be its localized title; -route-head metadata may retain a non-visible localized description. - -The canonical user-facing route remains the Shell-owned connector. It must resolve the exact page -entrypoint through the existing authenticated Shell session, selected legal entity, module-state, -and module-permission gates before loading the private remote component. Anonymous or unresolved -context must not load or render the generated MicroVertical page. No page-specific authentication, -backend call, Action, BFF, React state, or shared component is added to the empty starter. +Extend the existing Codesmith `scaffold:microvertical-page` command so a developer can generate a named page for an existing MicroVertical at an explicit root-relative URL. The current generator already accepts `--vertical` and `--page`, creates governed page and Shell wiring, and defaults to a private, non-indexable route. It does not accept a URL independently of the page identifier, and its starter renders a description and empty-state message in addition to the title. + +Keep `--page` as the stable lower-kebab page name used for component, locale, entrypoint, and Module Federation identities. Add `--url ` as a supported customization. When it is omitted, derive the canonical path as `//` from the discovered MicroVertical slug and page name, so `--vertical crm --page customers` produces `/crm/customers`. The i18n router adds the active locale at runtime, yielding `/cs/crm/customers` or `/en/crm/customers`; locale text must never be embedded in the generator input or canonical manifest path. Use an explicit URL to override the complete canonical path. Use the resulting URL to choose the nested TanStack route directories and every canonical/localized/Shell contribution path while continuing to use the page name for stable identifiers. The generated page's only visible content must be its localized title; route-head metadata may retain a non-visible localized description. + +The canonical user-facing route remains the Shell-owned connector. It must resolve the exact page entrypoint through the existing authenticated Shell session, selected legal entity, module-state, and module-permission gates before loading the private remote component. Anonymous or unresolved context must not load or render the generated MicroVertical page. No page-specific authentication, backend call, Action, BFF, React state, or shared component is added to the empty starter. ## Relevant Files @@ -106,15 +89,7 @@ IMPORTANT: Execute every step in order, top to bottom. ## Testing Strategy -Use the existing Node test runner against temporary workspaces for the generator's CLI, URL parser, -path planning, exact output, atomic owner wiring, legacy migration, rerun, collision, traversal, and -no-partial-write behavior. Use focused Shell unit and integration tests for the typed exact-page -request and authenticated gateway. Retain the module-only landing route as a compatibility case. -Use the existing Playwright authenticated CRM flow for the generated title-only starter and one -anonymous direct-route assertion; no business loading, empty, validation, conflict, or retry UI is -generated because the starter performs no business operation. Shell loading, selection-required, -forbidden, not-found, and unavailable states remain explicit and are tested at their existing -integration boundary. +Use the existing Node test runner against temporary workspaces for the generator's CLI, URL parser, path planning, exact output, atomic owner wiring, legacy migration, rerun, collision, traversal, and no-partial-write behavior. Use focused Shell unit and integration tests for the typed exact-page request and authenticated gateway. Retain the module-only landing route as a compatibility case. Use the existing Playwright authenticated CRM flow for the generated title-only starter and one anonymous direct-route assertion; no business loading, empty, validation, conflict, or retry UI is generated because the starter performs no business operation. Shell loading, selection-required, forbidden, not-found, and unavailable states remain explicit and are tested at their existing integration boundary. ## Acceptance Criteria @@ -154,33 +129,13 @@ Execute every command to validate the chore with zero regressions. ## Implementation Evidence - `mise exec -- pnpm exec tsc -p scripts/scaffolding/tsconfig.json` passed. -- Focused page-generator coverage now proves formatted multi-page owner slots, exact current/legacy - wiring (including conflicting duplicates), one- and multi-segment URLs, two-letter owner slugs, - exact page identities, general explicit locale-prefix rejection, and reserved, dynamic, and - cross-owner collision preflight. It also executes a newly generated federated page with the - owner's English and Czech resources. The full scaffolding suite passed 35/38; its three failures - are unrelated existing baselines: a stale Action fixture missing - `--legal-entity-scope`, unavailable generated gateway-issuer keys, and a disposable typecheck - fixture missing `system-principal-context-provenance.ts`. The formatter-stability generator test - passed. -- `mise exec -- pnpm scaffold:microvertical-page -- --help` passed and documented the optional URL - plus `//` default. A read-only real-workspace plan reports zero mutations for the - migrated CRM page and twelve mutations for a new CRM `customers` page; `/modules/bad` is rejected - against the existing `[moduleId]` route. -- `mise exec -- pnpm --filter @app/shell-super-app test:unit` passed 146/146 tests. Exact-page UI - coverage proves that private registries and loaders remain untouched for selection-required, - forbidden, not-found, and unavailable outcomes and run once only after resolution. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` was attempted. The suite parsed - the added exact-page `401`/`409`/`200`/`403`/`404`/`503` assertions but the live auth tests stopped - at the existing root configuration prerequisite (`AuthConfigError`/missing `DATABASE_URL`). -- The focused Playwright command was attempted and stopped at the existing root development - environment prerequisite before browser execution. Checked-in coverage now asserts anonymous and - authenticated English/Czech CRM behavior. -- `mise exec -- pnpm check` passed formatting, application lint, 58/58 Core Action tests, - typechecking, and skills checks before stopping on the unchanged CRM `modern.runtime.ts` i18n - boundary baseline. -- `mise exec -- pnpm build` completed the Shell production and TypeScript build, then stopped at the - existing missing CRM Module Federation DTS archive prerequisite. +- Focused page-generator coverage now proves formatted multi-page owner slots, exact current/legacy wiring (including conflicting duplicates), one- and multi-segment URLs, two-letter owner slugs, exact page identities, general explicit locale-prefix rejection, and reserved, dynamic, and cross-owner collision preflight. It also executes a newly generated federated page with the owner's English and Czech resources. The full scaffolding suite passed 35/38; its three failures are unrelated existing baselines: a stale Action fixture missing `--legal-entity-scope`, unavailable generated gateway-issuer keys, and a disposable typecheck fixture missing `system-principal-context-provenance.ts`. The formatter-stability generator test passed. +- `mise exec -- pnpm scaffold:microvertical-page -- --help` passed and documented the optional URL plus `//` default. A read-only real-workspace plan reports zero mutations for the migrated CRM page and twelve mutations for a new CRM `customers` page; `/modules/bad` is rejected against the existing `[moduleId]` route. +- `mise exec -- pnpm --filter @app/shell-super-app test:unit` passed 146/146 tests. Exact-page UI coverage proves that private registries and loaders remain untouched for selection-required, forbidden, not-found, and unavailable outcomes and run once only after resolution. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` was attempted. The suite parsed the added exact-page `401`/`409`/`200`/`403`/`404`/`503` assertions but the live auth tests stopped at the existing root configuration prerequisite (`AuthConfigError`/missing `DATABASE_URL`). +- The focused Playwright command was attempted and stopped at the existing root development environment prerequisite before browser execution. Checked-in coverage now asserts anonymous and authenticated English/Czech CRM behavior. +- `mise exec -- pnpm check` passed formatting, application lint, 58/58 Core Action tests, typechecking, and skills checks before stopping on the unchanged CRM `modern.runtime.ts` i18n boundary baseline. +- `mise exec -- pnpm build` completed the Shell production and TypeScript build, then stopped at the existing missing CRM Module Federation DTS archive prerequisite. ## Notes diff --git a/app/specs/chore-postgres-drizzle-schema-foundation.md b/app/specs/chore-postgres-drizzle-schema-foundation.md index 135869bc4..70a3f1cff 100644 --- a/app/specs/chore-postgres-drizzle-schema-foundation.md +++ b/app/specs/chore-postgres-drizzle-schema-foundation.md @@ -8,117 +8,57 @@ created: 2026-07-29 ## Chore Description -Establish the local PostgreSQL and typed Drizzle foundation for the OntOS -application on `develop`, using the read-only MVP2 experiment at -`/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2` as the -schema reference. - -Add a root Compose project named `ontos` with a PostgreSQL 17 Alpine service -and container named `ontos-db`, a persistent named volume, a health check, and -an `ontos` database. Add a committed root `.env.example` containing the local -connection values and keep the developer's `.env` untracked. Configure both -Drizzle Kit and the Effect-managed application database service to load and -validate `DATABASE_URL` from that root environment. - -Install Drizzle ORM with the `pg`/node-postgres driver and Drizzle Kit in the -package that owns the Core database infrastructure. Promote only the 18 MVP2 -tables defined in the existing PostgreSQL schema named exactly `core`. -Explicitly exclude the `auth` schema and every MicroVertical or business -schema, including `ticketing`, `properties`, `property`, `accounting`, and any -future vertical schema. Preserve the applicable Core tables, columns, -constraints, and indexes while correcting experiment-era definitions that -conflict with the current authoritative Action lifecycle. Generate a fresh -Core-only migration history instead of copying MVP2's historical migrations, -apply it to the new local database, verify the resulting schema through typed -Drizzle access, and prove that rerunning the migration is a no-op. - -Document an authoritative application rule that SQL access uses typed Drizzle -schema objects and query builders together with Effect. Handwritten SQL -strings, direct driver queries, and string-concatenated SQL are prohibited -when Drizzle can express the operation. Drizzle's parameterized `sql` tagged -template is reserved for schema constraints, bootstrap/migration work, or a -narrowly documented database operation that Drizzle cannot otherwise express. +Establish the local PostgreSQL and typed Drizzle foundation for the OntOS application on `develop`, using the read-only MVP2 experiment at `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2` as the schema reference. + +Add a root Compose project named `ontos` with a PostgreSQL 17 Alpine service and container named `ontos-db`, a persistent named volume, a health check, and an `ontos` database. Add a committed root `.env.example` containing the local connection values and keep the developer's `.env` untracked. Configure both Drizzle Kit and the Effect-managed application database service to load and validate `DATABASE_URL` from that root environment. + +Install Drizzle ORM with the `pg`/node-postgres driver and Drizzle Kit in the package that owns the Core database infrastructure. Promote only the 18 MVP2 tables defined in the existing PostgreSQL schema named exactly `core`. Explicitly exclude the `auth` schema and every MicroVertical or business schema, including `ticketing`, `properties`, `property`, `accounting`, and any future vertical schema. Preserve the applicable Core tables, columns, constraints, and indexes while correcting experiment-era definitions that conflict with the current authoritative Action lifecycle. Generate a fresh Core-only migration history instead of copying MVP2's historical migrations, apply it to the new local database, verify the resulting schema through typed Drizzle access, and prove that rerunning the migration is a no-op. + +Document an authoritative application rule that SQL access uses typed Drizzle schema objects and query builders together with Effect. Handwritten SQL strings, direct driver queries, and string-concatenated SQL are prohibited when Drizzle can express the operation. Drizzle's parameterized `sql` tagged template is reserved for schema constraints, bootstrap/migration work, or a narrowly documented database operation that Drizzle cannot otherwise express. ## Relevant Files Use these files to accomplish the chore: -- `../AGENTS.md` — repository scope, read-only MVP folders, and mandatory - generator rules. -- `AGENTS.md` — authoritative application architecture and managed pnpm - command convention. +- `../AGENTS.md` — repository scope, read-only MVP folders, and mandatory generator rules. +- `AGENTS.md` — authoritative application architecture and managed pnpm command convention. - `README.md` — current shell-only workspace shape and strict Effect topology. -- `docs/architecture/MICROVERTICALS.md` — independently deployable vertical - seams that this Core-only chore must not cross. -- `docs/architecture/ACTIONS.md` — authoritative pre-authentication Action - Invocation Log and indeterminate-outcome requirements that supersede the - MVP2 experiment schema. -- `docs/architecture/ERRORS.md` — typed Effect error requirements for - expected configuration and database failures. -- `docs/architecture/ULTRAMODERN.md` — infrastructure-file exception and - prohibition on manually creating unsupported business file types. -- `../docs/06_CORE_KERNEL.md` — Core-owned database capabilities and the - boundary against vertical business data. -- `../docs/10_DATA_STORAGE_AND_PROJECTIONS.md` — PostgreSQL schema ownership, - Drizzle-plus-Effect requirement, and narrow raw-SQL exception. -- `../docs/adr/0002-modular-monolith-for-v0.md` — current V0 modular-monolith - deployment context. -- `../docs/adr/0004-postgres-canonical-neo4j-projection.md` — PostgreSQL as the - canonical operational store. -- `package.json` — workspace database orchestration scripts and final - validation aggregate. -- `pnpm-workspace.yaml` — dependency policy and Effect cohort that new - database dependencies must respect. -- `tsconfig.json` — project references for the new Core infrastructure - package. +- `docs/architecture/MICROVERTICALS.md` — independently deployable vertical seams that this Core-only chore must not cross. +- `docs/architecture/ACTIONS.md` — authoritative pre-authentication Action Invocation Log and indeterminate-outcome requirements that supersede the MVP2 experiment schema. +- `docs/architecture/ERRORS.md` — typed Effect error requirements for expected configuration and database failures. +- `docs/architecture/ULTRAMODERN.md` — infrastructure-file exception and prohibition on manually creating unsupported business file types. +- `../docs/06_CORE_KERNEL.md` — Core-owned database capabilities and the boundary against vertical business data. +- `../docs/10_DATA_STORAGE_AND_PROJECTIONS.md` — PostgreSQL schema ownership, Drizzle-plus-Effect requirement, and narrow raw-SQL exception. +- `../docs/adr/0002-modular-monolith-for-v0.md` — current V0 modular-monolith deployment context. +- `../docs/adr/0004-postgres-canonical-neo4j-projection.md` — PostgreSQL as the canonical operational store. +- `package.json` — workspace database orchestration scripts and final validation aggregate. +- `pnpm-workspace.yaml` — dependency policy and Effect cohort that new database dependencies must respect. +- `tsconfig.json` — project references for the new Core infrastructure package. - `.gitignore` — protection for the developer's root `.env`. -- `scripts/validate-ultramodern-workspace.mts` — generated workspace contract - that must continue to recognize every package and topology owner. -- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/docker-compose.yml` - — read-only PostgreSQL 17, local port, volume, and health-check reference. -- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/.env` — - read-only reference for variable names only; do not copy secret values. -- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/drizzle.config.ts` - — read-only Drizzle schema-discovery and migration reference. -- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/src/db/schema.ts` - — read-only reference for the 18 `core` tables. -- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/drizzle/` - — read-only evidence of the Core schema evolution; do not copy the - experiment's mixed-schema historical migration chain into `develop`. +- `scripts/validate-ultramodern-workspace.mts` — generated workspace contract that must continue to recognize every package and topology owner. +- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/docker-compose.yml` — read-only PostgreSQL 17, local port, volume, and health-check reference. +- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/.env` — read-only reference for variable names only; do not copy secret values. +- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/drizzle.config.ts` — read-only Drizzle schema-discovery and migration reference. +- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/src/db/schema.ts` — read-only reference for the 18 `core` tables. +- `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-main/mvp2/packages/core-runtime/drizzle/` — read-only evidence of the Core schema evolution; do not copy the experiment's mixed-schema historical migration chain into `develop`. ### New Files -- `docker-compose.yml` — local `ontos` Compose project with the `ontos-db` - PostgreSQL service and persistent volume. +- `docker-compose.yml` — local `ontos` Compose project with the `ontos-db` PostgreSQL service and persistent volume. - `.env.example` — non-secret local PostgreSQL and `DATABASE_URL` values. -- `docs/architecture/DATABASE.md` — authoritative typed Drizzle, Effect, - schema ownership, environment, and raw-SQL rules. -- `packages/core-runtime/package.json` — Core database package dependencies - and focused schema, migration, test, and verification scripts. -- `packages/core-runtime/tsconfig.json` — TypeScript project configuration for - Core database infrastructure. -- `packages/core-runtime/drizzle.config.ts` — PostgreSQL Drizzle Kit - configuration that loads the root `.env`. -- `packages/core-runtime/src/db/config.ts` — validated database configuration - and typed configuration failure. -- `packages/core-runtime/src/db/client.ts` — scoped Effect-managed `pg` pool - and typed Drizzle client service. -- `packages/core-runtime/src/db/schema.ts` — promoted and reconciled `core` - schema. -- `packages/core-runtime/src/db/types.ts` — inferred Drizzle executor and - transaction types. -- `packages/core-runtime/src/index.ts` — narrow public exports for the Core - database package. -- `packages/core-runtime/tests/config.test.ts` — environment parsing and typed - failure tests. -- `packages/core-runtime/tests/schema-contract.test.ts` — static table, - ownership, and critical Action lifecycle schema assertions. -- `packages/core-runtime/scripts/verify-db-schema.mts` — migration smoke check - that reaches every promoted Core table through typed Drizzle table - references. -- `packages/core-runtime/drizzle/*.sql` and - `packages/core-runtime/drizzle/meta/**` — fresh generated Core-only migration - and Drizzle metadata. +- `docs/architecture/DATABASE.md` — authoritative typed Drizzle, Effect, schema ownership, environment, and raw-SQL rules. +- `packages/core-runtime/package.json` — Core database package dependencies and focused schema, migration, test, and verification scripts. +- `packages/core-runtime/tsconfig.json` — TypeScript project configuration for Core database infrastructure. +- `packages/core-runtime/drizzle.config.ts` — PostgreSQL Drizzle Kit configuration that loads the root `.env`. +- `packages/core-runtime/src/db/config.ts` — validated database configuration and typed configuration failure. +- `packages/core-runtime/src/db/client.ts` — scoped Effect-managed `pg` pool and typed Drizzle client service. +- `packages/core-runtime/src/db/schema.ts` — promoted and reconciled `core` schema. +- `packages/core-runtime/src/db/types.ts` — inferred Drizzle executor and transaction types. +- `packages/core-runtime/src/index.ts` — narrow public exports for the Core database package. +- `packages/core-runtime/tests/config.test.ts` — environment parsing and typed failure tests. +- `packages/core-runtime/tests/schema-contract.test.ts` — static table, ownership, and critical Action lifecycle schema assertions. +- `packages/core-runtime/scripts/verify-db-schema.mts` — migration smoke check that reaches every promoted Core table through typed Drizzle table references. +- `packages/core-runtime/drizzle/*.sql` and `packages/core-runtime/drizzle/meta/**` — fresh generated Core-only migration and Drizzle metadata. ## Step by Step Tasks @@ -126,310 +66,125 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Lock the migration scope to the Core schema -- [x] Record the only in-scope MVP2 reference inventory as the 18 tables in - PostgreSQL schema `core` - (`tenants`, `legal_entities`, `principals`, - `principal_auth_bindings`, `tenant_module_states`, - `action_invocations`, `tenant_module_state_changes`, `audit_events`, - `data_access_events`, `domain_events`, `outbox_messages`, - `outbox_deliveries`, `outbox_attempts`, `media_assets`, `media_links`, - `evidence_references`, `search_index_entries`, and - `worker_checkpoints`). -- [x] Preserve the PostgreSQL schema name exactly as `core`; do not rename it, - flatten its tables into `public`, or introduce another Core schema name. -- [x] Configure Drizzle schema discovery, migration generation, runtime - registration, tests, and verification to import only - `packages/core-runtime/src/db/schema.ts`. -- [x] Explicitly exclude the MVP2 `auth` schema and every MicroVertical schema. - Do not create, migrate, test, or reference `auth`, `ticketing`, - `properties`, `property`, `accounting`, or any other business schema. -- [x] Treat the 18-name Core inventory as an exact set: implementation fails - if any expected Core table is missing or if any additional OntOS - application table is present. PostgreSQL system catalogs and Drizzle's - migration bookkeeping are infrastructure metadata, not OntOS - application tables, and must be checked separately. -- [x] Do not run an Action, MicroVertical page, Outbox Message, Policy, or - vertical generator because this chore creates only Core database - infrastructure and no business module. +- [x] Record the only in-scope MVP2 reference inventory as the 18 tables in PostgreSQL schema `core` (`tenants`, `legal_entities`, `principals`, `principal_auth_bindings`, `tenant_module_states`, `action_invocations`, `tenant_module_state_changes`, `audit_events`, `data_access_events`, `domain_events`, `outbox_messages`, `outbox_deliveries`, `outbox_attempts`, `media_assets`, `media_links`, `evidence_references`, `search_index_entries`, and `worker_checkpoints`). +- [x] Preserve the PostgreSQL schema name exactly as `core`; do not rename it, flatten its tables into `public`, or introduce another Core schema name. +- [x] Configure Drizzle schema discovery, migration generation, runtime registration, tests, and verification to import only `packages/core-runtime/src/db/schema.ts`. +- [x] Explicitly exclude the MVP2 `auth` schema and every MicroVertical schema. Do not create, migrate, test, or reference `auth`, `ticketing`, `properties`, `property`, `accounting`, or any other business schema. +- [x] Treat the 18-name Core inventory as an exact set: implementation fails if any expected Core table is missing or if any additional OntOS application table is present. PostgreSQL system catalogs and Drizzle's migration bookkeeping are infrastructure metadata, not OntOS application tables, and must be checked separately. +- [x] Do not run an Action, MicroVertical page, Outbox Message, Policy, or vertical generator because this chore creates only Core database infrastructure and no business module. ### 2. Make typed database access authoritative -- [x] Add `docs/architecture/DATABASE.md` and link it from the Required - Guidance section of `AGENTS.md` as the authoritative rule for database - work. -- [x] Require every application query and mutation to use typed Drizzle - table/column references and query builders inside Effect services. - Prohibit direct `pg` queries, interpolated SQL strings, untyped result - objects, and exported promise-only database APIs when Drizzle and Effect - can represent the behavior. -- [x] Permit Drizzle's parameterized `sql` tagged template only for typed - checks/index predicates, migration/bootstrap work, or a documented - operation not expressible by a Drizzle builder. Require a nearby reason - and focused test for every application-level exception. -- [x] State that this package owns only PostgreSQL schema `core`, that `public` - owns no OntOS application tables, and that Auth and MicroVertical - schemas remain outside this chore and outside this migration history. +- [x] Add `docs/architecture/DATABASE.md` and link it from the Required Guidance section of `AGENTS.md` as the authoritative rule for database work. +- [x] Require every application query and mutation to use typed Drizzle table/column references and query builders inside Effect services. Prohibit direct `pg` queries, interpolated SQL strings, untyped result objects, and exported promise-only database APIs when Drizzle and Effect can represent the behavior. +- [x] Permit Drizzle's parameterized `sql` tagged template only for typed checks/index predicates, migration/bootstrap work, or a documented operation not expressible by a Drizzle builder. Require a nearby reason and focused test for every application-level exception. +- [x] State that this package owns only PostgreSQL schema `core`, that `public` owns no OntOS application tables, and that Auth and MicroVertical schemas remain outside this chore and outside this migration history. ### 3. Add the local PostgreSQL Compose project and environment contract -- [x] Add a top-level `docker-compose.yml` with top-level `name: ontos` (Docker - Compose project names must be lowercase), service key and - `container_name: ontos-db`, image `postgres:17-alpine`, and database, - user, password, and host-port interpolation from `.env`. -- [x] Configure `POSTGRES_DB=ontos`, a health check against the configured - `ontos` database, restart-safe persistent storage through a named - `ontos_postgres_data` volume, and host port `5433` so the new stack - retains MVP2's non-default local-port convention without colliding with - a system PostgreSQL on `5432`. -- [x] Add root `.env.example` with explicit development-only values for - `POSTGRES_DB=ontos`, `POSTGRES_USER=ontos`, - `POSTGRES_PASSWORD=ontos`, `POSTGRES_HOST=localhost`, - `POSTGRES_PORT=5433`, and - `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos`. -- [x] Add root `.env` to `.gitignore`, copy `.env.example` to the local - untracked `.env` when executing the chore, and never read or copy secret - values from the MVP2 `.env`. -- [x] Validate interpolation and service naming with `docker compose config`; - start only `ontos-db`, wait for its health check, and prove - `pg_isready` succeeds for database `ontos`. +- [x] Add a top-level `docker-compose.yml` with top-level `name: ontos` (Docker Compose project names must be lowercase), service key and `container_name: ontos-db`, image `postgres:17-alpine`, and database, user, password, and host-port interpolation from `.env`. +- [x] Configure `POSTGRES_DB=ontos`, a health check against the configured `ontos` database, restart-safe persistent storage through a named `ontos_postgres_data` volume, and host port `5433` so the new stack retains MVP2's non-default local-port convention without colliding with a system PostgreSQL on `5432`. +- [x] Add root `.env.example` with explicit development-only values for `POSTGRES_DB=ontos`, `POSTGRES_USER=ontos`, `POSTGRES_PASSWORD=ontos`, `POSTGRES_HOST=localhost`, `POSTGRES_PORT=5433`, and `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos`. +- [x] Add root `.env` to `.gitignore`, copy `.env.example` to the local untracked `.env` when executing the chore, and never read or copy secret values from the MVP2 `.env`. +- [x] Validate interpolation and service naming with `docker compose config`; start only `ontos-db`, wait for its health check, and prove `pg_isready` succeeds for database `ontos`. ### 4. Add the Core database package and PostgreSQL Drizzle toolchain -- [x] Create the infrastructure-owned `@app/core-runtime` package, add it to - the root TypeScript project references, and adjust the generated - workspace validator only as required to keep its package/topology - contract accurate. Do not classify Core runtime as a business - MicroVertical or add it to browser Module Federation exposes. -- [x] From `app/`, install repository-policy-compatible exact versions of - `drizzle-orm`, `pg`, and the existing Effect cohort as runtime - dependencies and `drizzle-kit`, `@types/pg`, and `dotenv` as development - dependencies using `mise exec -- pnpm --filter @app/core-runtime ...`. - Do not copy MVP2's old versions or introduce a second Effect cohort. -- [x] Add `drizzle.config.ts` with `dialect: "postgresql"`, the owning schema - source paths, a package-owned `drizzle/` output, `strict: true`, and - `verbose: true`. Load the root `.env` by an explicit path resolved from - the package instead of depending on the command's current directory. -- [x] Add focused `db:generate`, `db:migrate`, `db:test`, and `db:verify` - package scripts and root orchestration scripts that target only the Core - schema owner. Ensure every pnpm invocation remains - `mise exec -- pnpm ...`. -- [x] Implement database configuration as a validated Effect dependency. - Missing or malformed `DATABASE_URL` must produce a declared typed - configuration error rather than a non-null assertion, silent localhost - fallback, thrown expected error, or untyped rejected Promise. -- [x] Implement the application connection as a scoped Effect Layer around a - `pg.Pool` and `drizzle-orm/node-postgres`, close the pool on scope - release, and expose the typed Drizzle executor only through the - server-side Core infrastructure package. Do not import it into shell - browser code. -- [x] Add tests proving root `.env` discovery is independent of the invocation - directory, valid local configuration produces the expected connection - settings, invalid configuration remains typed, and pool finalization - runs when the Effect scope closes. +- [x] Create the infrastructure-owned `@app/core-runtime` package, add it to the root TypeScript project references, and adjust the generated workspace validator only as required to keep its package/topology contract accurate. Do not classify Core runtime as a business MicroVertical or add it to browser Module Federation exposes. +- [x] From `app/`, install repository-policy-compatible exact versions of `drizzle-orm`, `pg`, and the existing Effect cohort as runtime dependencies and `drizzle-kit`, `@types/pg`, and `dotenv` as development dependencies using `mise exec -- pnpm --filter @app/core-runtime ...`. Do not copy MVP2's old versions or introduce a second Effect cohort. +- [x] Add `drizzle.config.ts` with `dialect: "postgresql"`, the owning schema source paths, a package-owned `drizzle/` output, `strict: true`, and `verbose: true`. Load the root `.env` by an explicit path resolved from the package instead of depending on the command's current directory. +- [x] Add focused `db:generate`, `db:migrate`, `db:test`, and `db:verify` package scripts and root orchestration scripts that target only the Core schema owner. Ensure every pnpm invocation remains `mise exec -- pnpm ...`. +- [x] Implement database configuration as a validated Effect dependency. Missing or malformed `DATABASE_URL` must produce a declared typed configuration error rather than a non-null assertion, silent localhost fallback, thrown expected error, or untyped rejected Promise. +- [x] Implement the application connection as a scoped Effect Layer around a `pg.Pool` and `drizzle-orm/node-postgres`, close the pool on scope release, and expose the typed Drizzle executor only through the server-side Core infrastructure package. Do not import it into shell browser code. +- [x] Add tests proving root `.env` discovery is independent of the invocation directory, valid local configuration produces the expected connection settings, invalid configuration remains typed, and pool finalization runs when the Effect scope closes. ### 5. Promote and reconcile the Core typed schema -- [x] Promote exactly the 18 Core tables from the read-only MVP2 - `packages/core-runtime/src/db/schema.ts` source, preserving PostgreSQL - schema name `core`, column types, defaults, foreign keys, delete - behavior, indexes, unique constraints, and check constraints. -- [x] Reconcile `core.action_invocations` with the current - `docs/architecture/ACTIONS.md`: allow the initial row to exist before - authentication by making principal/authentication fields nullable until - resolved, retain an available anonymous-session reference and transport - correlation, and include `indeterminate` among the permitted lifecycle - statuses. Preserve `replayed` only for the documented idempotency path. -- [x] Keep successful canonical writes, evidence records, domain events, and - outbox data representable in the same PostgreSQL transaction while the - Action Invocation Log remains independently persistable. Do not copy an - MVP2 constraint when it prevents an authoritative current lifecycle. -- [x] Use Drizzle schema builders and parameterized Drizzle `sql` expressions - for constraints and partial indexes; do not replace the typed source - schema with handwritten migration SQL. -- [x] Add a static schema contract test that enumerates the promoted schema - and table names and proves the critical pre-authentication principal - nullability, status union (including `indeterminate`), foreign keys, - and unique indexes before migration generation. Add negative assertions - that no Auth or MicroVertical table is registered, and compare the - exported Core table names as exact-set equality against the 18-table - inventory so both missing and unexpected tables fail. +- [x] Promote exactly the 18 Core tables from the read-only MVP2 `packages/core-runtime/src/db/schema.ts` source, preserving PostgreSQL schema name `core`, column types, defaults, foreign keys, delete behavior, indexes, unique constraints, and check constraints. +- [x] Reconcile `core.action_invocations` with the current `docs/architecture/ACTIONS.md`: allow the initial row to exist before authentication by making principal/authentication fields nullable until resolved, retain an available anonymous-session reference and transport correlation, and include `indeterminate` among the permitted lifecycle statuses. Preserve `replayed` only for the documented idempotency path. +- [x] Keep successful canonical writes, evidence records, domain events, and outbox data representable in the same PostgreSQL transaction while the Action Invocation Log remains independently persistable. Do not copy an MVP2 constraint when it prevents an authoritative current lifecycle. +- [x] Use Drizzle schema builders and parameterized Drizzle `sql` expressions for constraints and partial indexes; do not replace the typed source schema with handwritten migration SQL. +- [x] Add a static schema contract test that enumerates the promoted schema and table names and proves the critical pre-authentication principal nullability, status union (including `indeterminate`), foreign keys, and unique indexes before migration generation. Add negative assertions that no Auth or MicroVertical table is registered, and compare the exported Core table names as exact-set equality against the 18-table inventory so both missing and unexpected tables fail. ### 6. Generate, apply, and verify the fresh Core migration -- [x] With the final typed Core schema committed in source, run the Core - owner's `db:generate` script and review the generated SQL and metadata. - Generate a fresh baseline for `develop`; do not copy MVP2 migration - names, snapshots, or incremental SQL that describe experiment history. -- [x] Assert that the generated migration creates exactly PostgreSQL schema - `core`, its 18 expected tables and constraints, and no application tables - in `public`. Confirm it creates no `auth`, `ticketing`, `properties`, - `property`, `accounting`, SpiceDB, or other MVP2 service schema. -- [x] Apply the Core migration to a healthy, empty `ontos` database backed by - the new chore-owned Compose volume using the root `.env`. Before using an - existing volume, inspect its exact Compose project and volume identity; - do not delete or reuse unrelated data. Capture the successful command - result, rerun the same migration command, and prove the second execution - succeeds without reapplying or duplicating schema objects. -- [x] Implement and run `db:verify` using imported typed Drizzle table - references and zero-row/rollback-safe queries against all 18 Core - tables. Do not use a handwritten `information_schema` query merely to - avoid the typed schema. -- [x] In the same verifier, use one documented, parameterized Drizzle `sql` - catalog query as the necessary migration-verification exception to - compare the database's OntOS application tables with the exact 18-table - `core` inventory. Fail with explicit missing and unexpected sets. Assert - that `public` has no application tables and that `auth`, `ticketing`, - `properties`, `property`, `accounting`, and every other non-Core - application schema are absent. Exclude only PostgreSQL system objects - and the Drizzle migration bookkeeping object from this application-table - comparison. -- [x] Leave the local `ontos-db` service healthy and migrated for subsequent - development. Do not remove its volume as part of normal validation. +- [x] With the final typed Core schema committed in source, run the Core owner's `db:generate` script and review the generated SQL and metadata. Generate a fresh baseline for `develop`; do not copy MVP2 migration names, snapshots, or incremental SQL that describe experiment history. +- [x] Assert that the generated migration creates exactly PostgreSQL schema `core`, its 18 expected tables and constraints, and no application tables in `public`. Confirm it creates no `auth`, `ticketing`, `properties`, `property`, `accounting`, SpiceDB, or other MVP2 service schema. +- [x] Apply the Core migration to a healthy, empty `ontos` database backed by the new chore-owned Compose volume using the root `.env`. Before using an existing volume, inspect its exact Compose project and volume identity; do not delete or reuse unrelated data. Capture the successful command result, rerun the same migration command, and prove the second execution succeeds without reapplying or duplicating schema objects. +- [x] Implement and run `db:verify` using imported typed Drizzle table references and zero-row/rollback-safe queries against all 18 Core tables. Do not use a handwritten `information_schema` query merely to avoid the typed schema. +- [x] In the same verifier, use one documented, parameterized Drizzle `sql` catalog query as the necessary migration-verification exception to compare the database's OntOS application tables with the exact 18-table `core` inventory. Fail with explicit missing and unexpected sets. Assert that `public` has no application tables and that `auth`, `ticketing`, `properties`, `property`, `accounting`, and every other non-Core application schema are absent. Exclude only PostgreSQL system objects and the Drizzle migration bookkeeping object from this application-table comparison. +- [x] Leave the local `ontos-db` service healthy and migrated for subsequent development. Do not remove its volume as part of normal validation. ### 7. Run every validation command -- [x] Execute every command listed under Validation Commands in order and - resolve failures without weakening typed SQL, Effect errors, schema - ownership, generated migration history, or existing workspace gates. -- [x] Inspect `git status --short` afterward and confirm no `.env`, database - volume data, copied MVP2 files, unrelated login work, or generated build - output is included in the implementation diff. +- [x] Execute every command listed under Validation Commands in order and resolve failures without weakening typed SQL, Effect errors, schema ownership, generated migration history, or existing workspace gates. +- [x] Inspect `git status --short` afterward and confirm no `.env`, database volume data, copied MVP2 files, unrelated login work, or generated build output is included in the implementation diff. ## Testing Strategy -Add unit tests for root environment discovery, valid and invalid -`DATABASE_URL` handling, typed configuration failures, scoped pool cleanup, -schema/table inventory, Action lifecycle constraints, and inferred Drizzle -types. Add database integration verification that starts from the new Compose -database, applies the generated migration history, reaches every promoted -Core table through its typed Drizzle reference, proves a second migration run -is idempotent, and compares the final application-table catalog to the exact -18-table Core inventory. Test both mismatch directions: one expected table -missing and one unexpected application table/schema present. +Add unit tests for root environment discovery, valid and invalid `DATABASE_URL` handling, typed configuration failures, scoped pool cleanup, schema/table inventory, Action lifecycle constraints, and inferred Drizzle types. Add database integration verification that starts from the new Compose database, applies the generated migration history, reaches every promoted Core table through its typed Drizzle reference, proves a second migration run is idempotent, and compares the final application-table catalog to the exact 18-table Core inventory. Test both mismatch directions: one expected table missing and one unexpected application table/schema present. -Generated migration SQL and Drizzle's parameterized schema-constraint -expressions are expected exceptions to the no-SQL-strings rule. Application -queries used by tests and verification must remain typed Drizzle queries. +Generated migration SQL and Drizzle's parameterized schema-constraint expressions are expected exceptions to the no-SQL-strings rule. Application queries used by tests and verification must remain typed Drizzle queries. ## Acceptance Criteria -- [x] `docker compose config` resolves a project named `ontos`, a service and - container named `ontos-db`, PostgreSQL 17 Alpine, a persistent named - volume, and an `ontos` database health check. -- [x] The committed root `.env.example` contains usable non-secret local - values, the untracked root `.env` is ignored, and both Docker Compose and - application tooling use the same connection contract. -- [x] `@app/core-runtime` uses `drizzle-orm/node-postgres` with `pg`, Drizzle - Kit, and the repository's existing Effect cohort; no second database ORM - or Effect version is introduced. -- [x] Database configuration failures are typed Effect errors and the - PostgreSQL pool is acquired and released through Effect scope. -- [x] Application database guidance requires typed Drizzle plus Effect and - prohibits avoidable raw SQL strings and direct driver queries. -- [x] PostgreSQL schema name `core` is preserved exactly and contains all 18 - referenced Core tables with preserved MVP2 structure plus documented - corrections required by the current Action lifecycle. -- [x] Exact-set verification reports no missing Core tables and no unexpected - Core tables. -- [x] `core.action_invocations` can be persisted before authentication and can - represent an indeterminate commit result. -- [x] The Drizzle configuration, runtime schema, migration, and verification - contain no `auth`, `ticketing`, `properties`, `property`, `accounting`, - or other MicroVertical schema or table. -- [x] After migration, the `ontos` database contains no OntOS application - tables outside the 18 tables in schema `core`; `public` is empty of - application tables. PostgreSQL system objects and Drizzle migration - bookkeeping are the only excluded infrastructure metadata. -- [x] Fresh generated migration history is committed, applies successfully to - the new empty `ontos` database, and a second migration run is a no-op. -- [x] Typed Drizzle verification reaches every promoted table without - handwritten application SQL. -- [x] Existing login-page work and every other pre-existing worktree change - remain untouched. -- [x] All focused database checks and the repository's final quality gate - pass. +- [x] `docker compose config` resolves a project named `ontos`, a service and container named `ontos-db`, PostgreSQL 17 Alpine, a persistent named volume, and an `ontos` database health check. +- [x] The committed root `.env.example` contains usable non-secret local values, the untracked root `.env` is ignored, and both Docker Compose and application tooling use the same connection contract. +- [x] `@app/core-runtime` uses `drizzle-orm/node-postgres` with `pg`, Drizzle Kit, and the repository's existing Effect cohort; no second database ORM or Effect version is introduced. +- [x] Database configuration failures are typed Effect errors and the PostgreSQL pool is acquired and released through Effect scope. +- [x] Application database guidance requires typed Drizzle plus Effect and prohibits avoidable raw SQL strings and direct driver queries. +- [x] PostgreSQL schema name `core` is preserved exactly and contains all 18 referenced Core tables with preserved MVP2 structure plus documented corrections required by the current Action lifecycle. +- [x] Exact-set verification reports no missing Core tables and no unexpected Core tables. +- [x] `core.action_invocations` can be persisted before authentication and can represent an indeterminate commit result. +- [x] The Drizzle configuration, runtime schema, migration, and verification contain no `auth`, `ticketing`, `properties`, `property`, `accounting`, or other MicroVertical schema or table. +- [x] After migration, the `ontos` database contains no OntOS application tables outside the 18 tables in schema `core`; `public` is empty of application tables. PostgreSQL system objects and Drizzle migration bookkeeping are the only excluded infrastructure metadata. +- [x] Fresh generated migration history is committed, applies successfully to the new empty `ontos` database, and a second migration run is a no-op. +- [x] Typed Drizzle verification reaches every promoted table without handwritten application SQL. +- [x] Existing login-page work and every other pre-existing worktree change remain untouched. +- [x] All focused database checks and the repository's final quality gate pass. ## Validation Commands Execute every command to validate the chore with zero regressions. -- `docker compose config` — Validate the `ontos` project, `.env` - interpolation, service, health check, port, and volume configuration. -- `docker compose up -d --wait ontos-db` — Create/start the requested - PostgreSQL container and wait for it to become healthy. -- `docker compose exec -T ontos-db sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"'` - — Prove the configured `ontos` database accepts connections. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — Run Core - configuration, Effect resource, and static schema contract tests. -- `mise exec -- pnpm db:generate` — Prove typed schema sources and committed - migration metadata are synchronized and produce no unreviewed extra - migration. -- `mise exec -- pnpm db:migrate` — Apply the Core-only migration history to - the local database. -- `mise exec -- pnpm db:migrate` — Prove a second migration run is - idempotent. -- `mise exec -- pnpm db:verify` — Reach every promoted table through typed - Drizzle references and prove exact 18-table Core parity—no missing Core table - and no unexpected OntOS application table or schema. +- `docker compose config` — Validate the `ontos` project, `.env` interpolation, service, health check, port, and volume configuration. +- `docker compose up -d --wait ontos-db` — Create/start the requested PostgreSQL container and wait for it to become healthy. +- `docker compose exec -T ontos-db sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"'` — Prove the configured `ontos` database accepts connections. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — Run Core configuration, Effect resource, and static schema contract tests. +- `mise exec -- pnpm db:generate` — Prove typed schema sources and committed migration metadata are synchronized and produce no unreviewed extra migration. +- `mise exec -- pnpm db:migrate` — Apply the Core-only migration history to the local database. +- `mise exec -- pnpm db:migrate` — Prove a second migration run is idempotent. +- `mise exec -- pnpm db:verify` — Reach every promoted table through typed Drizzle references and prove exact 18-table Core parity—no missing Core table and no unexpected OntOS application table or schema. - `mise exec -- pnpm check` — Run the final repository quality gate. ## Review Checklist - [x] Every acceptance criterion is satisfied. -- [x] The diff complies with `../AGENTS.md`, `AGENTS.md`, and all relevant - referenced guidance. +- [x] The diff complies with `../AGENTS.md`, `AGENTS.md`, and all relevant referenced guidance. - [x] Behavioral changes have tests. -- [x] The migration owns exactly PostgreSQL schema `core`; Auth and - MicroVertical schemas are absent. -- [x] Final catalog verification proves exact 18-table Core equality rather - than checking only a subset of tables. -- [x] No Auth or MicroVertical implementation is imported, copied, generated, - migrated, or modified. -- [x] Expected configuration/database failures remain declared typed Effect - errors. -- [x] Application queries use typed Drizzle APIs; raw SQL exceptions are - narrow, parameterized, documented, and tested. -- [x] Generated migration SQL was reviewed and was not manually substituted - for typed schema source. +- [x] The migration owns exactly PostgreSQL schema `core`; Auth and MicroVertical schemas are absent. +- [x] Final catalog verification proves exact 18-table Core equality rather than checking only a subset of tables. +- [x] No Auth or MicroVertical implementation is imported, copied, generated, migrated, or modified. +- [x] Expected configuration/database failures remain declared typed Effect errors. +- [x] Application queries use typed Drizzle APIs; raw SQL exceptions are narrow, parameterized, documented, and tested. +- [x] Generated migration SQL was reviewed and was not manually substituted for typed schema source. - [x] No unrelated changes, dead code, or accidental API expansion remain. ## Notes -- Scope is deliberately limited to the existing MVP2 PostgreSQL schema named - `core` and its 18 tables. The `auth` schema and every MicroVertical/business - schema—including `ticketing`, `properties`, `property`, and `accounting`—are - explicitly out of scope. No unresolved ownership decision remains. -- MVP2 is a read-only experiment and remains unchanged. Its central Drizzle - config and mixed-schema historical migrations are evidence, not files to - copy into the current Core-only migration history. -- MVP2's `action_invocations.principal_id` is non-null and its status check - omits `indeterminate`. Exact copying would violate current authoritative - Action rules, so promotion must preserve current behavior rather than - experiment-era constraints. -- Docker Compose project names permit lowercase letters, digits, dashes, and - underscores. The requested “Ontos” Docker Desktop group is therefore - represented by top-level `name: ontos`; the container itself remains exactly - `ontos-db`. -- PostgreSQL host port `5433` and PostgreSQL 17 Alpine deliberately follow the - MVP2 local reference. Container-to-container PostgreSQL connections, if - introduced later, use service host `ontos-db` and port `5432`; host - application tooling uses `localhost:5433`. -- The login page and its test/tooling changes are part of the current - `develop` baseline. Implementation must preserve that behavior while - coordinating intentional edits to `package.json`, `pnpm-lock.yaml`, and - `scripts/validate-ultramodern-workspace.mts`. +- Scope is deliberately limited to the existing MVP2 PostgreSQL schema named `core` and its 18 tables. The `auth` schema and every MicroVertical/business schema—including `ticketing`, `properties`, `property`, and `accounting`—are explicitly out of scope. No unresolved ownership decision remains. +- MVP2 is a read-only experiment and remains unchanged. Its central Drizzle config and mixed-schema historical migrations are evidence, not files to copy into the current Core-only migration history. +- MVP2's `action_invocations.principal_id` is non-null and its status check omits `indeterminate`. Exact copying would violate current authoritative Action rules, so promotion must preserve current behavior rather than experiment-era constraints. +- Docker Compose project names permit lowercase letters, digits, dashes, and underscores. The requested “Ontos” Docker Desktop group is therefore represented by top-level `name: ontos`; the container itself remains exactly `ontos-db`. +- PostgreSQL host port `5433` and PostgreSQL 17 Alpine deliberately follow the MVP2 local reference. Container-to-container PostgreSQL connections, if introduced later, use service host `ontos-db` and port `5432`; host application tooling uses `localhost:5433`. +- The login page and its test/tooling changes are part of the current `develop` baseline. Implementation must preserve that behavior while coordinating intentional edits to `package.json`, `pnpm-lock.yaml`, and `scripts/validate-ultramodern-workspace.mts`. ## Implementation Evidence ### Summary -- Added the local PostgreSQL 17 Compose contract, typed Effect-managed - node-postgres/Drizzle infrastructure package, exact 18-table `core` schema, - fresh baseline migration, exact-set verifier, and authoritative database - guidance. -- Reconciled `core.action_invocations` with the current pre-authentication and - indeterminate-outcome lifecycle while preserving the promoted Core - constraints, foreign keys, indexes, and delete behavior. +- Added the local PostgreSQL 17 Compose contract, typed Effect-managed node-postgres/Drizzle infrastructure package, exact 18-table `core` schema, fresh baseline migration, exact-set verifier, and authoritative database guidance. +- Reconciled `core.action_invocations` with the current pre-authentication and indeterminate-outcome lifecycle while preserving the promoted Core constraints, foreign keys, indexes, and delete behavior. ### Changed Files @@ -437,17 +192,9 @@ Execute every command to validate the chore with zero regressions. ### Tests Written or Updated -- `packages/core-runtime/tests/unit/config.test.ts` — proves root environment - discovery is invocation-directory independent, valid and invalid - configuration behavior remains typed, and pool finalization runs on scope - close. -- `packages/core-runtime/tests/unit/schema-contract.test.ts` — proves exact - Core table registration, no foreign schema registration, pre-auth Action - nullability, lifecycle status typing/checks, foreign keys, and unique - indexes. -- `packages/core-runtime/tests/unit/catalog-contract.test.ts` — proves exact-set - comparison reports both a missing Core table and unexpected application - tables/schemas. +- `packages/core-runtime/tests/unit/config.test.ts` — proves root environment discovery is invocation-directory independent, valid and invalid configuration behavior remains typed, and pool finalization runs on scope close. +- `packages/core-runtime/tests/unit/schema-contract.test.ts` — proves exact Core table registration, no foreign schema registration, pre-auth Action nullability, lifecycle status typing/checks, foreign keys, and unique indexes. +- `packages/core-runtime/tests/unit/catalog-contract.test.ts` — proves exact-set comparison reports both a missing Core table and unexpected application tables/schemas. ### Validation @@ -456,33 +203,19 @@ Execute every command to validate the chore with zero regressions. - `docker compose exec -T ontos-db sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"'` — passed. - `mise exec -- pnpm --filter @app/core-runtime db:test` — passed; 10 tests. - `mise exec -- pnpm --filter @app/core-runtime typecheck` — passed. -- `mise exec -- pnpm db:generate` — passed; 18 tables and no schema changes - after the committed baseline. +- `mise exec -- pnpm db:generate` — passed; 18 tables and no schema changes after the committed baseline. - `mise exec -- pnpm db:migrate` — passed. -- `mise exec -- pnpm db:migrate` — passed again without reapplying schema - objects. -- `mise exec -- pnpm db:verify` — passed; 18 typed tables, exact application - catalog, and one migration bookkeeping table verified. +- `mise exec -- pnpm db:migrate` — passed again without reapplying schema objects. +- `mise exec -- pnpm db:verify` — passed; 18 typed tables, exact application catalog, and one migration bookkeeping table verified. - `mise exec -- pnpm check` — passed. -- `mise exec -- pnpm build` — not run; the change adds server-only database - infrastructure and does not affect shell build output, Module Federation, - routing, or browser/runtime bundling. +- `mise exec -- pnpm build` — not run; the change adds server-only database infrastructure and does not affect shell build output, Module Federation, routing, or browser/runtime bundling. - Browser validation — not run; there is no user-facing behavior. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `MICROVERTICALS.md`, `ACTIONS.md`, - `ERRORS.md`, `ULTRAMODERN.md`, `DATABASE.md`, Core/storage product guidance, - and the relevant PostgreSQL/modular-monolith ADRs. -- Reviewed the complete source, generated SQL and metadata, topology/validator - changes, dependency cohort, status, diff check, and schema-boundary searches. - Fixed one review finding: the environment-discovery test now uses the - committed root `.env.example` so clean checkouts do not depend on local - untracked state. No screenshots apply. +- Re-read `../AGENTS.md`, `AGENTS.md`, `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, `ULTRAMODERN.md`, `DATABASE.md`, Core/storage product guidance, and the relevant PostgreSQL/modular-monolith ADRs. +- Reviewed the complete source, generated SQL and metadata, topology/validator changes, dependency cohort, status, diff check, and schema-boundary searches. Fixed one review finding: the environment-discovery test now uses the committed root `.env.example` so clean checkouts do not depend on local untracked state. No screenshots apply. ### Deviations and Follow-ups -- `packages/core-runtime/tsconfig.json` uses package-local `skipLibCheck` because - Drizzle ORM 0.45.2 publishes optional-driver declarations that are not TS7 - clean. Core source, tests, scripts, and the repository quality gate remain - fully checked. +- `packages/core-runtime/tsconfig.json` uses package-local `skipLibCheck` because Drizzle ORM 0.45.2 publishes optional-driver declarations that are not TS7 clean. Core source, tests, scripts, and the repository quality gate remain fully checked. diff --git a/app/specs/chore-rename-crm-microvertical-to-contacts.md b/app/specs/chore-rename-crm-microvertical-to-contacts.md index f18740297..80da5bc98 100644 --- a/app/specs/chore-rename-crm-microvertical-to-contacts.md +++ b/app/specs/chore-rename-crm-microvertical-to-contacts.md @@ -8,12 +8,7 @@ created: 2026-09-01 ## Chore Description -Rename the existing CRM MicroVertical to Contacts across every current application, deployment, -database, route, contract, translation, test, and documentation surface without changing its -Customer and Contact behavior. This is a coordinated identity migration, not a second module and -not a rewrite: the independently deployable MicroVertical seam, generated Effect BFF client, -governed read/Action boundaries, tenant state, PostgreSQL data, SpiceDB access, and user-visible -states must survive intact. +Rename the existing CRM MicroVertical to Contacts across every current application, deployment, database, route, contract, translation, test, and documentation surface without changing its Customer and Contact behavior. This is a coordinated identity migration, not a second module and not a rewrite: the independently deployable MicroVertical seam, generated Effect BFF client, governed read/Action boundaries, tenant state, PostgreSQL data, SpiceDB access, and user-visible states must survive intact. The target naming contract is: @@ -31,11 +26,7 @@ The target naming contract is: | Environment/CI names | `*_CRM_*`, `*_URL_CRM`, `ZEROPS_CRM_SERVICE_ID` | `*_CONTACTS_*`, `*_URL_CONTACTS`, `ZEROPS_CONTACTS_SERVICE_ID` | | Zerops/Cloudflare identity | `crm`, `app-crm` | `contacts`, `app-contacts` | -The migration must preserve existing Customer and Contact rows, tenant module state, authorization, -and structured Core references. It must fail closed on ambiguous mixed CRM/Contacts database or -authorization state. Historical specifications, ADRs, and already-applied Drizzle migrations remain -immutable provenance and are the only allowed legacy-name exceptions; new compatibility migrations -may mention the old identifiers only where required to recognize and migrate them. +The migration must preserve existing Customer and Contact rows, tenant module state, authorization, and structured Core references. It must fail closed on ambiguous mixed CRM/Contacts database or authorization state. Historical specifications, ADRs, and already-applied Drizzle migrations remain immutable provenance and are the only allowed legacy-name exceptions; new compatibility migrations may mention the old identifiers only where required to recognize and migrate them. ## Relevant Files @@ -131,13 +122,7 @@ IMPORTANT: Execute every step in order, top to bottom. ## Testing Strategy -Update existing unit, component, integration, database, contract, topology, and browser tests alongside -each renamed surface. Add dedicated upgrade tests for the legacy Drizzle journal/schema, Core module -identity rows, and SpiceDB relationships; run each migration twice and cover fresh, legacy, -already-migrated, partial, and conflicting states. Existing Customer/Contact CRUD, ARES lookup, -archival, authentication, module gating, legal-entity/tenant isolation, forbidden, unavailable, -validation, conflict, retry, accessibility, localization, responsive layout, Module Federation, -and deployment-readiness behaviors must remain unchanged except for Contacts naming and URLs. +Update existing unit, component, integration, database, contract, topology, and browser tests alongside each renamed surface. Add dedicated upgrade tests for the legacy Drizzle journal/schema, Core module identity rows, and SpiceDB relationships; run each migration twice and cover fresh, legacy, already-migrated, partial, and conflicting states. Existing Customer/Contact CRUD, ARES lookup, archival, authentication, module gating, legal-entity/tenant isolation, forbidden, unavailable, validation, conflict, retry, accessibility, localization, responsive layout, Module Federation, and deployment-readiness behaviors must remain unchanged except for Contacts naming and URLs. ## Acceptance Criteria @@ -194,95 +179,59 @@ Execute every command to validate the chore with zero regressions. ### Summary -- Renamed the live MicroVertical, package, contracts, governed operations, routes, federation, - translations, topology, deployment configuration, and current documentation to Contacts. -- Added data-preserving PostgreSQL and structured Core identity migrations, including exact journal, - schema, database-object, and runtime-role verification. -- Added a bounded prepare/verify/finalize SpiceDB migration and changed fresh local, stage, and - development-bootstrap authorization to `contacts.core`. -- Added an active-tree legacy-name guard while retaining byte-identical historical `0000`/`0001` - migration provenance. +- Renamed the live MicroVertical, package, contracts, governed operations, routes, federation, translations, topology, deployment configuration, and current documentation to Contacts. +- Added data-preserving PostgreSQL and structured Core identity migrations, including exact journal, schema, database-object, and runtime-role verification. +- Added a bounded prepare/verify/finalize SpiceDB migration and changed fresh local, stage, and development-bootstrap authorization to `contacts.core`. +- Added an active-tree legacy-name guard while retaining byte-identical historical `0000`/`0001` migration provenance. ### Changed Files -- 259 paths appear in the final staged diff, including 10 new migration, specification, script, and - test files across the root workflow, Shell, Core, Contacts, topology, deployment configuration, - and current documentation. The final line totals are recorded in the implementation report. +- 259 paths appear in the final staged diff, including 10 new migration, specification, script, and test files across the root workflow, Shell, Core, Contacts, topology, deployment configuration, and current documentation. The final line totals are recorded in the implementation report. ### Tests Written or Updated -- `packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` — proves populated - structured identity migration, UUID/timestamp/payload preservation, rerun behavior, unrelated-row - isolation, and collision failure. -- `scripts/tests/migrate-contacts-authorization.test.mts` — proves fresh, legacy-only, prepared, - finalized, divergent, and partial authorization migration planning. -- `scripts/tests/initialize-local-development.test.mts` — proves Contacts-only activation and that a - migrated module-state UUID is preserved while identity collisions fail closed. -- `packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts` — proves fresh development - authorization grants target only the encoded Contacts module-access object. -- `verticals/contacts/tests/unit/prepare-contacts-migration.test.ts` and - `verticals/contacts/tests/unit/schema-contract.test.ts` — prove journal state classification, - immutable historical provenance, and the data-preserving schema rename contract. -- Contacts unit/component/integration tests plus Shell unit/integration/e2e tests were renamed and - updated for Contacts package, API, federation, route, gateway, and localized UI identities. +- `packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` — proves populated structured identity migration, UUID/timestamp/payload preservation, rerun behavior, unrelated-row isolation, and collision failure. +- `scripts/tests/migrate-contacts-authorization.test.mts` — proves fresh, legacy-only, prepared, finalized, divergent, and partial authorization migration planning. +- `scripts/tests/initialize-local-development.test.mts` — proves Contacts-only activation and that a migrated module-state UUID is preserved while identity collisions fail closed. +- `packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts` — proves fresh development authorization grants target only the encoded Contacts module-access object. +- `verticals/contacts/tests/unit/prepare-contacts-migration.test.ts` and `verticals/contacts/tests/unit/schema-contract.test.ts` — prove journal state classification, immutable historical provenance, and the data-preserving schema rename contract. +- Contacts unit/component/integration tests plus Shell unit/integration/e2e tests were renamed and updated for Contacts package, API, federation, route, gateway, and localized UI identities. ### Validation - `mise exec -- pnpm install --frozen-lockfile` — passed. - `mise exec -- pnpm --filter @app/core-runtime db:test` — passed, 243/243. -- `mise exec -- node --test packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` - — passed. +- `mise exec -- node --test packages/core-runtime/tests/integration/contacts-identity-migration.test.ts` — passed. - `mise exec -- node --test scripts/tests/migrate-contacts-authorization.test.mts` — passed, 6/6. - `mise exec -- node --test scripts/tests/initialize-local-development.test.mts` — passed, 6/6. -- `mise exec -- node --test packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts` — - passed, 4/4. +- `mise exec -- node --test packages/core-runtime/tests/unit/spicedb-database-bootstrap.test.ts` — passed, 4/4. - `mise exec -- pnpm --filter @app/contacts test:unit` — passed, 53/53. - `mise exec -- pnpm --filter @app/contacts test:integration` — passed, 4/4. - `mise exec -- pnpm --filter @app/contacts test:component` — passed, 247/247. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed, 173/173. - `mise exec -- pnpm --filter @app/shell-super-app test:integration` — passed, 7/7. -- `mise exec -- pnpm db:migrate` — passed for the legacy-to-Contacts path and passed again as an - already-migrated no-op. -- `mise exec -- pnpm db:verify` — passed with exact Core/Auth/Contacts schema, journal, table, owner, - RLS, grant, constraint, and index inventories. -- `mise exec -- node scripts/migrate-contacts-authorization.mts prepare`, `verify`, and `finalize` — - all passed against the local authoritative context; focused tests cover legacy and divergent - relationship states. -- `mise exec -- pnpm local:initialize` — passed after the Core identity migration while preserving - the existing tenant module-state UUID. -- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed, 32/32, including authenticated - Contacts navigation, Customer/Contact CRUD/read journeys, localized login/logout, tenant switching, - keyboard retry, and mobile layout coverage. +- `mise exec -- pnpm db:migrate` — passed for the legacy-to-Contacts path and passed again as an already-migrated no-op. +- `mise exec -- pnpm db:verify` — passed with exact Core/Auth/Contacts schema, journal, table, owner, RLS, grant, constraint, and index inventories. +- `mise exec -- node scripts/migrate-contacts-authorization.mts prepare`, `verify`, and `finalize` — all passed against the local authoritative context; focused tests cover legacy and divergent relationship states. +- `mise exec -- pnpm local:initialize` — passed after the Core identity migration while preserving the existing tenant module-state UUID. +- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed, 32/32, including authenticated Contacts navigation, Customer/Contact CRUD/read journeys, localized login/logout, tenant switching, keyboard retry, and mobile layout coverage. - `mise exec -- pnpm check:module-contracts` — passed. - `mise exec -- pnpm module-entrypoints:check` — passed. - `mise exec -- pnpm i18n:boundaries` — passed. - `mise exec -- pnpm contract:check` — passed, including the active-tree stale-name guard. -- `mise exec -- pnpm build` — the literal local command correctly stopped at the promotable-envelope - guard because its revision was `workspace`; rerunning with - `ULTRAMODERN_SOURCE_REVISION=c7fb88eb33f91973d04fadc6e8ee2b5c28b61a8b` passed the full Contacts, - Shell, Module Federation type, deploy-output, and performance build. +- `mise exec -- pnpm build` — the literal local command correctly stopped at the promotable-envelope guard because its revision was `workspace`; rerunning with `ULTRAMODERN_SOURCE_REVISION=c7fb88eb33f91973d04fadc6e8ee2b5c28b61a8b` passed the full Contacts, Shell, Module Federation type, deploy-output, and performance build. - `mise exec -- pnpm check` — passed completely after the final fixes. -- SHA-256 comparison of Contacts `0000`/`0001` SQL and snapshots against their CRM source paths — - passed byte-for-byte. +- SHA-256 comparison of Contacts `0000`/`0001` SQL and snapshots against their CRM source paths — passed byte-for-byte. - `git diff --check` — passed. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `README.md`, `DEVELOPMENT.md`, the routed architecture and - integration documents named by this specification, and the complete specification before final - review. -- Reviewed the status, diff check/stat, key migrations, authorization cutover, database bootstrap, - generated route/federation identities, residual legacy-token inventory, and move detection. -- Fixed review findings in local module-state reconciliation and fresh SpiceDB bootstrap identity; - reran focused tests, browser validation, database verification, and the complete quality gate. -- No screenshots were retained because this chore changes identity/copy/routes rather than visual - design; Playwright directly verified the localized rendered Contacts surfaces and critical flows. +- Re-read `../AGENTS.md`, `AGENTS.md`, `README.md`, `DEVELOPMENT.md`, the routed architecture and integration documents named by this specification, and the complete specification before final review. +- Reviewed the status, diff check/stat, key migrations, authorization cutover, database bootstrap, generated route/federation identities, residual legacy-token inventory, and move detection. +- Fixed review findings in local module-state reconciliation and fresh SpiceDB bootstrap identity; reran focused tests, browser validation, database verification, and the complete quality gate. +- No screenshots were retained because this chore changes identity/copy/routes rather than visual design; Playwright directly verified the localized rendered Contacts surfaces and critical flows. ### Deviations and Follow-ups -- The external Zerops service/project-variable and GitHub repository-variable cutover, the - deployment lock, dark Contacts deployment, distributed smoke test, and old-service removal require - external environment authority and remain the post-merge rollout work. -- The code implementation and all local validation gates are complete. The plan remains - `in_progress`, rather than `done`, until the two external cutover tasks and the external - Zerops/GitHub acceptance criterion are completed. +- The external Zerops service/project-variable and GitHub repository-variable cutover, the deployment lock, dark Contacts deployment, distributed smoke test, and old-service removal require external environment authority and remain the post-merge rollout work. +- The code implementation and all local validation gates are complete. The plan remains `in_progress`, rather than `done`, until the two external cutover tasks and the external Zerops/GitHub acceptance criterion are completed. diff --git a/app/specs/chore-support-dynamic-microvertical-pages.md b/app/specs/chore-support-dynamic-microvertical-pages.md index bf1bb2591..9f256fe41 100644 --- a/app/specs/chore-support-dynamic-microvertical-pages.md +++ b/app/specs/chore-support-dynamic-microvertical-pages.md @@ -8,18 +8,9 @@ created: 2026-08-14 ## Chore Description -Extend the mandatory `scaffold:microvertical-page` Codesmith generator and the authenticated Shell -page gateway so an owner can generate a private exact page whose canonical URL contains safe named -parameters, such as `/crm/customers/:id/edit`. Today the generator accepts only static lowercase -kebab-case segments and explicitly rejects parameters, even though the UltraModern route metadata -and existing Shell-owned resource routes already use `:parameter` patterns backed by `[parameter]` -filesystem segments. - -The extension must keep dynamic page URLs declarative and non-executable in the serialized module -contract, omit them from ordinary module navigation because a route template is not a usable href, -preserve exact Shell/Core page gating before a remote loads, and pass only a bounded route-parameter -map to the approved remote component. Route parameters remain untrusted business input; they never -become tenant, principal, legal-entity, authorization, or module-state context. +Extend the mandatory `scaffold:microvertical-page` Codesmith generator and the authenticated Shell page gateway so an owner can generate a private exact page whose canonical URL contains safe named parameters, such as `/crm/customers/:id/edit`. Today the generator accepts only static lowercase kebab-case segments and explicitly rejects parameters, even though the UltraModern route metadata and existing Shell-owned resource routes already use `:parameter` patterns backed by `[parameter]` filesystem segments. + +The extension must keep dynamic page URLs declarative and non-executable in the serialized module contract, omit them from ordinary module navigation because a route template is not a usable href, preserve exact Shell/Core page gating before a remote loads, and pass only a bounded route-parameter map to the approved remote component. Route parameters remain untrusted business input; they never become tenant, principal, legal-entity, authorization, or module-state context. ## Relevant Files @@ -88,11 +79,7 @@ IMPORTANT: Execute every step in order, top to bottom. ## Testing Strategy -Use Core schema unit tests for the serialized template grammar, disposable generator fixtures for -every output and failure path, and Shell unit tests for the runtime prop boundary and load ordering. -Compile generated dynamic-page fixtures against the real workspace contracts. Preserve all static -page tests as compatibility coverage and verify that every validation failure leaves the fixture -byte-for-byte unchanged. +Use Core schema unit tests for the serialized template grammar, disposable generator fixtures for every output and failure path, and Shell unit tests for the runtime prop boundary and load ordering. Compile generated dynamic-page fixtures against the real workspace contracts. Preserve all static page tests as compatibility coverage and verify that every validation failure leaves the fixture byte-for-byte unchanged. ## Acceptance Criteria @@ -127,80 +114,51 @@ Execute every command to validate the chore with zero regressions. ## Notes -- This chore is required before the requested `CustomerEdit` page can use its exact URL. The current - generator help and implementation explicitly reject `:id` parameters. -- Dynamic page route parameters are business input only. Receiving BFF schemas still validate - `customerId`, and authenticated tenant/principal/legal-entity context still comes exclusively from - the verified Shell boundary. -- Dynamic pages intentionally have no generated module navigation item. Contextual links from a - Customer list/detail flow must supply a concrete Customer ID. +- This chore is required before the requested `CustomerEdit` page can use its exact URL. The current generator help and implementation explicitly reject `:id` parameters. +- Dynamic page route parameters are business input only. Receiving BFF schemas still validate `customerId`, and authenticated tenant/principal/legal-entity context still comes exclusively from the verified Shell boundary. +- Dynamic pages intentionally have no generated module navigation item. Contextual links from a Customer list/detail flow must supply a concrete Customer ID. - This chore adds no CRM page, Customer behavior, BFF operation, database change, or UI. ## Implementation Evidence ### Summary -- Added safe dynamic page-template validation, canonical-to-filesystem mapping, collision preflight, - non-navigation generation, and bounded untrusted route-parameter propagation through the existing - exact Shell gateway. -- Updated command help, public documentation, and authoritative module guidance without generating - the CRM `customer-edit` business page. +- Added safe dynamic page-template validation, canonical-to-filesystem mapping, collision preflight, non-navigation generation, and bounded untrusted route-parameter propagation through the existing exact Shell gateway. +- Updated command help, public documentation, and authoritative module guidance without generating the CRM `customer-edit` business page. ### Changed Files -17 files changed, 730 insertions(+), 96 deletions(-), including the dynamic-page implementation, -its tests and documentation, plus the narrow scaffolding validation fixes required to make every -listed command pass. +17 files changed, 730 insertions(+), 96 deletions(-), including the dynamic-page implementation, its tests and documentation, plus the narrow scaffolding validation fixes required to make every listed command pass. ### Tests Written or Updated -- `packages/core-runtime/tests/unit/shell-contribution.test.ts` — accepted mixed templates, plain - serialization, and unsafe/ambiguous/repeated parameter rejection. -- `scripts/scaffolding/tests/scaffold-generators.test.mts` — exact dynamic output, compilation, - formatting, reruns, static compatibility, collisions, developer edits, and atomic no-write paths. -- `apps/shell-super-app/tests/unit/routes/modules/loader.test.ts` — declared/bounded parameter - selection, empty static parameters, and target-identity separation. -- `apps/shell-super-app/tests/unit/routes/modules/page.test.tsx` — post-resolution lazy-load ordering - and approved remote props for dynamic and static pages. -- `scripts/scaffolding/tests/module-contract-generator.test.mts` — supplies the Action generator's - mandatory legal-entity scope so the fixture reaches the intended missing-contract assertion. +- `packages/core-runtime/tests/unit/shell-contribution.test.ts` — accepted mixed templates, plain serialization, and unsafe/ambiguous/repeated parameter rejection. +- `scripts/scaffolding/tests/scaffold-generators.test.mts` — exact dynamic output, compilation, formatting, reruns, static compatibility, collisions, developer edits, and atomic no-write paths. +- `apps/shell-super-app/tests/unit/routes/modules/loader.test.ts` — declared/bounded parameter selection, empty static parameters, and target-identity separation. +- `apps/shell-super-app/tests/unit/routes/modules/page.test.tsx` — post-resolution lazy-load ordering and approved remote props for dynamic and static pages. +- `scripts/scaffolding/tests/module-contract-generator.test.mts` — supplies the Action generator's mandatory legal-entity scope so the fixture reaches the intended missing-contract assertion. ### Validation - `mise exec -- pnpm exec tsc -p scripts/scaffolding/tsconfig.json` — passed. -- `mise exec -- pnpm exec oxlint scripts/scaffolding` — passed after resolving all reported - scaffolding findings. -- `mise exec -- node --test scripts/scaffolding/tests/*.test.mts` — 42/42 passed, including - formatter stability and generated-file typechecking. +- `mise exec -- pnpm exec oxlint scripts/scaffolding` — passed after resolving all reported scaffolding findings. +- `mise exec -- node --test scripts/scaffolding/tests/*.test.mts` — 42/42 passed, including formatter stability and generated-file typechecking. - `mise exec -- pnpm scaffold:microvertical-page -- --help` — passed. -- `mise exec -- node --test packages/core-runtime/tests/unit/shell-contribution.test.ts` — 20/20 - passed, including non-configured locale-prefix regressions. +- `mise exec -- node --test packages/core-runtime/tests/unit/shell-contribution.test.ts` — 20/20 passed, including non-configured locale-prefix regressions. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — 149/149 passed. - `mise exec -- pnpm module-entrypoints:check` — passed. - `mise exec -- pnpm check:module-contracts` — passed. - `mise exec -- pnpm check` — passed twice, including after the review fix. -- `mise exec -- pnpm build` — CRM server/client compilation and Module Federation DTS generation - passed, then the existing release-envelope precondition rejected source revision `workspace`. -- Browser validation — not run: this infrastructure chore intentionally does not generate the CRM - business page, so there is no changed user-visible runtime path to exercise. +- `mise exec -- pnpm build` — CRM server/client compilation and Module Federation DTS generation passed, then the existing release-envelope precondition rejected source revision `workspace`. +- Browser validation — not run: this infrastructure chore intentionally does not generate the CRM business page, so there is no changed user-visible runtime path to exercise. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, - `ULTRAMODERN.md`, `FRONTEND.md`, `MODULE_ENTRYPOINTS.md`, and `MODULE_MANIFESTS.md`; the final diff - preserves independent deployment seams, exact target gating, typed errors, private lazy loading, - and the `appId`/`moduleId` split. -- The review found and fixed one evidence mismatch: the disposable dynamic fixture now uses the - specification's exact `/crm/customers/:id/edit` canonical template. A second specification review - and standards/AGENTS review found no release blockers after generic locale-prefix coverage was - added and an initially broad lint cleanup was narrowed to behavior-preserving changes. No - screenshots apply. +- Re-read `../AGENTS.md`, `AGENTS.md`, `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, `ULTRAMODERN.md`, `FRONTEND.md`, `MODULE_ENTRYPOINTS.md`, and `MODULE_MANIFESTS.md`; the final diff preserves independent deployment seams, exact target gating, typed errors, private lazy loading, and the `appId`/`moduleId` split. +- The review found and fixed one evidence mismatch: the disposable dynamic fixture now uses the specification's exact `/crm/customers/:id/edit` canonical template. A second specification review and standards/AGENTS review found no release blockers after generic locale-prefix coverage was added and an initially broad lint cleanup was narrowed to behavior-preserving changes. No screenshots apply. ### Deviations and Follow-ups - No validation-command deviations remain. -- The build requires a promotable source revision instead of the worktree value `workspace`; no - product or generator failure occurred before that environment precondition. -- Core and Codesmith retain separate implementations of the same route grammar. The review judged - this non-blocking because sharing the validator would widen a dependency boundary and both suites - now cover matching locale and parameter cases. +- The build requires a promotable source revision instead of the worktree value `workspace`; no product or generator failure occurred before that environment precondition. +- Core and Codesmith retain separate implementations of the same route grammar. The review judged this non-blocking because sharing the validator would widen a dependency boundary and both suites now cover matching locale and parameter cases. diff --git a/app/specs/feature-action-permissions.md b/app/specs/feature-action-permissions.md index 872c6f24f..684025b5a 100644 --- a/app/specs/feature-action-permissions.md +++ b/app/specs/feature-action-permissions.md @@ -8,221 +8,89 @@ created: 2026-07-31 ## Feature Description -Gate every typed Action at the existing permission stage with a Core-owned -SpiceDB authorization service. Losslessly encode each Action descriptor's stable, -globally unique `actionKey` as an `ak_`-prefixed base64url SpiceDB Action object -identifier, keep the existing trusted OntOS `principalId` as the subject identifier, and make the -permission check run after the durable invocation has been created but before -the invocation becomes `running`, the business transaction opens, or the -private handler can execute. - -Preserve the requested compatibility rule that an Action with no SpiceDB -restriction record is allowed. Represent that rule explicitly in the SpiceDB -schema: an `action` object whose id is the encoded Action key is restricted only when it has a -self-referential restriction-marker relationship. An unconfigured Action has -no marker and is allowed; a restricted Action must grant the `execute` -permission to the `principal` object whose id is the trusted principal id, -otherwise it is denied. A valid -negative marker check is the only fail-open path. Missing configuration, -network failures, timeouts, schema errors, conditional decisions, and other -indeterminate SpiceDB responses are typed infrastructure failures and fail -closed. - -On a definite denial, atomically transition the independently persisted -Action Invocation from `received` to terminal `rejected`, set `completed_at`, -and insert one terminal `action.rejected` Audit Event with normalized authz -outcome data. Only after that evidence transaction commits may Core return a -typed `ActionPermissionDenied` carrying a safe human-readable reason. No -handler, business transaction, business write, Data Access Event, Domain -Event, or Outbox Message may occur on the denied path. +Gate every typed Action at the existing permission stage with a Core-owned SpiceDB authorization service. Losslessly encode each Action descriptor's stable, globally unique `actionKey` as an `ak_`-prefixed base64url SpiceDB Action object identifier, keep the existing trusted OntOS `principalId` as the subject identifier, and make the permission check run after the durable invocation has been created but before the invocation becomes `running`, the business transaction opens, or the private handler can execute. + +Preserve the requested compatibility rule that an Action with no SpiceDB restriction record is allowed. Represent that rule explicitly in the SpiceDB schema: an `action` object whose id is the encoded Action key is restricted only when it has a self-referential restriction-marker relationship. An unconfigured Action has no marker and is allowed; a restricted Action must grant the `execute` permission to the `principal` object whose id is the trusted principal id, otherwise it is denied. A valid negative marker check is the only fail-open path. Missing configuration, network failures, timeouts, schema errors, conditional decisions, and other indeterminate SpiceDB responses are typed infrastructure failures and fail closed. + +On a definite denial, atomically transition the independently persisted Action Invocation from `received` to terminal `rejected`, set `completed_at`, and insert one terminal `action.rejected` Audit Event with normalized authz outcome data. Only after that evidence transaction commits may Core return a typed `ActionPermissionDenied` carrying a safe human-readable reason. No handler, business transaction, business write, Data Access Event, Domain Event, or Outbox Message may occur on the denied path. ## User Story -As an OntOS administrator -I want Action execution to honor centrally managed SpiceDB permissions -So that unauthorized state changes are blocked and leave durable evidence +As an OntOS administrator I want Action execution to honor centrally managed SpiceDB permissions So that unauthorized state changes are blocked and leave durable evidence ## Problem Statement -The Action runtime currently exposes explicit authentication, permission, and -policy stage boundaries, but the permission boundary is deliberately deferred -and performs no check. Consequently every structurally valid request with a -trusted principal can advance to `running` and invoke its handler regardless -of SpiceDB relationships. Core also has no SpiceDB client/configuration layer, -local SpiceDB service, typed authorization failures, or denial evidence path. - -The current authoritative `docs/architecture/ACTIONS.md` says rejected or -failed attempts remain open and produce no Audit Event, while issue 71 -specifically requires a permission denial to update `action_invocations` and -create an `audit_events` row. The implementation must therefore make authz -denial a narrow documented terminal-rejection exception without changing the -existing open-invocation behavior for domain rejection, handler failure, or -infrastructure failure. +The Action runtime currently exposes explicit authentication, permission, and policy stage boundaries, but the permission boundary is deliberately deferred and performs no check. Consequently every structurally valid request with a trusted principal can advance to `running` and invoke its handler regardless of SpiceDB relationships. Core also has no SpiceDB client/configuration layer, local SpiceDB service, typed authorization failures, or denial evidence path. + +The current authoritative `docs/architecture/ACTIONS.md` says rejected or failed attempts remain open and produce no Audit Event, while issue 71 specifically requires a permission denial to update `action_invocations` and create an `audit_events` row. The implementation must therefore make authz denial a narrow documented terminal-rejection exception without changing the existing open-invocation behavior for domain rejection, handler failure, or infrastructure failure. ## Solution Statement -Add a pinned local SpiceDB container and a Core-owned bootstrap authorization -schema, then wrap the official Node client in a scoped Effect service with -typed configuration and check failures. The service performs fully consistent -permission checks because no relationship-write/ZedToken flow exists yet: -first check the Action's self-marker permission, return an explicit -`unconfigured` allow decision when the marker is absent, and otherwise check -the principal's `execute` permission. Feed that service into -`makeActionRuntime`, replacing `permission_boundary_deferred` with a real -permission stage before the `running` transition. - -Extend the Core Action error union with a safe denial error and a separate -fail-closed permission-check infrastructure error. Add a typed repository -operation that serializes concurrent denials, atomically persists exactly one -terminal denial Audit Event and the `rejected` invocation transition, and is -idempotent when the same invocation has already been rejected. Existing Core -schema types and constraints already support `rejected`, `completed_at`, -`outcome = denied`, and `outcome_stage = authz`, so this feature requires no -Core Drizzle schema change or migration. +Add a pinned local SpiceDB container and a Core-owned bootstrap authorization schema, then wrap the official Node client in a scoped Effect service with typed configuration and check failures. The service performs fully consistent permission checks because no relationship-write/ZedToken flow exists yet: first check the Action's self-marker permission, return an explicit `unconfigured` allow decision when the marker is absent, and otherwise check the principal's `execute` permission. Feed that service into `makeActionRuntime`, replacing `permission_boundary_deferred` with a real permission stage before the `running` transition. + +Extend the Core Action error union with a safe denial error and a separate fail-closed permission-check infrastructure error. Add a typed repository operation that serializes concurrent denials, atomically persists exactly one terminal denial Audit Event and the `rejected` invocation transition, and is idempotent when the same invocation has already been rejected. Existing Core schema types and constraints already support `rejected`, `completed_at`, `outcome = denied`, and `outcome_stage = authz`, so this feature requires no Core Drizzle schema change or migration. ## Relevant Files Use these files to implement the feature: -- `../AGENTS.md` — application scope, read-only directories, and mandatory - Codesmith rules; no Action or Policy is created by this infrastructure - feature, so no generator applies. -- `AGENTS.md` — authoritative Core, Action, Effect error, database, and - toolchain constraints. -- `README.md` — workspace shape, strict Effect topology, and local command - conventions. -- `docs/architecture/ACTIONS.md` — authoritative Action stage order and - rejection/evidence lifecycle that must describe the new permission gate and - the terminal authz-denial exception. -- `docs/architecture/ERRORS.md` — typed Effect error and eventual BFF HTTP - mapping rules; permission denial maps to `403` and permission-service - unavailability maps to `503`. -- `docs/architecture/DATABASE.md` — typed Drizzle/Effect persistence and Core - schema-ownership rules for the denial evidence transaction. -- `docs/architecture/MICROVERTICALS.md` — Core authorization must remain - shared infrastructure and must not introduce imports across business - MicroVertical seams. -- `docs/architecture/ULTRAMODERN.md` — infrastructure-file exception, - Effect-first implementation rules, and prohibition on ad hoc business file - creation. -- `../docs/06_CORE_KERNEL.md` — product architecture assigning the SpiceDB - adapter and Action enforcement to Core. -- `../docs/07_RUNTIME_CONSISTENCY_MODEL.md` — normalized authz Audit Event - outcomes, codes, and terminal `rejected` invocation semantics. -- `../docs/09_AUTHN_AUTHZ_MODEL.md` — separation of BetterAuth authentication, - OntOS principals, SpiceDB relationship authorization, and business policy. -- `../docs/evidence/mvp/22_MVP2_CORESDK_IMPLEMENTATION_REQUIREMENTS.md` — evidence - transaction and handler non-execution requirements for denied writes. -- `../docs/evidence/mvp/23_CORESDK_OPERATION_FLOW_DESIGN.md` — Action permission ordering, - `403` semantics, and audit checkpoint guidance. -- `docker-compose.yml` — local `ontos` Compose project that must gain the - correctly named, pinned SpiceDB service. -- `.env.example` — non-secret local SpiceDB endpoint, ports, development key, - and explicit insecure-local-client configuration. +- `../AGENTS.md` — application scope, read-only directories, and mandatory Codesmith rules; no Action or Policy is created by this infrastructure feature, so no generator applies. +- `AGENTS.md` — authoritative Core, Action, Effect error, database, and toolchain constraints. +- `README.md` — workspace shape, strict Effect topology, and local command conventions. +- `docs/architecture/ACTIONS.md` — authoritative Action stage order and rejection/evidence lifecycle that must describe the new permission gate and the terminal authz-denial exception. +- `docs/architecture/ERRORS.md` — typed Effect error and eventual BFF HTTP mapping rules; permission denial maps to `403` and permission-service unavailability maps to `503`. +- `docs/architecture/DATABASE.md` — typed Drizzle/Effect persistence and Core schema-ownership rules for the denial evidence transaction. +- `docs/architecture/MICROVERTICALS.md` — Core authorization must remain shared infrastructure and must not introduce imports across business MicroVertical seams. +- `docs/architecture/ULTRAMODERN.md` — infrastructure-file exception, Effect-first implementation rules, and prohibition on ad hoc business file creation. +- `../docs/06_CORE_KERNEL.md` — product architecture assigning the SpiceDB adapter and Action enforcement to Core. +- `../docs/07_RUNTIME_CONSISTENCY_MODEL.md` — normalized authz Audit Event outcomes, codes, and terminal `rejected` invocation semantics. +- `../docs/09_AUTHN_AUTHZ_MODEL.md` — separation of BetterAuth authentication, OntOS principals, SpiceDB relationship authorization, and business policy. +- `../docs/evidence/mvp/22_MVP2_CORESDK_IMPLEMENTATION_REQUIREMENTS.md` — evidence transaction and handler non-execution requirements for denied writes. +- `../docs/evidence/mvp/23_CORESDK_OPERATION_FLOW_DESIGN.md` — Action permission ordering, `403` semantics, and audit checkpoint guidance. +- `docker-compose.yml` — local `ontos` Compose project that must gain the correctly named, pinned SpiceDB service. +- `.env.example` — non-secret local SpiceDB endpoint, ports, development key, and explicit insecure-local-client configuration. - `package.json` — root Action test orchestration and final quality gate. -- `pnpm-workspace.yaml` — dependency policy that the official SpiceDB client - must satisfy. +- `pnpm-workspace.yaml` — dependency policy that the official SpiceDB client must satisfy. - `pnpm-lock.yaml` — lockfile updated by the repository-managed pnpm install. -- `packages/core-runtime/package.json` — Core ownership of the official - SpiceDB Node client and focused permission/runtime test scripts. -- `packages/core-runtime/src/actions/definition.ts` — existing globally unique - `actionKey` descriptor contract losslessly mapped to the SpiceDB Action identifier. -- `packages/core-runtime/src/actions/errors.ts` — transport-neutral Core Action - error union and status-mapping guidance. -- `packages/core-runtime/src/actions/repository.ts` — typed Drizzle repository - operations for invocation and Audit Event persistence. -- `packages/core-runtime/src/actions/runtime.ts` — deferred permission boundary - to replace with the real pre-handler authorization gate. -- `packages/core-runtime/src/db/schema.ts` — existing `rejected` invocation and - authz Audit Event constraints that prove a migration is unnecessary. -- `packages/core-runtime/src/index.ts` — narrow public Action errors/runtime - surface; the raw SpiceDB client must remain private. -- `packages/core-runtime/tests/unit/action-definition.test.ts` — descriptor - tests for stable Action-key permission identity. -- `packages/core-runtime/tests/unit/action-errors.test.ts` — exhaustive public - Core Action error-tag and safe-message tests. -- `packages/core-runtime/tests/unit/action-runtime.test.ts` — stage ordering, - fake permission decisions, fail-closed behavior, and handler non-execution - tests. -- `packages/core-runtime/tests/unit/action-public-surface.test.ts` — proves the - authorization adapter/client does not leak through the public package API. -- `packages/core-runtime/tests/integration/action-runtime.test.ts` — existing - PostgreSQL Action lifecycle tests that must keep all non-authz failure - behavior unchanged and exercise denial-persistence rollback. +- `packages/core-runtime/package.json` — Core ownership of the official SpiceDB Node client and focused permission/runtime test scripts. +- `packages/core-runtime/src/actions/definition.ts` — existing globally unique `actionKey` descriptor contract losslessly mapped to the SpiceDB Action identifier. +- `packages/core-runtime/src/actions/errors.ts` — transport-neutral Core Action error union and status-mapping guidance. +- `packages/core-runtime/src/actions/repository.ts` — typed Drizzle repository operations for invocation and Audit Event persistence. +- `packages/core-runtime/src/actions/runtime.ts` — deferred permission boundary to replace with the real pre-handler authorization gate. +- `packages/core-runtime/src/db/schema.ts` — existing `rejected` invocation and authz Audit Event constraints that prove a migration is unnecessary. +- `packages/core-runtime/src/index.ts` — narrow public Action errors/runtime surface; the raw SpiceDB client must remain private. +- `packages/core-runtime/tests/unit/action-definition.test.ts` — descriptor tests for stable Action-key permission identity. +- `packages/core-runtime/tests/unit/action-errors.test.ts` — exhaustive public Core Action error-tag and safe-message tests. +- `packages/core-runtime/tests/unit/action-runtime.test.ts` — stage ordering, fake permission decisions, fail-closed behavior, and handler non-execution tests. +- `packages/core-runtime/tests/unit/action-public-surface.test.ts` — proves the authorization adapter/client does not leak through the public package API. +- `packages/core-runtime/tests/integration/action-runtime.test.ts` — existing PostgreSQL Action lifecycle tests that must keep all non-authz failure behavior unchanged and exercise denial-persistence rollback. ### New Files -- `packages/core-runtime/spicedb/bootstrap.yaml` — Core-owned local SpiceDB - bootstrap schema and schema validation fixtures for the Action restriction - marker and `execute` grant. -- `packages/core-runtime/src/permissions/config-error.ts` — typed, sanitized - SpiceDB configuration failure. -- `packages/core-runtime/src/permissions/config.ts` — root-environment loading - and validation for endpoint, pre-shared key, and explicit local insecure - mode. -- `packages/core-runtime/src/permissions/service.ts` — scoped Effect wrapper - around the official SpiceDB client and the typed Action permission-decision - contract. -- `packages/core-runtime/tests/unit/action-permission.test.ts` — configuration, - request mapping, decision classification, timeout/unavailability, and client - finalization tests. -- `packages/core-runtime/tests/integration/action-permission.test.ts` — live - SpiceDB plus PostgreSQL proof of unconfigured allow, configured allow, - configured denial, fail-closed unavailability, and durable denial evidence. +- `packages/core-runtime/spicedb/bootstrap.yaml` — Core-owned local SpiceDB bootstrap schema and schema validation fixtures for the Action restriction marker and `execute` grant. +- `packages/core-runtime/src/permissions/config-error.ts` — typed, sanitized SpiceDB configuration failure. +- `packages/core-runtime/src/permissions/config.ts` — root-environment loading and validation for endpoint, pre-shared key, and explicit local insecure mode. +- `packages/core-runtime/src/permissions/service.ts` — scoped Effect wrapper around the official SpiceDB client and the typed Action permission-decision contract. +- `packages/core-runtime/tests/unit/action-permission.test.ts` — configuration, request mapping, decision classification, timeout/unavailability, and client finalization tests. +- `packages/core-runtime/tests/integration/action-permission.test.ts` — live SpiceDB plus PostgreSQL proof of unconfigured allow, configured allow, configured denial, fail-closed unavailability, and durable denial evidence. ## Implementation Plan ### Phase 1: Foundation -Add the pinned `authzed/spicedb:v1.56.0` local service as -`ontos-spicedb`, mount a Core-owned bootstrap schema, expose the gRPC and HTTP -development ports, authenticate with a non-secret development-only pre-shared -key from `.env.example`, and add a gRPC health check. Install the official -`@authzed/authzed-node@1.6.1` client exactly in `@app/core-runtime`. Build a -scoped Effect configuration/client service that never silently downgrades TLS, -never exposes the key, raw client, or raw gRPC errors, and distinguishes valid -negative decisions from service failure. - -The bootstrap schema defines `principal` and `action`. Each Action can have a -self-referential `restriction` marker and an `executor` relation to a -principal. `permission is_restricted = restriction` answers whether an Action -has a permission record; `permission execute = executor` answers whether the -current principal may execute a restricted Action. The marker tuple uses the -lossless SpiceDB-safe encoding, for example -`action:ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU#restriction@action:ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU`. +Add the pinned `authzed/spicedb:v1.56.0` local service as `ontos-spicedb`, mount a Core-owned bootstrap schema, expose the gRPC and HTTP development ports, authenticate with a non-secret development-only pre-shared key from `.env.example`, and add a gRPC health check. Install the official `@authzed/authzed-node@1.6.1` client exactly in `@app/core-runtime`. Build a scoped Effect configuration/client service that never silently downgrades TLS, never exposes the key, raw client, or raw gRPC errors, and distinguishes valid negative decisions from service failure. + +The bootstrap schema defines `principal` and `action`. Each Action can have a self-referential `restriction` marker and an `executor` relation to a principal. `permission is_restricted = restriction` answers whether an Action has a permission record; `permission execute = executor` answers whether the current principal may execute a restricted Action. The marker tuple uses the lossless SpiceDB-safe encoding, for example `action:ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU#restriction@action:ak_aW52ZW50b3J5LnN0b2NrLnJlc2VydmU`. ### Phase 2: Core Implementation -Map `ActionDescriptor.actionKey` losslessly to `ak_` plus unpadded base64url so -the existing dotted keys satisfy SpiceDB's object-id alphabet, and make its -global uniqueness and immutability explicit in the descriptor contract. -Return a typed decision union from the permission service: -`unconfigured` (valid negative marker check, allow), `allowed` (marker and -execute checks both positive), or `denied` (marker positive, execute negative). -Map conditional/unknown decisions, timeouts, invalid schema responses, client -errors, and missing configuration to typed fail-closed errors rather than -conflating them with an absent marker. - -Extend the Action runtime so the permission decision occurs after invocation -creation/idempotency verification and before the `received`-to-`running` -transition. On denial, call a new repository operation that locks the -invocation, inserts exactly one `action.rejected` Audit Event with -`outcome = denied`, `outcome_stage = authz`, and -`outcome_code = spicedb_permission_denied`, and updates the invocation to -`rejected` with `completed_at` in one Core transaction. Return the typed denial -only after that commit. Preserve the current open invocation and no-audit -behavior for SpiceDB infrastructure failure and all non-authz failures. +Map `ActionDescriptor.actionKey` losslessly to `ak_` plus unpadded base64url so the existing dotted keys satisfy SpiceDB's object-id alphabet, and make its global uniqueness and immutability explicit in the descriptor contract. Return a typed decision union from the permission service: `unconfigured` (valid negative marker check, allow), `allowed` (marker and execute checks both positive), or `denied` (marker positive, execute negative). Map conditional/unknown decisions, timeouts, invalid schema responses, client errors, and missing configuration to typed fail-closed errors rather than conflating them with an absent marker. + +Extend the Action runtime so the permission decision occurs after invocation creation/idempotency verification and before the `received`-to-`running` transition. On denial, call a new repository operation that locks the invocation, inserts exactly one `action.rejected` Audit Event with `outcome = denied`, `outcome_stage = authz`, and `outcome_code = spicedb_permission_denied`, and updates the invocation to `rejected` with `completed_at` in one Core transaction. Return the typed denial only after that commit. Preserve the current open invocation and no-audit behavior for SpiceDB infrastructure failure and all non-authz failures. ### Phase 3: Integration -Wire the live permission Effect layer into `ActionRuntimeLive` while keeping -the raw client private and fakeable through `makeActionRuntime` tests. Expand -unit tests beside each behavior, then add a live Compose-backed integration -suite that writes isolated relationships, proves all three decision states, -asserts the exact Action/principal identifiers, and verifies that denied -attempts create only the terminal invocation/audit evidence. Keep the existing -successful Action transaction, idempotency, concurrency, rollback, -indeterminate-commit, and commit-resolution tests green. +Wire the live permission Effect layer into `ActionRuntimeLive` while keeping the raw client private and fakeable through `makeActionRuntime` tests. Expand unit tests beside each behavior, then add a live Compose-backed integration suite that writes isolated relationships, proves all three decision states, asserts the exact Action/principal identifiers, and verifies that denied attempts create only the terminal invocation/audit evidence. Keep the existing successful Action transaction, idempotency, concurrency, rollback, indeterminate-commit, and commit-resolution tests green. ## Step by Step Tasks @@ -230,293 +98,109 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Add the local SpiceDB service and authorization schema -- [x] Update `docker-compose.yml` with service key and `container_name` - `ontos-spicedb`, pinned image `authzed/spicedb:v1.56.0`, memory datastore - for local development, pre-shared-key authentication, gRPC/HTTP port - mappings, a read-only bootstrap mount from - `packages/core-runtime/spicedb/bootstrap.yaml`, and a gRPC readiness - health check; do not couple SpiceDB storage to the Core-owned `core` - PostgreSQL schema. -- [x] Extend `.env.example` with non-secret development values for - `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_GRPC_PORT=50051`, - `SPICEDB_HTTP_PORT=8443`, `SPICEDB_PRESHARED_KEY`, and - `SPICEDB_INSECURE=true`; keep production TLS/secret provisioning outside - committed configuration and never read or overwrite a developer's - `.env`. -- [x] Create `packages/core-runtime/spicedb/bootstrap.yaml` with the minimal - `principal`/`action` schema, self-referential restriction marker, - `is_restricted` and `execute` permissions, and validation fixtures that - prove absent marker, restricted grant, and restricted denial semantics. -- [x] Do not run an Action or Policy Codesmith generator: this step adds - Core-owned authorization infrastructure and does not create either - supported business file type. -- [x] Validate Compose interpolation, the exact container name, bootstrap - mount, pinned image, ports, and readiness behavior with the matching - commands under Validation Commands. +- [x] Update `docker-compose.yml` with service key and `container_name` `ontos-spicedb`, pinned image `authzed/spicedb:v1.56.0`, memory datastore for local development, pre-shared-key authentication, gRPC/HTTP port mappings, a read-only bootstrap mount from `packages/core-runtime/spicedb/bootstrap.yaml`, and a gRPC readiness health check; do not couple SpiceDB storage to the Core-owned `core` PostgreSQL schema. +- [x] Extend `.env.example` with non-secret development values for `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_GRPC_PORT=50051`, `SPICEDB_HTTP_PORT=8443`, `SPICEDB_PRESHARED_KEY`, and `SPICEDB_INSECURE=true`; keep production TLS/secret provisioning outside committed configuration and never read or overwrite a developer's `.env`. +- [x] Create `packages/core-runtime/spicedb/bootstrap.yaml` with the minimal `principal`/`action` schema, self-referential restriction marker, `is_restricted` and `execute` permissions, and validation fixtures that prove absent marker, restricted grant, and restricted denial semantics. +- [x] Do not run an Action or Policy Codesmith generator: this step adds Core-owned authorization infrastructure and does not create either supported business file type. +- [x] Validate Compose interpolation, the exact container name, bootstrap mount, pinned image, ports, and readiness behavior with the matching commands under Validation Commands. ### 2. Add the scoped Effect SpiceDB client and decision service -- [x] From `app/`, install the exact official client with - `mise exec -- pnpm --filter @app/core-runtime add --save-exact @authzed/authzed-node@1.6.1` - so `packages/core-runtime/package.json` and `pnpm-lock.yaml` remain owned - by the repository-managed toolchain. -- [x] Add typed configuration loading in - `packages/core-runtime/src/permissions/config.ts` and - `config-error.ts`, following the database configuration's explicit root - path and Effect Layer pattern; validate a non-empty endpoint/key and - require an explicit insecure-local flag rather than silently disabling - transport security. -- [x] Add `packages/core-runtime/src/permissions/service.ts` as a scoped - Effect service around the official client, close its gRPC resources on - scope release, apply bounded request deadlines, use fully consistent - checks, and attach safe correlation metadata without logging the - pre-shared key. -- [x] Implement the two-check algorithm with constants for object/permission - names: check `is_restricted` on the Action object whose id is the lossless - `ak_`-prefixed base64url encoding of the Action key, using that same Action - object as subject; return - `unconfigured` only for a valid - `NO_PERMISSION`, otherwise check - `execute` on that Action object for the `principal` object whose id is - the trusted principal id, and classify the positive/negative result as - `allowed`/`denied`. -- [x] Map conditional/unknown permissionship, deadlines, unavailable service, - authentication failure, schema mismatch, and malformed client responses - to a sanitized typed permission-check error. These paths must fail - closed and must never be reclassified as an absent Action record. -- [x] Add `action-permission.test.ts` unit tests for root-independent config - discovery, missing/malformed config, explicit insecure-local handling, - exact Action/principal identifier mapping, fully consistent checks, all - decision states, bounded failure, secret sanitization, and client - finalization. +- [x] From `app/`, install the exact official client with `mise exec -- pnpm --filter @app/core-runtime add --save-exact @authzed/authzed-node@1.6.1` so `packages/core-runtime/package.json` and `pnpm-lock.yaml` remain owned by the repository-managed toolchain. +- [x] Add typed configuration loading in `packages/core-runtime/src/permissions/config.ts` and `config-error.ts`, following the database configuration's explicit root path and Effect Layer pattern; validate a non-empty endpoint/key and require an explicit insecure-local flag rather than silently disabling transport security. +- [x] Add `packages/core-runtime/src/permissions/service.ts` as a scoped Effect service around the official client, close its gRPC resources on scope release, apply bounded request deadlines, use fully consistent checks, and attach safe correlation metadata without logging the pre-shared key. +- [x] Implement the two-check algorithm with constants for object/permission names: check `is_restricted` on the Action object whose id is the lossless `ak_`-prefixed base64url encoding of the Action key, using that same Action object as subject; return `unconfigured` only for a valid `NO_PERMISSION`, otherwise check `execute` on that Action object for the `principal` object whose id is the trusted principal id, and classify the positive/negative result as `allowed`/`denied`. +- [x] Map conditional/unknown permissionship, deadlines, unavailable service, authentication failure, schema mismatch, and malformed client responses to a sanitized typed permission-check error. These paths must fail closed and must never be reclassified as an absent Action record. +- [x] Add `action-permission.test.ts` unit tests for root-independent config discovery, missing/malformed config, explicit insecure-local handling, exact Action/principal identifier mapping, fully consistent checks, all decision states, bounded failure, secret sanitization, and client finalization. ### 3. Extend the typed Core Action error contract -- [x] Add `ActionPermissionDenied` with stable code - `action_permission_denied` and a safe human-readable `reason` message, - plus a distinct `ActionPermissionCheckError` with stable code - `action_permission_check_failed`, to - `packages/core-runtime/src/actions/errors.ts`. -- [x] Add both errors to `ActionCoreError`, `ACTION_CORE_ERROR_TAGS`, the - public exports in `packages/core-runtime/src/index.ts`, and the - transport-neutral status guidance: denial is eventually mapped - exhaustively to HTTP `403`; an unavailable/indeterminate permission - capability maps to `503`. -- [x] Update `action-errors.test.ts` to prove the exhaustive tag order, stable - codes, safe denial reason, absence of credentials/internal details, and - continued transport neutrality; update `action-public-surface.test.ts` - to prove only the typed errors are exported and the raw client/config - service stays private. -- [x] Update `docs/architecture/ERRORS.md` with the new internal-to-public - mappings while preserving the rule that each future Action BFF contract - must declare its public `403`/`503` Problem Details schemas. Do not add a - generic Action HTTP endpoint merely to exercise status mapping. +- [x] Add `ActionPermissionDenied` with stable code `action_permission_denied` and a safe human-readable `reason` message, plus a distinct `ActionPermissionCheckError` with stable code `action_permission_check_failed`, to `packages/core-runtime/src/actions/errors.ts`. +- [x] Add both errors to `ActionCoreError`, `ACTION_CORE_ERROR_TAGS`, the public exports in `packages/core-runtime/src/index.ts`, and the transport-neutral status guidance: denial is eventually mapped exhaustively to HTTP `403`; an unavailable/indeterminate permission capability maps to `503`. +- [x] Update `action-errors.test.ts` to prove the exhaustive tag order, stable codes, safe denial reason, absence of credentials/internal details, and continued transport neutrality; update `action-public-surface.test.ts` to prove only the typed errors are exported and the raw client/config service stays private. +- [x] Update `docs/architecture/ERRORS.md` with the new internal-to-public mappings while preserving the rule that each future Action BFF contract must declare its public `403`/`503` Problem Details schemas. Do not add a generic Action HTTP endpoint merely to exercise status mapping. ### 4. Persist permission denials as terminal evidence -- [x] Add a typed `rejectPermissionDenied` repository operation in - `packages/core-runtime/src/actions/repository.ts` that opens a Core-owned - Drizzle transaction, locks the invocation, and accepts only the matching - open `received` state or an already completed `rejected` state for the - same invocation. -- [x] In that transaction, insert exactly one terminal Audit Event with - `event_type = action.rejected`, `outcome = denied`, - `outcome_stage = authz`, - `outcome_code = spicedb_permission_denied`, the descriptor audit - profile, trusted actor/tenant/legal-entity and target context, and only - small redacted evidence such as the stable Action key; then update the - invocation to `status = rejected` with `completed_at` set. -- [x] Make concurrent persistence for the same denied idempotent invocation - serialize on the invocation lock and produce one Audit Event. Reject an - incompatible lifecycle state through the existing typed invocation - state/persistence errors rather than overwriting it. -- [x] If either the Audit Event insert or invocation update fails, roll back - both writes, return a typed infrastructure error, keep the handler - unexecuted, and do not return a denial response that falsely claims the - required evidence was persisted. -- [x] Extend unit repository/runtime fakes and PostgreSQL integration tests to - prove atomic denial persistence, duplicate/concurrent denial behavior, - audit-insert rollback, invocation-update rollback, no secret/payload - evidence, and no change to existing non-authz open-invocation behavior. +- [x] Add a typed `rejectPermissionDenied` repository operation in `packages/core-runtime/src/actions/repository.ts` that opens a Core-owned Drizzle transaction, locks the invocation, and accepts only the matching open `received` state or an already completed `rejected` state for the same invocation. +- [x] In that transaction, insert exactly one terminal Audit Event with `event_type = action.rejected`, `outcome = denied`, `outcome_stage = authz`, `outcome_code = spicedb_permission_denied`, the descriptor audit profile, trusted actor/tenant/legal-entity and target context, and only small redacted evidence such as the stable Action key; then update the invocation to `status = rejected` with `completed_at` set. +- [x] Make concurrent persistence for the same denied idempotent invocation serialize on the invocation lock and produce one Audit Event. Reject an incompatible lifecycle state through the existing typed invocation state/persistence errors rather than overwriting it. +- [x] If either the Audit Event insert or invocation update fails, roll back both writes, return a typed infrastructure error, keep the handler unexecuted, and do not return a denial response that falsely claims the required evidence was persisted. +- [x] Extend unit repository/runtime fakes and PostgreSQL integration tests to prove atomic denial persistence, duplicate/concurrent denial behavior, audit-insert rollback, invocation-update rollback, no secret/payload evidence, and no change to existing non-authz open-invocation behavior. ### 5. Enforce permission before Action execution -- [x] Inject the permission decision service into `makeActionRuntime` and - `ActionRuntimeLive`; update every test runtime construction with an - explicit fake decision so no unit test accidentally depends on a live - external service. -- [x] In `runAction`, perform authorization after - `createOrResolveInvocation` and `verifyInvocation`, notify a real - `permission_checked` stage instead of - `permission_boundary_deferred`, and retain - `policy_boundary_deferred` after a permission allow. -- [x] For `unconfigured` and `allowed`, continue to the existing independent - `running` transition and business transaction without changing handler - inputs or exposing permission control flow to a MicroVertical. -- [x] For `denied`, persist the terminal rejection evidence and then fail with - `ActionPermissionDenied`; never transition the invocation to `running`, - acquire the business transaction, create a collector, or invoke the - handler. -- [x] For permission configuration/client/check failure, fail closed with the - typed infrastructure error, leave the invocation open in `received` - without pretending a denial occurred, and never execute the handler. -- [x] Update `action-runtime.test.ts` with ordered-stage assertions and spies - proving Action-key identity, unconfigured allow, configured allow, - denial, check failure, idempotency/hash checks before authorization, - no handler/business transaction on blocked paths, and unchanged policy - placement. -- [x] Update `docs/architecture/ACTIONS.md` to replace the deferred permission - increment, document the absent-marker compatibility rule and fail-closed - infrastructure behavior, and define terminal authz denial as the narrow - exception to the otherwise-open definite-rejection lifecycle. +- [x] Inject the permission decision service into `makeActionRuntime` and `ActionRuntimeLive`; update every test runtime construction with an explicit fake decision so no unit test accidentally depends on a live external service. +- [x] In `runAction`, perform authorization after `createOrResolveInvocation` and `verifyInvocation`, notify a real `permission_checked` stage instead of `permission_boundary_deferred`, and retain `policy_boundary_deferred` after a permission allow. +- [x] For `unconfigured` and `allowed`, continue to the existing independent `running` transition and business transaction without changing handler inputs or exposing permission control flow to a MicroVertical. +- [x] For `denied`, persist the terminal rejection evidence and then fail with `ActionPermissionDenied`; never transition the invocation to `running`, acquire the business transaction, create a collector, or invoke the handler. +- [x] For permission configuration/client/check failure, fail closed with the typed infrastructure error, leave the invocation open in `received` without pretending a denial occurred, and never execute the handler. +- [x] Update `action-runtime.test.ts` with ordered-stage assertions and spies proving Action-key identity, unconfigured allow, configured allow, denial, check failure, idempotency/hash checks before authorization, no handler/business transaction on blocked paths, and unchanged policy placement. +- [x] Update `docs/architecture/ACTIONS.md` to replace the deferred permission increment, document the absent-marker compatibility rule and fail-closed infrastructure behavior, and define terminal authz denial as the narrow exception to the otherwise-open definite-rejection lifecycle. ### 6. Prove the live SpiceDB and PostgreSQL integration -- [x] Add `packages/core-runtime/tests/integration/action-permission.test.ts` - and update the package's `action:test:integration` script to include all - Action integration tests without weakening `db:test`. -- [x] Against the healthy Compose service, use the official client to create - isolated Action/principal relationships and clean them after the suite; - do not depend on global developer permission fixtures or mutate a - production authorization graph. -- [x] Prove an Action without a restriction marker is allowed, a marked Action - with an executor relationship is allowed, and a marked Action without - an executor relationship returns the typed denial with its safe reason. -- [x] For the denied case, prove the handler counter remains zero, no test - business row/Data Access Event/Domain Event/Outbox Message exists, the - invocation is terminal `rejected` with `completed_at`, and exactly one - linked `action.rejected` Audit Event has the normalized authz outcome. -- [x] Prove SpiceDB outage or invalid credentials return the typed fail-closed - check error, do not run the handler, do not create denial evidence, and - leave the invocation retryable in `received`. -- [x] Re-run the existing Action integration suite to prove success atomicity, - handler/domain rejection rollback, concurrency, idempotency conflict, - indeterminate commit, and commit resolution have not regressed. +- [x] Add `packages/core-runtime/tests/integration/action-permission.test.ts` and update the package's `action:test:integration` script to include all Action integration tests without weakening `db:test`. +- [x] Against the healthy Compose service, use the official client to create isolated Action/principal relationships and clean them after the suite; do not depend on global developer permission fixtures or mutate a production authorization graph. +- [x] Prove an Action without a restriction marker is allowed, a marked Action with an executor relationship is allowed, and a marked Action without an executor relationship returns the typed denial with its safe reason. +- [x] For the denied case, prove the handler counter remains zero, no test business row/Data Access Event/Domain Event/Outbox Message exists, the invocation is terminal `rejected` with `completed_at`, and exactly one linked `action.rejected` Audit Event has the normalized authz outcome. +- [x] Prove SpiceDB outage or invalid credentials return the typed fail-closed check error, do not run the handler, do not create denial evidence, and leave the invocation retryable in `received`. +- [x] Re-run the existing Action integration suite to prove success atomicity, handler/domain rejection rollback, concurrency, idempotency conflict, indeterminate commit, and commit resolution have not regressed. ### 7. Run every validation command -- [x] Execute every command listed under Validation Commands in order and - resolve failures without weakening authorization, typed errors, denial - evidence atomicity, Core database ownership, or existing workspace - gates. -- [x] Inspect `git status --short` and `git diff --check` afterward; confirm no - `.env`, SpiceDB credentials, local datastore data, generated build - output, unrelated application changes, or manual Action/Policy scaffold - is included. +- [x] Execute every command listed under Validation Commands in order and resolve failures without weakening authorization, typed errors, denial evidence atomicity, Core database ownership, or existing workspace gates. +- [x] Inspect `git status --short` and `git diff --check` afterward; confirm no `.env`, SpiceDB credentials, local datastore data, generated build output, unrelated application changes, or manual Action/Policy scaffold is included. ## Testing Strategy ### Unit Tests -Use a fake official-client seam and explicit permission service fakes to test -configuration parsing, secure/insecure client construction, resource and -subject mapping, the two-check decision table, fully consistent requests, -timeouts, typed sanitization, client finalization, stage order, and every -runtime branch. Assert that the existing descriptor `actionKey` is mapped -losslessly and collision-free and that different Action keys address different SpiceDB objects. -Exercise the denial repository contract with fake transaction executors so -Audit Event and invocation-update failures cannot partially persist or allow -the handler to run. Keep the exhaustive Action error union and narrow public -surface tests aligned. +Use a fake official-client seam and explicit permission service fakes to test configuration parsing, secure/insecure client construction, resource and subject mapping, the two-check decision table, fully consistent requests, timeouts, typed sanitization, client finalization, stage order, and every runtime branch. Assert that the existing descriptor `actionKey` is mapped losslessly and collision-free and that different Action keys address different SpiceDB objects. Exercise the denial repository contract with fake transaction executors so Audit Event and invocation-update failures cannot partially persist or allow the handler to run. Keep the exhaustive Action error union and narrow public surface tests aligned. ### Integration Tests -Run PostgreSQL and the pinned SpiceDB container from Compose. Use unique -tenant, principal, Action, target, and idempotency identifiers. Create only -suite-owned SpiceDB relationships, exercise unconfigured/allowed/denied and -unavailable paths through the real Action runtime, inspect typed Drizzle rows -for invocation/audit/business/evidence results, and clean only suite-owned -fixtures. Retain the full existing PostgreSQL Action runtime suite as the -cross-boundary regression proof. +Run PostgreSQL and the pinned SpiceDB container from Compose. Use unique tenant, principal, Action, target, and idempotency identifiers. Create only suite-owned SpiceDB relationships, exercise unconfigured/allowed/denied and unavailable paths through the real Action runtime, inspect typed Drizzle rows for invocation/audit/business/evidence results, and clean only suite-owned fixtures. Retain the full existing PostgreSQL Action runtime suite as the cross-boundary regression proof. ### Edge Cases -- The Action has no restriction marker: allow only after a successful, - fully-determined negative `is_restricted` check. -- The Action has a restriction marker but no executor grant: deny, persist one - terminal evidence transaction, and return the safe typed reason. +- The Action has no restriction marker: allow only after a successful, fully-determined negative `is_restricted` check. +- The Action has a restriction marker but no executor grant: deny, persist one terminal evidence transaction, and return the safe typed reason. - The Action marker and executor grant both exist: allow and execute once. -- SpiceDB is unavailable, times out, rejects credentials, has no expected - schema, or returns conditional/unknown permissionship: fail closed without - classifying the Action as unconfigured. -- The denial Audit Event insert or invocation update fails: roll back both, - return infrastructure failure, and never invoke the handler. -- Two concurrent requests share one denied idempotency key: serialize the - denial transition, create one Audit Event, and execute no handler. -- A denied terminal invocation is retried with the same idempotency key: do - not reauthorize or execute it; preserve terminal invocation semantics. A - newly granted principal uses a new user-intent idempotency key. -- The same idempotency key has a different request hash: retain the existing - conflict result before any permission check. -- Action keys contain the repository's namespaced dotted format, while SpiceDB - object ids reject dots: use the documented lossless `ak_` plus unpadded - base64url mapping and never derive identity from a display label, route, - payload, or target. -- No principal or permission relationship contains tenant payload data: - authorization uses the trusted globally unique OntOS principal id, never a - caller-supplied subject. +- SpiceDB is unavailable, times out, rejects credentials, has no expected schema, or returns conditional/unknown permissionship: fail closed without classifying the Action as unconfigured. +- The denial Audit Event insert or invocation update fails: roll back both, return infrastructure failure, and never invoke the handler. +- Two concurrent requests share one denied idempotency key: serialize the denial transition, create one Audit Event, and execute no handler. +- A denied terminal invocation is retried with the same idempotency key: do not reauthorize or execute it; preserve terminal invocation semantics. A newly granted principal uses a new user-intent idempotency key. +- The same idempotency key has a different request hash: retain the existing conflict result before any permission check. +- Action keys contain the repository's namespaced dotted format, while SpiceDB object ids reject dots: use the documented lossless `ak_` plus unpadded base64url mapping and never derive identity from a display label, route, payload, or target. +- No principal or permission relationship contains tenant payload data: authorization uses the trusted globally unique OntOS principal id, never a caller-supplied subject. ## Acceptance Criteria -- [x] `docker-compose.yml` defines healthy container `ontos-spicedb` from - pinned image `authzed/spicedb:v1.56.0` with the committed Core-owned - bootstrap schema and non-secret `.env.example` contract. -- [x] `@app/core-runtime` uses pinned official client - `@authzed/authzed-node@1.6.1` behind a scoped, private Effect service with - typed configuration and check failures. -- [x] Every Action's existing stable, globally unique `actionKey` is losslessly - encoded into its SpiceDB Action object identifier and is documented as immutable once - relationships reference it. -- [x] A valid negative restriction-marker check allows an unconfigured Action; - a restricted Action executes only when the trusted principal has the - `execute` permission. -- [x] SpiceDB outage, timeout, authentication/schema error, and - conditional/unknown decisions fail closed and never become the - no-record allow case. -- [x] Permission evaluation occurs after durable invocation/idempotency - handling and before `running`, the business transaction, collector, and - handler. -- [x] A definite denial returns `ActionPermissionDenied` with stable code and - safe human-readable reason; status guidance maps it to public HTTP - `403` without adding a generic Action endpoint. -- [x] A definite denial executes no handler and atomically leaves one - `rejected` invocation with `completed_at` plus exactly one linked - `action.rejected` Audit Event with outcome `denied`, stage `authz`, and - code `spicedb_permission_denied`. -- [x] Denied and failed permission paths persist no business write, Data - Access Event, Domain Event, or Outbox Message. -- [x] Denial evidence persistence failure rolls back both Core evidence writes, - remains typed, and does not execute the handler. -- [x] Existing successful, domain-rejected, failed, concurrent, idempotent, - and indeterminate Action behavior remains covered and unchanged outside - the documented authz-denial exception. -- [x] The raw SpiceDB client, credentials, and private authorization service - are absent from browser/public package surfaces and audit evidence. -- [x] No Core Drizzle schema or migration is changed because the existing - lifecycle and Audit Event constraints already support this behavior. +- [x] `docker-compose.yml` defines healthy container `ontos-spicedb` from pinned image `authzed/spicedb:v1.56.0` with the committed Core-owned bootstrap schema and non-secret `.env.example` contract. +- [x] `@app/core-runtime` uses pinned official client `@authzed/authzed-node@1.6.1` behind a scoped, private Effect service with typed configuration and check failures. +- [x] Every Action's existing stable, globally unique `actionKey` is losslessly encoded into its SpiceDB Action object identifier and is documented as immutable once relationships reference it. +- [x] A valid negative restriction-marker check allows an unconfigured Action; a restricted Action executes only when the trusted principal has the `execute` permission. +- [x] SpiceDB outage, timeout, authentication/schema error, and conditional/unknown decisions fail closed and never become the no-record allow case. +- [x] Permission evaluation occurs after durable invocation/idempotency handling and before `running`, the business transaction, collector, and handler. +- [x] A definite denial returns `ActionPermissionDenied` with stable code and safe human-readable reason; status guidance maps it to public HTTP `403` without adding a generic Action endpoint. +- [x] A definite denial executes no handler and atomically leaves one `rejected` invocation with `completed_at` plus exactly one linked `action.rejected` Audit Event with outcome `denied`, stage `authz`, and code `spicedb_permission_denied`. +- [x] Denied and failed permission paths persist no business write, Data Access Event, Domain Event, or Outbox Message. +- [x] Denial evidence persistence failure rolls back both Core evidence writes, remains typed, and does not execute the handler. +- [x] Existing successful, domain-rejected, failed, concurrent, idempotent, and indeterminate Action behavior remains covered and unchanged outside the documented authz-denial exception. +- [x] The raw SpiceDB client, credentials, and private authorization service are absent from browser/public package surfaces and audit evidence. +- [x] No Core Drizzle schema or migration is changed because the existing lifecycle and Audit Event constraints already support this behavior. ## Validation Commands Execute every command to validate the feature with zero regressions. -- `docker compose --env-file .env.example config` — validate the Compose - project, exact service/container naming, pinned image, bootstrap mount, - ports, health check, and environment interpolation without reading `.env`. -- `docker compose --env-file .env.example up -d --wait ontos-db ontos-spicedb` - — start the isolated local PostgreSQL and SpiceDB dependencies and wait for - both health checks. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos mise exec -- pnpm db:migrate` - — apply the existing Core/Auth migrations to the local validation database; - no new Core migration should be generated by this feature. -- `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — run the - Action descriptor, permission adapter, error, runtime, and public-surface - unit tests. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos SPICEDB_ENDPOINT=localhost:50051 SPICEDB_PRESHARED_KEY=ontos-local-development-key SPICEDB_INSECURE=true mise exec -- pnpm --filter @app/core-runtime action:test:integration` - — prove the live PostgreSQL/SpiceDB permission gate and all existing Action - integration behavior using only committed development defaults. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters BETTER_AUTH_URL=http://localhost:3020 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3020,http://127.0.0.1:3020 mise exec -- pnpm db:verify` — verify all typed Core/Auth PostgreSQL schema - references and confirm that SpiceDB introduced no table into an OntOS-owned - PostgreSQL schema. +- `docker compose --env-file .env.example config` — validate the Compose project, exact service/container naming, pinned image, bootstrap mount, ports, health check, and environment interpolation without reading `.env`. +- `docker compose --env-file .env.example up -d --wait ontos-db ontos-spicedb` — start the isolated local PostgreSQL and SpiceDB dependencies and wait for both health checks. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos mise exec -- pnpm db:migrate` — apply the existing Core/Auth migrations to the local validation database; no new Core migration should be generated by this feature. +- `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — run the Action descriptor, permission adapter, error, runtime, and public-surface unit tests. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos SPICEDB_ENDPOINT=localhost:50051 SPICEDB_PRESHARED_KEY=ontos-local-development-key SPICEDB_INSECURE=true mise exec -- pnpm --filter @app/core-runtime action:test:integration` — prove the live PostgreSQL/SpiceDB permission gate and all existing Action integration behavior using only committed development defaults. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters BETTER_AUTH_URL=http://localhost:3020 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3020,http://127.0.0.1:3020 mise exec -- pnpm db:verify` — verify all typed Core/Auth PostgreSQL schema references and confirm that SpiceDB introduced no table into an OntOS-owned PostgreSQL schema. - `mise exec -- pnpm check` — Run the final repository quality gate. ## Review Checklist @@ -529,122 +213,62 @@ Execute every command to validate the feature with zero regressions. ## Notes -- The request is classified as a Feature because it adds a new security and - evidence capability to every Action. -- No implementation blocker remains. This plan makes the required “no record - means allow” behavior concrete with an explicit self-referential restriction - marker. A plain SpiceDB `CheckPermission` negative result cannot by itself - distinguish an absent Action configuration from a configured Action without - a grant, so the two-check schema contract is required. -- Fail-open behavior is intentionally limited to a valid negative marker - decision because issue 71 explicitly requires backward-compatible allow for - unconfigured Actions. All service and decision uncertainty fails closed. -- The local Compose service deliberately uses SpiceDB's in-memory datastore; - relationship data is disposable and the committed bootstrap restores the - schema. Production datastore topology, TLS/secret provisioning, high - availability, backups, and relationship-administration workflows are - separate deployment/security work and do not block this runtime gate. -- Action/role administration and SpiceDB relationship-write consistency are - outside issue 71. This feature reads relationships and uses isolated test - writes only; future access-management Actions must define ordering, - recovery, audit, and ZedToken propagation before production use. -- Fully consistent checks are the conservative choice until a relationship - write path can propagate ZedTokens. A later measured optimization may use - `at_least_as_fresh` without changing the permission service contract. -- Primary technical references used to settle the plan are the official - [SpiceDB schema language](https://authzed.com/docs/spicedb/concepts/schema), - [permission query semantics](https://authzed.com/docs/spicedb/concepts/querying-data), - [schema validation guidance](https://authzed.com/docs/spicedb/modeling/validation-testing-debugging), - [official Node client releases](https://github.com/authzed/authzed-node/releases), - and [SpiceDB v1.56.0 release](https://github.com/authzed/spicedb/releases/tag/v1.56.0). +- The request is classified as a Feature because it adds a new security and evidence capability to every Action. +- No implementation blocker remains. This plan makes the required “no record means allow” behavior concrete with an explicit self-referential restriction marker. A plain SpiceDB `CheckPermission` negative result cannot by itself distinguish an absent Action configuration from a configured Action without a grant, so the two-check schema contract is required. +- Fail-open behavior is intentionally limited to a valid negative marker decision because issue 71 explicitly requires backward-compatible allow for unconfigured Actions. All service and decision uncertainty fails closed. +- The local Compose service deliberately uses SpiceDB's in-memory datastore; relationship data is disposable and the committed bootstrap restores the schema. Production datastore topology, TLS/secret provisioning, high availability, backups, and relationship-administration workflows are separate deployment/security work and do not block this runtime gate. +- Action/role administration and SpiceDB relationship-write consistency are outside issue 71. This feature reads relationships and uses isolated test writes only; future access-management Actions must define ordering, recovery, audit, and ZedToken propagation before production use. +- Fully consistent checks are the conservative choice until a relationship write path can propagate ZedTokens. A later measured optimization may use `at_least_as_fresh` without changing the permission service contract. +- Primary technical references used to settle the plan are the official [SpiceDB schema language](https://authzed.com/docs/spicedb/concepts/schema), [permission query semantics](https://authzed.com/docs/spicedb/concepts/querying-data), [schema validation guidance](https://authzed.com/docs/spicedb/modeling/validation-testing-debugging), [official Node client releases](https://github.com/authzed/authzed-node/releases), and [SpiceDB v1.56.0 release](https://github.com/authzed/spicedb/releases/tag/v1.56.0). ## Implementation Evidence ### Summary -- Added the pinned, healthy local SpiceDB service, development configuration contract, and - Core-owned authorization schema with fixtures for absent, granted, and denied relationships. -- Added a private, scoped Effect adapter around exact `@authzed/authzed-node@1.6.1`, strict typed - configuration, two fully consistent checks, a two-second deadline, resource finalization, - sanitized fail-closed errors, and safe trace correlation attributes. -- Losslessly map dotted OntOS Action keys to SpiceDB-safe `ak_`-prefixed base64url object ids while - retaining the canonical Action key in the runtime, invocation, and audit evidence contracts. -- Replaced the deferred Action permission boundary with the real gate after invocation/idempotency - verification and before `running`, transaction acquisition, collector creation, or handler entry. -- Kept handlers outside the public Action registration object and made Core `runAction` the only - package-supported path that can resolve and invoke them. -- Added atomic terminal-denial persistence and typed denial/check failures with documented future - HTTP `403`/`503` mappings. All focused, live integration, repository, and build validation passes. +- Added the pinned, healthy local SpiceDB service, development configuration contract, and Core-owned authorization schema with fixtures for absent, granted, and denied relationships. +- Added a private, scoped Effect adapter around exact `@authzed/authzed-node@1.6.1`, strict typed configuration, two fully consistent checks, a two-second deadline, resource finalization, sanitized fail-closed errors, and safe trace correlation attributes. +- Losslessly map dotted OntOS Action keys to SpiceDB-safe `ak_`-prefixed base64url object ids while retaining the canonical Action key in the runtime, invocation, and audit evidence contracts. +- Replaced the deferred Action permission boundary with the real gate after invocation/idempotency verification and before `running`, transaction acquisition, collector creation, or handler entry. +- Kept handlers outside the public Action registration object and made Core `runAction` the only package-supported path that can resolve and invoke them. +- Added atomic terminal-denial persistence and typed denial/check failures with documented future HTTP `403`/`503` mappings. All focused, live integration, repository, and build validation passes. ### Changed Files -- 16 tracked files contain 705 additions and 31 deletions. Six new implementation files add 1,206 - lines. This 646-line specification is the seventh new file. -- Compose/config/schema: `.env.example`, `docker-compose.yml`, and - `packages/core-runtime/spicedb/bootstrap.yaml`. +- 16 tracked files contain 705 additions and 31 deletions. Six new implementation files add 1,206 lines. This 646-line specification is the seventh new file. +- Compose/config/schema: `.env.example`, `docker-compose.yml`, and `packages/core-runtime/spicedb/bootstrap.yaml`. - Permission adapter: three files under `packages/core-runtime/src/permissions/`. -- Action contract/runtime/persistence: `definition.ts`, `errors.ts`, `repository.ts`, `runtime.ts`, - `index.ts`, and the Action/error architecture documents. -- Dependency/test wiring: `packages/core-runtime/package.json`, `pnpm-lock.yaml`, - `pnpm-workspace.yaml`, existing Action tests, and two new permission test files. +- Action contract/runtime/persistence: `definition.ts`, `errors.ts`, `repository.ts`, `runtime.ts`, `index.ts`, and the Action/error architecture documents. +- Dependency/test wiring: `packages/core-runtime/package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, existing Action tests, and two new permission test files. ### Tests Written or Updated -- `packages/core-runtime/tests/unit/action-permission.test.ts`: strict root configuration, TLS rules, - collision-free Action-key encoding, exact trusted principal identity, fully consistent decision - table, malformed/conditional/client failures, bounded deadlines, sanitization, and finalization. -- `packages/core-runtime/tests/unit/action-runtime.test.ts`: exact permission stage/order/input, - configured and - unconfigured allow, denial non-execution, fail-closed checks, denial-persistence failure, and - pre-authorization idempotency/hash terminal behavior. -- Updated Action error/public-surface tests and the existing PostgreSQL Action runtime suite's - explicit permission seam. -- Updated Action definition tests to prove an exported registration exposes its immutable descriptor - without exposing a directly callable handler. -- `packages/core-runtime/tests/integration/action-permission.test.ts`: isolated live - SpiceDB/PostgreSQL fixtures, - unconfigured and granted execution, normalized atomic denial, concurrent denial idempotency, - both denial rollback points, and invalid-credential fail-closed behavior. +- `packages/core-runtime/tests/unit/action-permission.test.ts`: strict root configuration, TLS rules, collision-free Action-key encoding, exact trusted principal identity, fully consistent decision table, malformed/conditional/client failures, bounded deadlines, sanitization, and finalization. +- `packages/core-runtime/tests/unit/action-runtime.test.ts`: exact permission stage/order/input, configured and unconfigured allow, denial non-execution, fail-closed checks, denial-persistence failure, and pre-authorization idempotency/hash terminal behavior. +- Updated Action error/public-surface tests and the existing PostgreSQL Action runtime suite's explicit permission seam. +- Updated Action definition tests to prove an exported registration exposes its immutable descriptor without exposing a directly callable handler. +- `packages/core-runtime/tests/integration/action-permission.test.ts`: isolated live SpiceDB/PostgreSQL fixtures, unconfigured and granted execution, normalized atomic denial, concurrent denial idempotency, both denial rollback points, and invalid-credential fail-closed behavior. ### Validation - `docker compose --env-file .env.example config` — passed. -- `docker compose --env-file .env.example up -d --wait ontos-db ontos-spicedb` — passed; both - containers reached healthy state with SpiceDB `v1.56.0` bootstrapped from the committed schema. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos mise exec -- pnpm db:migrate` — passed; - existing Core and Auth migrations applied and no migration was generated. +- `docker compose --env-file .env.example up -d --wait ontos-db ontos-spicedb` — passed; both containers reached healthy state with SpiceDB `v1.56.0` bootstrapped from the committed schema. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos mise exec -- pnpm db:migrate` — passed; existing Core and Auth migrations applied and no migration was generated. - `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — passed, 48/48 tests. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos SPICEDB_ENDPOINT=localhost:50051 SPICEDB_PRESHARED_KEY=ontos-local-development-key SPICEDB_INSECURE=true mise exec -- pnpm --filter @app/core-runtime action:test:integration` - — passed, 16/16 live PostgreSQL/SpiceDB Action integration tests. -- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters BETTER_AUTH_URL=http://localhost:3020 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3020,http://127.0.0.1:3020 mise exec -- pnpm db:verify` - — passed; verified 18 Core tables and four Auth tables. -- `mise exec -- pnpm check` — passed, including format, lint, focused tests, typecheck, skills, - i18n, API, workspace-contract, and performance gates. -- `mise exec -- pnpm build` — passed, including server/client production build, TS-Go compile, - Module Federation type assertion, deployment packaging, and performance readiness. -- `git diff --check` — passed; final status contains only feature files and this plan. No `.env`, - datastore, generated build output, business scaffold, or production credential is present. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos SPICEDB_ENDPOINT=localhost:50051 SPICEDB_PRESHARED_KEY=ontos-local-development-key SPICEDB_INSECURE=true mise exec -- pnpm --filter @app/core-runtime action:test:integration` — passed, 16/16 live PostgreSQL/SpiceDB Action integration tests. +- `DATABASE_URL=postgresql://ontos:ontos@localhost:5433/ontos BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters BETTER_AUTH_URL=http://localhost:3020 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3020,http://127.0.0.1:3020 mise exec -- pnpm db:verify` — passed; verified 18 Core tables and four Auth tables. +- `mise exec -- pnpm check` — passed, including format, lint, focused tests, typecheck, skills, i18n, API, workspace-contract, and performance gates. +- `mise exec -- pnpm build` — passed, including server/client production build, TS-Go compile, Module Federation type assertion, deployment packaging, and performance readiness. +- `git diff --check` — passed; final status contains only feature files and this plan. No `.env`, datastore, generated build output, business scaffold, or production credential is present. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, the implementation plan/acceptance criteria, and the - relevant Action, Effect error, database, MicroVertical, UltraModern, Core, consistency, - authentication/authorization, and CoreSDK guidance. -- Confirmed the change remains Core-owned infrastructure, adds no cross-MicroVertical import or - BFF/public-client expansion, uses typed Drizzle references and Effect errors, changes no schema - or migration, and does not require a Codesmith-supported business scaffold. -- Review fixes removed retention/logging of raw gRPC causes, tightened denial lookup to the exact - Action/tenant/principal tuple, rejected endpoint query/fragment suffixes, removed the unsupported - SpiceDB `v1.56.0` plaintext flag, introduced the required lossless object-id encoding, removed the - public registration handler bypass, and made integration cleanup exact and resilient. +- Re-read `../AGENTS.md`, `AGENTS.md`, the implementation plan/acceptance criteria, and the relevant Action, Effect error, database, MicroVertical, UltraModern, Core, consistency, authentication/authorization, and CoreSDK guidance. +- Confirmed the change remains Core-owned infrastructure, adds no cross-MicroVertical import or BFF/public-client expansion, uses typed Drizzle references and Effect errors, changes no schema or migration, and does not require a Codesmith-supported business scaffold. +- Review fixes removed retention/logging of raw gRPC causes, tightened denial lookup to the exact Action/tenant/principal tuple, rejected endpoint query/fragment suffixes, removed the unsupported SpiceDB `v1.56.0` plaintext flag, introduced the required lossless object-id encoding, removed the public registration handler bypass, and made integration cleanup exact and resilient. - No frontend files changed, so browser validation and screenshots are not applicable. ### Deviations and Follow-ups -- The originally drafted `@authzed/authzed-node@1.7.0` does not exist. Per developer direction, the - implementation uses the latest published version, exact `1.6.1`. -- SpiceDB object ids reject the repository's dotted Action-key format. The adapter therefore applies - the documented reversible `ak_` plus unpadded base64url mapping; no canonical OntOS identity or - evidence value changed. -- Production SpiceDB persistence, TLS/secret provisioning, relationship administration, and - ZedToken propagation remain intentionally outside issue 71. No implementation blocker remains. +- The originally drafted `@authzed/authzed-node@1.7.0` does not exist. Per developer direction, the implementation uses the latest published version, exact `1.6.1`. +- SpiceDB object ids reject the repository's dotted Action-key format. The adapter therefore applies the documented reversible `ak_` plus unpadded base64url mapping; no canonical OntOS identity or evidence value changed. +- Production SpiceDB persistence, TLS/secret provisioning, relationship administration, and ZedToken propagation remain intentionally outside issue 71. No implementation blocker remains. diff --git a/app/specs/feature-action-policies.md b/app/specs/feature-action-policies.md index d01738c0f..926370a5d 100644 --- a/app/specs/feature-action-policies.md +++ b/app/specs/feature-action-policies.md @@ -14,9 +14,7 @@ Every declared Policy is evaluated against the decoded payload and trusted execu ## User Story -As an OntOS module developer -I want every Action to reference and enforce its applicable global and module-owned business Policies -So that disallowed state changes are stopped consistently, reported safely, and retained as durable evidence without crossing MicroVertical boundaries +As an OntOS module developer I want every Action to reference and enforce its applicable global and module-owned business Policies So that disallowed state changes are stopped consistently, reported safely, and retained as durable evidence without crossing MicroVertical boundaries ## Problem Statement diff --git a/app/specs/feature-add-generators.md b/app/specs/feature-add-generators.md index 162475f5f..eece2a7d9 100644 --- a/app/specs/feature-add-generators.md +++ b/app/specs/feature-add-generators.md @@ -14,9 +14,7 @@ The implementation is tooling-focused. Apart from registering the commands, pinn ## User Story -As an OntOS developer or coding agent -I want mandatory scaffolding commands for Actions, MicroVertical pages, Outbox Messages, and Policies -So that new business code starts with the required Effect contracts, ownership boundaries, safe defaults, and repository wiring already in place +As an OntOS developer or coding agent I want mandatory scaffolding commands for Actions, MicroVertical pages, Outbox Messages, and Policies So that new business code starts with the required Effect contracts, ownership boundaries, safe defaults, and repository wiring already in place ## Problem Statement diff --git a/app/specs/feature-authenticated-dashboard-layout.md b/app/specs/feature-authenticated-dashboard-layout.md index 0b3dc0272..33a81d273 100644 --- a/app/specs/feature-authenticated-dashboard-layout.md +++ b/app/specs/feature-authenticated-dashboard-layout.md @@ -6,71 +6,33 @@ created: 2026-08-06 # Feature: Authenticated dashboard layout -> [!IMPORTANT] -> **Historical scope:** [OntOS #78](https://github.com/TechsioCZ/ontos/issues/78) and [the Tenant switcher specification](./feature-tenant-switcher.md) supersede this feature's disabled-placeholder/no-switching constraint. The feature remains a record of the original dashboard delivery, not the current account-tenancy model. +> [!IMPORTANT] **Historical scope:** [OntOS #78](https://github.com/TechsioCZ/ontos/issues/78) and [the Tenant switcher specification](./feature-tenant-switcher.md) supersede this feature's disabled-placeholder/no-switching constraint. The feature remains a record of the original dashboard delivery, not the current account-tenancy model. ## Feature Description -Add the default signed-user dashboard layout requested by GitHub issue #77. The layout is an -opt-in Shell presentation wrapper for authenticated pages, not global route chrome: each signed-in -page can supply its own localized page title and active navigation key while the authenticated home -page uses the default Home configuration. +Add the default signed-user dashboard layout requested by GitHub issue #77. The layout is an opt-in Shell presentation wrapper for authenticated pages, not global route chrome: each signed-in page can supply its own localized page title and active navigation key while the authenticated home page uses the default Home configuration. -The layout follows the arrangement in Figma project `ERP`, page `Pre-Alpha Repo`, frame -`Home — Aktivní modul` (`6:399`): a left sidebar contains the OntOS brand, an intentionally empty -and disabled tenant Select placeholder, a Home link, and links for the signed-in tenant's installed -active MicroVerticals. The main area starts with a `@techsio/ui-kit` Header whose rightmost element -is a `Menu` triggered by the signed user's display name. That Menu contains exactly one command: -logout. Search is omitted. The current authenticated home identity and active-module content remain -the page body, with logout moved from its standalone button into the Header Menu. +The layout follows the arrangement in Figma project `ERP`, page `Pre-Alpha Repo`, frame `Home — Aktivní modul` (`6:399`): a left sidebar contains the OntOS brand, an intentionally empty and disabled tenant Select placeholder, a Home link, and links for the signed-in tenant's installed active MicroVerticals. The main area starts with a `@techsio/ui-kit` Header whose rightmost element is a `Menu` triggered by the signed user's display name. That Menu contains exactly one command: logout. Search is omitted. The current authenticated home identity and active-module content remain the page body, with logout moved from its standalone button into the Header Menu. -Treat Figma only as a wireframe for component arrangement. Use the installed -`@techsio/ui-kit@0.25.1` components, component tokens, and Tailwind layout utilities instead of -copying Figma colors, measurements, or visual styling. +Treat Figma only as a wireframe for component arrangement. Use the installed `@techsio/ui-kit@0.25.1` components, component tokens, and Tailwind layout utilities instead of copying Figma colors, measurements, or visual styling. ## User Story -As a signed-in OntOS user -I want a consistent dashboard layout with module navigation and an account menu -So that I can recognize my current workspace, navigate to an active MicroVertical, and log out -from every authenticated page +As a signed-in OntOS user I want a consistent dashboard layout with module navigation and an account menu So that I can recognize my current workspace, navigate to an active MicroVertical, and log out from every authenticated page ## Problem Statement -The Shell home route currently renders authenticated identity, active MicroVertical state, and a -standalone logout button inside a centered full-screen section. The existing `shell-frame.tsx` is -unused legacy shell chrome, depends on a custom generated header/status presentation, and cannot -receive the current identity, module list, page title, or logout state. The global `layout.tsx` -deliberately renders only the route outlet, so applying dashboard chrome globally would also wrap -anonymous and login pages incorrectly. +The Shell home route currently renders authenticated identity, active MicroVertical state, and a standalone logout button inside a centered full-screen section. The existing `shell-frame.tsx` is unused legacy shell chrome, depends on a custom generated header/status presentation, and cannot receive the current identity, module list, page title, or logout state. The global `layout.tsx` deliberately renders only the route outlet, so applying dashboard chrome globally would also wrap anonymous and login pages incorrectly. -The current authenticated loader already provides the safe display name and an ordered list of -installed MicroVerticals whose persisted tenant state is exactly `active`. The missing capability -is therefore presentation and page-level composition, not another BFF endpoint, Core query, -Action, or client-side fetch. +The current authenticated loader already provides the safe display name and an ordered list of installed MicroVerticals whose persisted tenant state is exactly `active`. The missing capability is therefore presentation and page-level composition, not another BFF endpoint, Core query, Action, or client-side fetch. ## Solution Statement -Refactor the existing `shell-frame.tsx` module into an `AuthenticatedDashboardLayout` component. -Give it a small page configuration (`title` and optional current MicroVertical key), the safe -authenticated identity, the active-module items, logout pending state, and a semantic logout -callback. The component will own the responsive sidebar and Header/Menu chrome while rendering -page-specific children in the main content area. An absent current MicroVertical key means Home is -the active navigation entry, which makes the authenticated home configuration the default while -allowing a later page to opt into the same layout with its own title and active module. - -Use `@techsio/ui-kit/organisms/header` for the main-area header, -`@techsio/ui-kit/molecules/menu` with one data-driven action item and `triggerText` equal to -`identity.displayName`, `@techsio/ui-kit/molecules/select` for the disabled empty placeholder, and -`@techsio/ui-kit/atoms/link` with the Modern i18n Link adapter for client-side localized -navigation. Use a semantic native `aside` because the installed UI kit has no Sidebar component. -Use only Tailwind layout utilities around UI-kit components; do not duplicate their appearance -with component `className` overrides or add plain CSS. - -Render this layout only from the authenticated branch of `HomeView`. Keep the anonymous branch, -session and active-module loader, strict Effect client, and BFF contracts unchanged. Keep the -existing identity details, active-module list, unavailable feedback, and logout success/failure -state behavior in the page body. Replace only the standalone logout Button with the Menu command. +Refactor the existing `shell-frame.tsx` module into an `AuthenticatedDashboardLayout` component. Give it a small page configuration (`title` and optional current MicroVertical key), the safe authenticated identity, the active-module items, logout pending state, and a semantic logout callback. The component will own the responsive sidebar and Header/Menu chrome while rendering page-specific children in the main content area. An absent current MicroVertical key means Home is the active navigation entry, which makes the authenticated home configuration the default while allowing a later page to opt into the same layout with its own title and active module. + +Use `@techsio/ui-kit/organisms/header` for the main-area header, `@techsio/ui-kit/molecules/menu` with one data-driven action item and `triggerText` equal to `identity.displayName`, `@techsio/ui-kit/molecules/select` for the disabled empty placeholder, and `@techsio/ui-kit/atoms/link` with the Modern i18n Link adapter for client-side localized navigation. Use a semantic native `aside` because the installed UI kit has no Sidebar component. Use only Tailwind layout utilities around UI-kit components; do not duplicate their appearance with component `className` overrides or add plain CSS. + +Render this layout only from the authenticated branch of `HomeView`. Keep the anonymous branch, session and active-module loader, strict Effect client, and BFF contracts unchanged. Keep the existing identity details, active-module list, unavailable feedback, and logout success/failure state behavior in the page body. Replace only the standalone logout Button with the Menu command. ## Relevant Files @@ -99,25 +61,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Turn the existing unused shell frame into a typed, page-configurable authenticated layout while -preserving the global outlet-only layout. Define navigation directly from the existing safe -identity and active-module view model; do not add a store, context, hook, BFF operation, or module -registry. Add component tests at the same time to lock the layout's page configuration and -landmark contract. +Turn the existing unused shell frame into a typed, page-configurable authenticated layout while preserving the global outlet-only layout. Define navigation directly from the existing safe identity and active-module view model; do not add a store, context, hook, BFF operation, or module registry. Add component tests at the same time to lock the layout's page configuration and landmark contract. ### Phase 2: Core Implementation -Compose the responsive sidebar, empty disabled Select, localized Home/module links, UI-kit Header, -and one-item user Menu. Move logout invocation into the Menu callback without weakening the -existing duplicate-submit guard, success transition, or retryable error state. Keep authenticated -page content as children and keep anonymous rendering outside the layout. +Compose the responsive sidebar, empty disabled Select, localized Home/module links, UI-kit Header, and one-item user Menu. Move logout invocation into the Menu callback without weakening the existing duplicate-submit guard, success transition, or retryable error state. Keep authenticated page content as children and keep anonymous rendering outside the layout. ### Phase 3: Integration -Add aligned English/Czech copy and prove the full session flow at desktop and mobile widths. Cover -empty and unavailable active-module data, deterministic links, keyboard Menu use, logout pending, -failure/retry, and the complete removal of authenticated chrome after successful logout. Run the -focused Shell gates, production build, and final repository check from `app/`. +Add aligned English/Czech copy and prove the full session flow at desktop and mobile widths. Cover empty and unavailable active-module data, deterministic links, keyboard Menu use, logout pending, failure/retry, and the complete removal of authenticated chrome after successful logout. Run the focused Shell gates, production build, and final repository check from `app/`. ## Step by Step Tasks @@ -167,20 +119,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use the existing Shell component test files to cover the layout's typed page configuration, -semantic landmarks, UI-kit composition, localized Link adapter, disabled empty Select, exact-active -module links, Home/module current state, page-title substitution, one-item account Menu, pending -disablement, semantic logout callback, authenticated composition, anonymous isolation, existing -content, logout success/failure, redaction, and English/Czech locale parity. +Use the existing Shell component test files to cover the layout's typed page configuration, semantic landmarks, UI-kit composition, localized Link adapter, disabled empty Select, exact-active module links, Home/module current state, page-title substitution, one-item account Menu, pending disablement, semantic logout callback, authenticated composition, anonymous isolation, existing content, logout success/failure, redaction, and English/Czech locale parity. ### Integration Tests -Use the existing Playwright authentication fixture and real Shell BFF to prove that a valid session -renders the layout, survives reload, invokes the existing sign-out endpoint through the Menu, -clears the cookie and dashboard after success, and retains an accessible retry path after a failed -request. Exercise desktop and narrow viewports plus keyboard Menu navigation. No new backend or -database integration test is required because the identity, active-module read, and sign-out -contracts are reused unchanged and already have focused runtime coverage. +Use the existing Playwright authentication fixture and real Shell BFF to prove that a valid session renders the layout, survives reload, invokes the existing sign-out endpoint through the Menu, clears the cookie and dashboard after success, and retains an accessible retry path after a failed request. Exercise desktop and narrow viewports plus keyboard Menu navigation. No new backend or database integration test is required because the identity, active-module read, and sign-out contracts are reused unchanged and already have focused runtime coverage. ### Edge Cases diff --git a/app/specs/feature-betterauth-tenant-login.md b/app/specs/feature-betterauth-tenant-login.md index 43811d798..1abf6f998 100644 --- a/app/specs/feature-betterauth-tenant-login.md +++ b/app/specs/feature-betterauth-tenant-login.md @@ -6,91 +6,55 @@ created: 2026-07-30 # Feature: Better Auth Shell/Core tenant login -> [!IMPORTANT] -> **Historical scope:** [OntOS #78](https://github.com/TechsioCZ/ontos/issues/78) and [the Tenant switcher specification](./feature-tenant-switcher.md) supersede this feature's one-active-binding/no-selector constraint. The feature remains a record of the original login delivery, not the current account-tenancy model. +> [!IMPORTANT] **Historical scope:** [OntOS #78](https://github.com/TechsioCZ/ontos/issues/78) and [the Tenant switcher specification](./feature-tenant-switcher.md) supersede this feature's one-active-binding/no-selector constraint. The feature remains a record of the original login delivery, not the current account-tenancy model. ## Feature Description -Add email-and-password authentication to OntOS with Better Auth as a Shell/Core -capability. Authentication must not be implemented as a MicroVertical, deployment -vertical, or package under `verticals/`. +Add email-and-password authentication to OntOS with Better Auth as a Shell/Core capability. Authentication must not be implemented as a MicroVertical, deployment vertical, or package under `verticals/`. -The Shell owns the login and home routes, Better Auth credential/session runtime, -session cookies, the strict Effect authentication BFF, and the private Drizzle schema -and migration history for Better Auth tables. Core continues to own tenants, -principals, `principal_auth_bindings`, and the resolver that maps an authenticated -Better Auth subject to exactly one active OntOS principal in one active tenant. +The Shell owns the login and home routes, Better Auth credential/session runtime, session cookies, the strict Effect authentication BFF, and the private Drizzle schema and migration history for Better Auth tables. Core continues to own tenants, principals, `principal_auth_bindings`, and the resolver that maps an authenticated Better Auth subject to exactly one active OntOS principal in one active tenant. The localized home route has exactly two visible states: - an anonymous visitor sees only a link to the login page; -- an authenticated user sees only safe information about the logged-in identity and a - logout button. +- an authenticated user sees only safe information about the logged-in identity and a logout button. -Clicking logout must invalidate the Better Auth session, clear the browser session -cookie, and return the home route to its anonymous state. +Clicking logout must invalidate the Better Auth session, clear the browser session cookie, and return the home route to its anonymous state. ## User Story -As an OntOS user -I want to sign in, see which identity is active, and log out -So that I can securely enter and leave the tenant context represented by my principal +As an OntOS user I want to sign in, see which identity is active, and log out So that I can securely enter and leave the tenant context represented by my principal ## Problem Statement -The Shell currently has a UI-only login form and a promotional home page. It has no -authentication runtime, persisted session, strict Effect authentication BFF, Core -principal resolution, authenticated home state, or logout behavior. +The Shell currently has a UI-only login form and a promotional home page. It has no authentication runtime, persisted session, strict Effect authentication BFF, Core principal resolution, authenticated home state, or logout behavior. -Authentication is a cross-cutting Shell/Core responsibility, not a business domain. -Implementing it as an Auth MicroVertical would create a false vertical boundary, -incorrectly expose authentication as a separately owned business capability, and -contradict the accepted product statement that Shell and Core determine whether a -Better Auth session represents a logged-in OntOS user. +Authentication is a cross-cutting Shell/Core responsibility, not a business domain. Implementing it as an Auth MicroVertical would create a false vertical boundary, incorrectly expose authentication as a separately owned business capability, and contradict the accepted product statement that Shell and Core determine whether a Better Auth session represents a logged-in OntOS user. ## Current Reverted Baseline -Re-opened against the reverted workspace on 2026-07-30. The current implementation -baseline is: - -- the Shell login page performs only local required-field validation and makes no - authentication request; -- the Shell home page still renders its hero, showcase, calls to action, and build - markers; -- the Shell has no `api/index.ts`, `shared/api.ts`, generated authentication client, - Better Auth dependency, Auth Drizzle model, Auth migration, or authentication test - suite; -- Core already owns tenants, principals, and `principal_auth_bindings`, but it has no - Better Auth subject resolver; +Re-opened against the reverted workspace on 2026-07-30. The current implementation baseline is: + +- the Shell login page performs only local required-field validation and makes no authentication request; +- the Shell home page still renders its hero, showcase, calls to action, and build markers; +- the Shell has no `api/index.ts`, `shared/api.ts`, generated authentication client, Better Auth dependency, Auth Drizzle model, Auth migration, or authentication test suite; +- Core already owns tenants, principals, and `principal_auth_bindings`, but it has no Better Auth subject resolver; - root database commands currently delegate only to `@app/core-runtime`; -- no Auth MicroVertical source is tracked, but ignored residual directories, build - output, dependency links, and caches remain under `verticals/auth/` from the reverted - implementation attempt. +- no Auth MicroVertical source is tracked, but ignored residual directories, build output, dependency links, and caches remain under `verticals/auth/` from the reverted implementation attempt. -All implementation tasks, acceptance criteria, and review checks below are therefore -intentionally open. No prior implementation or validation evidence may be reused. +All implementation tasks, acceptance criteria, and review checks below are therefore intentionally open. No prior implementation or validation evidence may be reused. ## Solution Statement Add authentication directly to the Shell/Core boundary: -- Shell owns Better Auth credential and session mechanics, the `auth` PostgreSQL - schema, the strict Effect authentication BFF, cookie propagation, login/logout UI, - and current-session presentation. -- Core owns only the non-secret Better Auth subject binding, active - principal-and-tenant resolution, and the safe identity DTO needed by the Shell. -- No Auth MicroVertical, Auth remote, Auth delivery unit, `@app/auth` package, or - `verticals/auth/**` files may be introduced. +- Shell owns Better Auth credential and session mechanics, the `auth` PostgreSQL schema, the strict Effect authentication BFF, cookie propagation, login/logout UI, and current-session presentation. +- Core owns only the non-secret Better Auth subject binding, active principal-and-tenant resolution, and the safe identity DTO needed by the Shell. +- No Auth MicroVertical, Auth remote, Auth delivery unit, `@app/auth` package, or `verticals/auth/**` files may be introduced. -The Shell BFF exposes only declared `signIn`, `currentSession`, and `signOut` -operations through Effect Schema and a generated Shell client. `signIn` validates -credentials and creates a session only when Core resolves exactly one active binding. -`currentSession` revalidates the Core identity on every read. `signOut` delegates to -Better Auth and forwards every cookie-clearing `Set-Cookie` header. +The Shell BFF exposes only declared `signIn`, `currentSession`, and `signOut` operations through Effect Schema and a generated Shell client. `signIn` validates credentials and creates a session only when Core resolves exactly one active binding. `currentSession` revalidates the Core identity on every read. `signOut` delegates to Better Auth and forwards every cookie-clearing `Set-Cookie` header. -The home route consumes the Shell authentication client and renders exactly one of the -two required visible states. It must not retain the current hero, showcase, -promotional calls to action, build markers, or other visible content. +The home route consumes the Shell authentication client and renders exactly one of the two required visible states. It must not retain the current hero, showcase, promotional calls to action, build markers, or other visible content. ## Relevant Files @@ -150,25 +114,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Resolve the Shell BFF scaffold blocker before creating any initial API files. Configure -the Shell as the authentication BFF and Better Auth database owner without adding a -vertical, remote, package, or delivery unit. Pin compatible Better Auth/Drizzle -dependencies, generate the Better Auth model, and establish independent Core and -Shell-owned schema inventories and migration histories. +Resolve the Shell BFF scaffold blocker before creating any initial API files. Configure the Shell as the authentication BFF and Better Auth database owner without adding a vertical, remote, package, or delivery unit. Pin compatible Better Auth/Drizzle dependencies, generate the Better Auth model, and establish independent Core and Shell-owned schema inventories and migration histories. ### Phase 2: Core Implementation -Implement and test the narrow Core subject resolver. Configure Better Auth inside the -Shell, enforce Core resolution before session creation and on every session read, and -implement declared `signIn`, `currentSession`, and `signOut` Shell BFF operations with -typed errors, redacted logging, and correct cookie propagation. +Implement and test the narrow Core subject resolver. Configure Better Auth inside the Shell, enforce Core resolution before session creation and on every session read, and implement declared `signIn`, `currentSession`, and `signOut` Shell BFF operations with typed errors, redacted logging, and correct cookie propagation. ### Phase 3: Integration -Connect the localized Shell login and home routes through the generated Shell -authentication client. Reduce the home route to its exact anonymous/authenticated -states, implement logout and retry behavior, and prove the complete localized flow -with unit, database integration, contract, and browser tests. +Connect the localized Shell login and home routes through the generated Shell authentication client. Reduce the home route to its exact anonymous/authenticated states, implement logout and retry behavior, and prove the complete localized flow with unit, database integration, contract, and browser tests. ## Step by Step Tasks @@ -240,18 +194,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test Core resolver decisions; Better Auth table placement; Shell Effect request, -response, and error schemas; generic credential errors; sign-out cookie invalidation; -redaction; exact home-page contents; logout pending/retry behavior; and the absence of -Auth vertical ownership. +Test Core resolver decisions; Better Auth table placement; Shell Effect request, response, and error schemas; generic credential errors; sign-out cookie invalidation; redaction; exact home-page contents; logout pending/retry behavior; and the absence of Auth vertical ownership. ### Integration Tests -Run PostgreSQL-backed tests for independent Core and Shell migration histories, exact -catalog verification, Better Auth credential/session behavior, Core resolution, -revocation, and cookie propagation/clearing through the Shell strict Effect BFF. Run -Playwright against the assembled Shell for the complete English and Czech login, -current-session, and logout flow. +Run PostgreSQL-backed tests for independent Core and Shell migration histories, exact catalog verification, Better Auth credential/session behavior, Core resolution, revocation, and cookie propagation/clearing through the Shell strict Effect BFF. Run Playwright against the assembled Shell for the complete English and Czech login, current-session, and logout flow. ### Edge Cases @@ -314,48 +261,33 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Implemented Better Auth email/password login as a Shell/Core capability with a - strict Effect BFF, safe principal/tenant resolution, localized exact home states, - session persistence, and sign-out behavior. -- Consolidated migration bookkeeping into the single PostgreSQL schema `drizzle`, - with independent `__drizzle_migrations_core` and `__drizzle_migrations_auth` - journal tables. -- Provisioned `ji.prochazka@gmail.com` with an active Better Auth account, Core - principal, tenant, and binding in the Docker PostgreSQL database. +- Implemented Better Auth email/password login as a Shell/Core capability with a strict Effect BFF, safe principal/tenant resolution, localized exact home states, session persistence, and sign-out behavior. +- Consolidated migration bookkeeping into the single PostgreSQL schema `drizzle`, with independent `__drizzle_migrations_core` and `__drizzle_migrations_auth` journal tables. +- Provisioned `ji.prochazka@gmail.com` with an active Better Auth account, Core principal, tenant, and binding in the Docker PostgreSQL database. ### Changed Files -- 58 files changed, 4,320 insertions, 236 deletions before integration with the - latest `develop`. +- 58 files changed, 4,320 insertions, 236 deletions before integration with the latest `develop`. ### Tests Written or Updated -- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — anonymous and - authenticated UI, successful sign-out, and failed sign-out retry behavior. -- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — validation, - submission, typed authentication failures, and duplicate submission prevention. -- `apps/shell-super-app/tests/unit/auth-*.test.ts` — BFF contract, configuration, - schema, and ownership boundaries. -- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — credential, - principal resolution, session persistence, revocation, and sign-out behavior. -- `apps/shell-super-app/tests/e2e/login.spec.ts` — localized browser login, persisted - session, logout, retry, keyboard, and narrow-viewport behavior. -- `packages/core-runtime/tests/unit/principal-resolver.test.ts` and - `packages/core-runtime/tests/integration/principal-resolver.test.ts` — safe identity - resolution and fail-closed binding, principal, and tenant states. +- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — anonymous and authenticated UI, successful sign-out, and failed sign-out retry behavior. +- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — validation, submission, typed authentication failures, and duplicate submission prevention. +- `apps/shell-super-app/tests/unit/auth-*.test.ts` — BFF contract, configuration, schema, and ownership boundaries. +- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — credential, principal resolution, session persistence, revocation, and sign-out behavior. +- `apps/shell-super-app/tests/e2e/login.spec.ts` — localized browser login, persisted session, logout, retry, keyboard, and narrow-viewport behavior. +- `packages/core-runtime/tests/unit/principal-resolver.test.ts` and `packages/core-runtime/tests/integration/principal-resolver.test.ts` — safe identity resolution and fail-closed binding, principal, and tenant states. ### Validation - `docker compose config` — passed. - `mise exec -- pnpm install --frozen-lockfile` — passed. - `mise exec -- pnpm db:migrate` — passed. -- `DATABASE_URL= mise exec -- pnpm db:migrate` twice — passed against a - newly created empty database; the temporary database was dropped afterward. +- `DATABASE_URL= mise exec -- pnpm db:migrate` twice — passed against a newly created empty database; the temporary database was dropped afterward. - `mise exec -- pnpm db:verify` — passed; verified 18 Core and 4 Auth tables. - `mise exec -- pnpm db:test` — passed; 13 Core tests and 1 Auth integration test. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed; 25 tests. -- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed; 5 Chromium - scenarios. +- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed; 5 Chromium scenarios. - `mise exec -- pnpm api:check` — passed. - `mise exec -- pnpm i18n:boundaries` — passed. - `mise exec -- pnpm contract:check` — passed. @@ -366,21 +298,12 @@ Execute every command to validate the feature with zero regressions. ### Review -- Reviewed `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, - `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, - `docs/architecture/DATABASE.md`, `docs/architecture/ULTRAMODERN.md`, - `docs/frontend/FRONTEND.md`, `../docs/09_AUTHN_AUTHZ_MODEL.md`, and - `../docs/adr/0014-authenticated-principal-session.md`. -- Confirmed there is no Auth MicroVertical, package, remote, or deployment unit; - the browser uses only the generated Shell authentication client; migration SQL - creates only `auth` objects; and the live application schemas are exactly `auth`, - `core`, `drizzle`, and `public`. +- Reviewed `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, `docs/architecture/DATABASE.md`, `docs/architecture/ULTRAMODERN.md`, `docs/frontend/FRONTEND.md`, `../docs/09_AUTHN_AUTHZ_MODEL.md`, and `../docs/adr/0014-authenticated-principal-session.md`. +- Confirmed there is no Auth MicroVertical, package, remote, or deployment unit; the browser uses only the generated Shell authentication client; migration SQL creates only `auth` objects; and the live application schemas are exactly `auth`, `core`, `drizzle`, and `public`. ### Deviations and Follow-ups -- Approved generator deviation: after the implementation skill stopped on the - missing Shell BFF generator, the user explicitly approved creating the new files - without a generator and stated that no generators were needed for this task. +- Approved generator deviation: after the implementation skill stopped on the missing Shell BFF generator, the user explicitly approved creating the new files without a generator and stated that no generators were needed for this task. - No follow-up work is required for this specification. ## Notes diff --git a/app/specs/feature-complete-identity-modes.md b/app/specs/feature-complete-identity-modes.md index b064eb69a..64ce63087 100644 --- a/app/specs/feature-complete-identity-modes.md +++ b/app/specs/feature-complete-identity-modes.md @@ -8,252 +8,132 @@ created: 2026-08-09 ## Feature Description -Complete the Shell/Core identity architecture so every identity mode documented for OntOS has a -trusted, usable runtime path. Preserve the existing Better Auth interactive session flow and add: +Complete the Shell/Core identity architecture so every identity mode documented for OntOS has a trusted, usable runtime path. Preserve the existing Better Auth interactive session flow and add: - human API keys; - administrator-issued API keys bound to tenant-local `service` and `integration` principals; - tenant-local `system` or `service` principal contexts for trusted background operations; -- tenant-local support impersonation, including writes, with the target as effective actor and the - original support administrator retained in evidence; and +- tenant-local support impersonation, including writes, with the target as effective actor and the original support administrator retained in evidence; and - complete backend lifecycle APIs for non-human principals, API keys, bindings, and impersonation. -External API-key callers authenticate only at the central Shell/Core gateway. Shell verifies the -raw key through Better Auth, Core resolves the stable Better Auth key ID through exactly one active -`api_key` binding, and Shell returns the existing five-minute assertion for one explicit -MicroVertical audience. The raw API key never crosses the Shell boundary. +External API-key callers authenticate only at the central Shell/Core gateway. Shell verifies the raw key through Better Auth, Core resolves the stable Better Auth key ID through exactly one active `api_key` binding, and Shell returns the existing five-minute assertion for one explicit MicroVertical audience. The raw API key never crosses the Shell boundary. -No administration UI, Better Auth organization model, autonomous agent behavior, global -cross-tenant support identity, generic Action endpoint, or Auth MicroVertical is included. +No administration UI, Better Auth organization model, autonomous agent behavior, global cross-tenant support identity, generic Action endpoint, or Auth MicroVertical is included. ## User Story -As an OntOS administrator or integration operator -I want human, service, integration, system, and impersonated actors to enter the same governed -operation runtime through their appropriate trusted authentication path -So that API automation, background work, and support activity are authorized and audited as the -correct effective actor without exposing credentials or weakening tenant isolation +As an OntOS administrator or integration operator I want human, service, integration, system, and impersonated actors to enter the same governed operation runtime through their appropriate trusted authentication path So that API automation, background work, and support activity are authorized and audited as the correct effective actor without exposing credentials or weakening tenant isolation ## Problem Statement -The `main` branch currently implements only Better Auth user sessions end to end. Core already -stores principal kinds `human`, `service`, `integration`, `agent`, and `system`; binding subject -types `user` and `api_key`; Action authentication methods `session`, `api_key`, `system`, and -`support_impersonation`; and optional binding, context-reference, and impersonator evidence fields. -Those shapes make the architecture explicit but do not authenticate or construct the non-session -modes. - -The Shell Auth schema has no Better Auth API-key or Admin plugin fields. The principal resolver is -hard-coded to `subject_type = user`. Shell always creates `authMethod = session`. A manually -constructed `system` context bypasses the binding requirement, but no Core-owned factory proves the -tenant, principal kind, or job identity. Impersonation fields flow through Action/read persistence -when manually supplied, but no Better Auth impersonation session is resolved into an effective -target and original administrator. - -Consequently, API-key rows and non-human principals can be seeded but cannot securely call OntOS; -system identity can be forged by trusted code that constructs a loose object; support impersonation -cannot start, stop, revalidate, or audit; and principal/key lifecycle would require direct database -mutation. +The `main` branch currently implements only Better Auth user sessions end to end. Core already stores principal kinds `human`, `service`, `integration`, `agent`, and `system`; binding subject types `user` and `api_key`; Action authentication methods `session`, `api_key`, `system`, and `support_impersonation`; and optional binding, context-reference, and impersonator evidence fields. Those shapes make the architecture explicit but do not authenticate or construct the non-session modes. + +The Shell Auth schema has no Better Auth API-key or Admin plugin fields. The principal resolver is hard-coded to `subject_type = user`. Shell always creates `authMethod = session`. A manually constructed `system` context bypasses the binding requirement, but no Core-owned factory proves the tenant, principal kind, or job identity. Impersonation fields flow through Action/read persistence when manually supplied, but no Better Auth impersonation session is resolved into an effective target and original administrator. + +Consequently, API-key rows and non-human principals can be seeded but cannot securely call OntOS; system identity can be forged by trusted code that constructs a loose object; support impersonation cannot start, stop, revalidate, or audit; and principal/key lifecycle would require direct database mutation. ## Solution Statement Keep authentication in the existing Shell/Core capability and implement five cooperating layers: -1. Extend the Shell-owned Better Auth instance with the pinned API Key and Admin plugins and their - generated Drizzle schema. Better Auth remains the only owner of raw key generation, hashing, - verification, expiration, counters/rate limits, enabled state, and impersonation sessions. -2. Generalize Core principal resolution around an explicit stable provider subject - `{ provider, subjectType, providerSubjectId }`. Enforce that one Better Auth API-key ID has at - most one Core binding globally, while Better Auth users retain tenant-specific multi-binding - support. -3. Add generated `core.identity.*` Actions for every Core principal/binding mutation and the - sensitive impersonation evidence lifecycle. Shell orchestrates Better Auth mechanics with these - Actions in fail-closed, compensating order and never places raw key material in an Action payload. -4. Add governed Core identity reads and tenant-level SpiceDB permissions for self-service access, - identity administration, and support impersonation. Shell joins authorized Core binding results - with Auth-owned non-secret key metadata without crossing schema ownership. -5. Construct each trusted principal context through one mode-specific path. Session, API-key, - impersonation, and system contexts satisfy cross-field invariants before Action/Read runtimes; - every operation revalidates the effective tenant/principal/binding and, for impersonation, the - tenant-local original administrator and continuing support permission. - -API-key issuance creates the Better Auth credential first, then commits its Core binding, and only -then returns the raw key once. Binding failure disables the credential and returns no key. -Disable/revoke operations close the Core binding first; a temporarily enabled provider key without -an active binding remains unusable. Re-enable performs the provider update first and activates the -Core binding last. Rotation creates and binds the replacement before revoking the old binding, and -reports any provider-cleanup lag without withholding the one-time replacement secret. - -Support impersonation uses the current tenant, requires a non-empty reason, an active target human -user binding, an active original administrator binding in that same tenant, the restricted -`core.identity.record-support-impersonation` Action, and tenant `impersonate` permission. Shell -records a requested checkpoint, creates the Better Auth impersonation session, sets its selected -tenant and clears legal-entity selection, then records a started checkpoint containing only the -safe session reference. Failure to persist the started checkpoint revokes the new session. Stop -restores the original session and records a stopped checkpoint. Impersonated writes run with the -target's permissions and Policies, not the support administrator's permissions. +1. Extend the Shell-owned Better Auth instance with the pinned API Key and Admin plugins and their generated Drizzle schema. Better Auth remains the only owner of raw key generation, hashing, verification, expiration, counters/rate limits, enabled state, and impersonation sessions. +2. Generalize Core principal resolution around an explicit stable provider subject `{ provider, subjectType, providerSubjectId }`. Enforce that one Better Auth API-key ID has at most one Core binding globally, while Better Auth users retain tenant-specific multi-binding support. +3. Add generated `core.identity.*` Actions for every Core principal/binding mutation and the sensitive impersonation evidence lifecycle. Shell orchestrates Better Auth mechanics with these Actions in fail-closed, compensating order and never places raw key material in an Action payload. +4. Add governed Core identity reads and tenant-level SpiceDB permissions for self-service access, identity administration, and support impersonation. Shell joins authorized Core binding results with Auth-owned non-secret key metadata without crossing schema ownership. +5. Construct each trusted principal context through one mode-specific path. Session, API-key, impersonation, and system contexts satisfy cross-field invariants before Action/Read runtimes; every operation revalidates the effective tenant/principal/binding and, for impersonation, the tenant-local original administrator and continuing support permission. + +API-key issuance creates the Better Auth credential first, then commits its Core binding, and only then returns the raw key once. Binding failure disables the credential and returns no key. Disable/revoke operations close the Core binding first; a temporarily enabled provider key without an active binding remains unusable. Re-enable performs the provider update first and activates the Core binding last. Rotation creates and binds the replacement before revoking the old binding, and reports any provider-cleanup lag without withholding the one-time replacement secret. + +Support impersonation uses the current tenant, requires a non-empty reason, an active target human user binding, an active original administrator binding in that same tenant, the restricted `core.identity.record-support-impersonation` Action, and tenant `impersonate` permission. Shell records a requested checkpoint, creates the Better Auth impersonation session, sets its selected tenant and clears legal-entity selection, then records a started checkpoint containing only the safe session reference. Failure to persist the started checkpoint revokes the new session. Stop restores the original session and records a stopped checkpoint. Impersonated writes run with the target's permissions and Policies, not the support administrator's permissions. ## Relevant Files Use these files to implement the feature: - `../AGENTS.md` — limits work to `app/` and mandates Codesmith for every Action. -- `AGENTS.md` — defines Shell/Core authentication ownership, strict Effect boundaries, governed - operations, database ownership, and generator rules. +- `AGENTS.md` — defines Shell/Core authentication ownership, strict Effect boundaries, governed operations, database ownership, and generator rules. - `README.md` — documents workspace topology, commands, and the existing Shell authentication BFF. -- `docs/architecture/ACTIONS.md` — requires Actions for Core mutations and defines the narrow Better - Auth credential/session lifecycle exception. -- `docs/architecture/DATA_ACCESS.md` — requires immutable, revalidated operational scope and governed - reads with durable evidence. -- `docs/architecture/DATABASE.md` — keeps `auth` and `core` schema histories and typed Drizzle access - owner-local. -- `docs/architecture/ERRORS.md` — defines typed Effect errors, RFC 9457 responses, and `401`, `403`, - `429`, `503`, and `500` semantics. -- `docs/architecture/MICROVERTICALS.md` — keeps authentication out of a MicroVertical and preserves - audience-scoped gateway assertions. -- `docs/architecture/MODULE_ENTRYPOINTS.md` — requires structured Core entrypoints and fail-closed - context checks before private implementation resolution. -- `../docs/contexts/ontos/CONTEXT.md` — defines Principal, Principal Auth Binding, Authenticated - Principal Session, Tenant, and evidence vocabulary. -- `../docs/adr/0014-authenticated-principal-session.md` — requires active Better Auth subjects to - resolve through active tenant-local Core identity. -- `apps/shell-super-app/package.json` — owns the pinned Better Auth/API-key dependency and focused - Auth validation commands. -- `apps/shell-super-app/shared/api.ts` — owns the strict Effect contracts for lifecycle and - impersonation endpoints. -- `apps/shell-super-app/api/index.ts` — composes typed handlers, Core Action/Read runtimes, and - Problem Details mappings. -- `apps/shell-super-app/api/auth/config.ts` — validates Better Auth and mechanical support-plugin - configuration. -- `apps/shell-super-app/api/auth/service.ts` — owns Better Auth session resolution and must construct - session/impersonation contexts without exposing provider records. -- `apps/shell-super-app/api/auth/db/schema.ts` — Shell-owned Better Auth Drizzle schema to regenerate - for the API Key and Admin plugins. +- `docs/architecture/ACTIONS.md` — requires Actions for Core mutations and defines the narrow Better Auth credential/session lifecycle exception. +- `docs/architecture/DATA_ACCESS.md` — requires immutable, revalidated operational scope and governed reads with durable evidence. +- `docs/architecture/DATABASE.md` — keeps `auth` and `core` schema histories and typed Drizzle access owner-local. +- `docs/architecture/ERRORS.md` — defines typed Effect errors, RFC 9457 responses, and `401`, `403`, `429`, `503`, and `500` semantics. +- `docs/architecture/MICROVERTICALS.md` — keeps authentication out of a MicroVertical and preserves audience-scoped gateway assertions. +- `docs/architecture/MODULE_ENTRYPOINTS.md` — requires structured Core entrypoints and fail-closed context checks before private implementation resolution. +- `../docs/contexts/ontos/CONTEXT.md` — defines Principal, Principal Auth Binding, Authenticated Principal Session, Tenant, and evidence vocabulary. +- `../docs/adr/0014-authenticated-principal-session.md` — requires active Better Auth subjects to resolve through active tenant-local Core identity. +- `apps/shell-super-app/package.json` — owns the pinned Better Auth/API-key dependency and focused Auth validation commands. +- `apps/shell-super-app/shared/api.ts` — owns the strict Effect contracts for lifecycle and impersonation endpoints. +- `apps/shell-super-app/api/index.ts` — composes typed handlers, Core Action/Read runtimes, and Problem Details mappings. +- `apps/shell-super-app/api/auth/config.ts` — validates Better Auth and mechanical support-plugin configuration. +- `apps/shell-super-app/api/auth/service.ts` — owns Better Auth session resolution and must construct session/impersonation contexts without exposing provider records. +- `apps/shell-super-app/api/auth/db/schema.ts` — Shell-owned Better Auth Drizzle schema to regenerate for the API Key and Admin plugins. - `apps/shell-super-app/api/auth/db/catalog.ts` — exact Auth table inventory. - `apps/shell-super-app/api/auth/errors.ts` — existing typed Shell authentication failure vocabulary. -- `apps/shell-super-app/api/auth/gateway-issuer.ts` — issues the existing audience-scoped assertion - after trusted context resolution. -- `apps/shell-super-app/api/auth/legal-entity-selection.ts` — existing legal-entity validation to - reuse for optional API-key exchange context. -- `apps/shell-super-app/src/api/auth-client.ts` — typed client wrappers for the expanded Shell Auth - contract; no route or UI will consume the new administration operations in this feature. -- `apps/shell-super-app/scripts/verify-auth-db-schema.mts` — verifies all typed Auth plugin tables and - independent migration bookkeeping. +- `apps/shell-super-app/api/auth/gateway-issuer.ts` — issues the existing audience-scoped assertion after trusted context resolution. +- `apps/shell-super-app/api/auth/legal-entity-selection.ts` — existing legal-entity validation to reuse for optional API-key exchange context. +- `apps/shell-super-app/src/api/auth-client.ts` — typed client wrappers for the expanded Shell Auth contract; no route or UI will consume the new administration operations in this feature. +- `apps/shell-super-app/scripts/verify-auth-db-schema.mts` — verifies all typed Auth plugin tables and independent migration bookkeeping. - `apps/shell-super-app/tests/unit/auth-schema.test.ts` — proves the exact generated plugin schema. -- `apps/shell-super-app/tests/unit/auth-contract.test.ts` — proves endpoint methods, paths, schemas, - safe response fields, and HTTP status unions. -- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — proves live session, API-key, - gateway, lifecycle, compensation, and impersonation behavior. -- `packages/shared-contracts/src/gateway-context.ts` — shared assertion request/claims/client contract - and fixed five-minute TTL. -- `packages/shared-contracts/tests/unit/gateway-context.test.ts` — assertion schema, client, and - secret-stripping coverage. -- `packages/core-runtime/src/db/schema.ts` — principal kinds, subject types, binding cardinality, and - durable authentication evidence constraints. +- `apps/shell-super-app/tests/unit/auth-contract.test.ts` — proves endpoint methods, paths, schemas, safe response fields, and HTTP status unions. +- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — proves live session, API-key, gateway, lifecycle, compensation, and impersonation behavior. +- `packages/shared-contracts/src/gateway-context.ts` — shared assertion request/claims/client contract and fixed five-minute TTL. +- `packages/shared-contracts/tests/unit/gateway-context.test.ts` — assertion schema, client, and secret-stripping coverage. +- `packages/core-runtime/src/db/schema.ts` — principal kinds, subject types, binding cardinality, and durable authentication evidence constraints. - `packages/core-runtime/src/auth/principal-resolver.ts` — current user-only resolver to generalize. - `packages/core-runtime/src/auth/principal-resolver-errors.ts` — typed fail-closed resolution errors. -- `packages/core-runtime/src/actions/principal-context.ts` — trusted context schema and mode-specific - cross-field invariants. -- `packages/core-runtime/src/operations/context.ts` — operation-time tenant, principal, binding, - legal-entity, and impersonator revalidation. -- `packages/core-runtime/src/permissions/context-access.ts` — tenant/legal-entity authorization - adapter to extend with identity-admin and impersonation checks. +- `packages/core-runtime/src/actions/principal-context.ts` — trusted context schema and mode-specific cross-field invariants. +- `packages/core-runtime/src/operations/context.ts` — operation-time tenant, principal, binding, legal-entity, and impersonator revalidation. +- `packages/core-runtime/src/permissions/context-access.ts` — tenant/legal-entity authorization adapter to extend with identity-admin and impersonation checks. - `packages/core-runtime/spicedb/bootstrap.yaml` — local schema and restricted identity Action proof. -- `packages/core-runtime/src/index.ts` — narrow public Core identity contracts and generated Action - exports. -- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — subject-type classification and - fail-closed resolver coverage. -- `packages/core-runtime/tests/integration/principal-resolver.test.ts` — PostgreSQL binding - cardinality, status, and tenant-isolation coverage. -- `packages/core-runtime/tests/unit/operation-context.test.ts` — mode invariants and impersonator - revalidation behavior. +- `packages/core-runtime/src/index.ts` — narrow public Core identity contracts and generated Action exports. +- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — subject-type classification and fail-closed resolver coverage. +- `packages/core-runtime/tests/integration/principal-resolver.test.ts` — PostgreSQL binding cardinality, status, and tenant-isolation coverage. +- `packages/core-runtime/tests/unit/operation-context.test.ts` — mode invariants and impersonator revalidation behavior. - `packages/core-runtime/tests/integration/context-access.test.ts` — live tenant permission checks. -- `packages/core-runtime/tests/integration/action-runtime.test.ts` — generated identity Action - lifecycle, authorization, transaction, and evidence coverage. +- `packages/core-runtime/tests/integration/action-runtime.test.ts` — generated identity Action lifecycle, authorization, transaction, and evidence coverage. - `scripts/verify-application-db-schema.mts` — composed Auth/Core schema verification. -- `scripts/scaffolding/action/scaffold.mts` — mandatory generator used unchanged for the initial - Core Action files and exports. +- `scripts/scaffolding/action/scaffold.mts` — mandatory generator used unchanged for the initial Core Action files and exports. - `package.json` — exact workspace validation commands. ### New Files -- `packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts` — generated - sensitive Action for tenant-local `service`, `integration`, or `system` principals. -- `packages/core-runtime/src/modules/actions/change-principal-status.action.ts` — generated - sensitive Action for guarded non-human principal lifecycle transitions. -- `packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts` — generated Action that can - bind a verified Better Auth key ID only to the calling human principal. -- `packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts` — generated - Action for a human principal's own API-key binding lifecycle. -- `packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts` — generated sensitive - Action for binding a key ID to a tenant-local service/integration principal. -- `packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts` — - generated sensitive Action for managed binding disable, re-enable, and terminal revocation. -- `packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts` — generated - sensitive Action for requested, started, and stopped impersonation checkpoints. -- `packages/core-runtime/src/auth/principal-management.ts` — transaction-scoped Core services for - principal/binding transitions and sanitized impersonation evidence. -- `packages/core-runtime/src/auth/principal-management-errors.ts` — typed lifecycle conflicts, - invalid targets/transitions, and persistence failures. -- `packages/core-runtime/src/auth/principal-administration-reads.ts` — governed self and managed - principal/binding metadata reads. -- `packages/core-runtime/src/auth/system-principal-context.ts` — Core-owned trusted system-operation - registration and context resolver. -- `packages/core-runtime/tests/unit/principal-management.test.ts` — lifecycle transition and secret - exclusion tests. -- `packages/core-runtime/tests/unit/system-principal-context.test.ts` — branded registration, - validation, and fail-closed construction tests. -- `packages/core-runtime/tests/integration/principal-management.test.ts` — live Action/read, - cardinality, evidence, and tenant-isolation proof. -- `apps/shell-super-app/api/auth/api-key-service.ts` — Auth-owner Effect adapter for Better Auth key - issue, verify, safe metadata, enabled state, and compensation. -- `apps/shell-super-app/api/auth/identity-lifecycle.ts` — Shell orchestration across Auth mechanics - and generated Core Actions/reads. -- `apps/shell-super-app/api/auth/impersonation-service.ts` — Shell orchestration for start, resolution, - revalidation, stop, cookie forwarding, and compensation. -- `packages/core-runtime/drizzle/*.sql` — Drizzle-generated Core migration for API-key binding - cardinality/lifecycle constraints. -- `apps/shell-super-app/drizzle-auth/*.sql` — Drizzle-generated Auth migration for API Key/Admin - plugin tables and fields. +- `packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts` — generated sensitive Action for tenant-local `service`, `integration`, or `system` principals. +- `packages/core-runtime/src/modules/actions/change-principal-status.action.ts` — generated sensitive Action for guarded non-human principal lifecycle transitions. +- `packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts` — generated Action that can bind a verified Better Auth key ID only to the calling human principal. +- `packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts` — generated Action for a human principal's own API-key binding lifecycle. +- `packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts` — generated sensitive Action for binding a key ID to a tenant-local service/integration principal. +- `packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts` — generated sensitive Action for managed binding disable, re-enable, and terminal revocation. +- `packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts` — generated sensitive Action for requested, started, and stopped impersonation checkpoints. +- `packages/core-runtime/src/auth/principal-management.ts` — transaction-scoped Core services for principal/binding transitions and sanitized impersonation evidence. +- `packages/core-runtime/src/auth/principal-management-errors.ts` — typed lifecycle conflicts, invalid targets/transitions, and persistence failures. +- `packages/core-runtime/src/auth/principal-administration-reads.ts` — governed self and managed principal/binding metadata reads. +- `packages/core-runtime/src/auth/system-principal-context.ts` — Core-owned trusted system-operation registration and context resolver. +- `packages/core-runtime/tests/unit/principal-management.test.ts` — lifecycle transition and secret exclusion tests. +- `packages/core-runtime/tests/unit/system-principal-context.test.ts` — branded registration, validation, and fail-closed construction tests. +- `packages/core-runtime/tests/integration/principal-management.test.ts` — live Action/read, cardinality, evidence, and tenant-isolation proof. +- `apps/shell-super-app/api/auth/api-key-service.ts` — Auth-owner Effect adapter for Better Auth key issue, verify, safe metadata, enabled state, and compensation. +- `apps/shell-super-app/api/auth/identity-lifecycle.ts` — Shell orchestration across Auth mechanics and generated Core Actions/reads. +- `apps/shell-super-app/api/auth/impersonation-service.ts` — Shell orchestration for start, resolution, revalidation, stop, cookie forwarding, and compensation. +- `packages/core-runtime/drizzle/*.sql` — Drizzle-generated Core migration for API-key binding cardinality/lifecycle constraints. +- `apps/shell-super-app/drizzle-auth/*.sql` — Drizzle-generated Auth migration for API Key/Admin plugin tables and fields. ## Implementation Plan ### Phase 1: Foundation -Generate every Core identity Action before editing its payload, handler, service factory, or export. -Install the API-key plugin at the exact Better Auth `1.6.23` cohort used by `main`, enable the -Better Auth Admin plugin, regenerate the complete Auth Drizzle model, and generate independent Auth -and Core migrations. Generalize Core subject resolution, enforce global API-key binding -cardinality, strengthen trusted-context cross-field invariants, add tenant permission checks, and -provide a Core-only system context constructor. +Generate every Core identity Action before editing its payload, handler, service factory, or export. Install the API-key plugin at the exact Better Auth `1.6.23` cohort used by `main`, enable the Better Auth Admin plugin, regenerate the complete Auth Drizzle model, and generate independent Auth and Core migrations. Generalize Core subject resolution, enforce global API-key binding cardinality, strengthen trusted-context cross-field invariants, add tenant permission checks, and provide a Core-only system context constructor. ### Phase 2: Core Implementation -Implement generated Core Actions for non-human principal creation/status, self and managed API-key -bindings, and impersonation evidence. Add governed reads for self-service and tenant identity -administration. Mark every sensitive `core.identity.*` Action as restricted in SpiceDB and require -explicit executor relationships; no identity administration Action may inherit the existing -unconfigured-Action compatibility allow in a deployed environment. +Implement generated Core Actions for non-human principal creation/status, self and managed API-key bindings, and impersonation evidence. Add governed reads for self-service and tenant identity administration. Mark every sensitive `core.identity.*` Action as restricted in SpiceDB and require explicit executor relationships; no identity administration Action may inherit the existing unconfigured-Action compatibility allow in a deployed environment. -Implement Shell-owned credential mechanics and compensating orchestration. Human keys bind only to -the current human principal. Managed keys are Better Auth user-owned by the issuing administrator -but bind only to active service/integration principals in that administrator's tenant. Any tenant -administrator with the required SpiceDB permission can manage them through OntOS, independently of -the issuing human. +Implement Shell-owned credential mechanics and compensating orchestration. Human keys bind only to the current human principal. Managed keys are Better Auth user-owned by the issuing administrator but bind only to active service/integration principals in that administrator's tenant. Any tenant administrator with the required SpiceDB permission can manage them through OntOS, independently of the issuing human. ### Phase 3: Integration -Add a dedicated API-key exchange endpoint using `X-API-Key` and payload -`{ audience, legalEntityId? }`. Resolve one tenant/principal from the stable key ID, validate an -optional legal entity, and issue the existing audience-scoped assertion with `authMethod = api_key`, -the Core binding ID, and a safe Better Auth key reference. +Add a dedicated API-key exchange endpoint using `X-API-Key` and payload `{ audience, legalEntityId? }`. Resolve one tenant/principal from the stable key ID, validate an optional legal entity, and issue the existing audience-scoped assertion with `authMethod = api_key`, the Core binding ID, and a safe Better Auth key reference. -Integrate Better Auth impersonation sessions with tenant-local Core resolution. Require the support -administrator and target to have active user bindings in the same tenant, continuously recheck -support permission, propagate effective/original actor evidence through gateway assertions and -Actions/reads, and allow writes only under the target principal's authorization and Policies. -Exercise all modes through unit, PostgreSQL/SpiceDB/Auth integration, schema verification, and build -validation without adding browser UI. +Integrate Better Auth impersonation sessions with tenant-local Core resolution. Require the support administrator and target to have active user bindings in the same tenant, continuously recheck support permission, propagate effective/original actor evidence through gateway assertions and Actions/reads, and allow writes only under the target principal's authorization and Policies. Exercise all modes through unit, PostgreSQL/SpiceDB/Auth integration, schema verification, and build validation without adding browser UI. ## Step by Step Tasks @@ -261,8 +141,7 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Generate every Core identity Action -- [x] From `app/`, run these mandatory Codesmith commands before creating or editing the Action - files: +- [x] From `app/`, run these mandatory Codesmith commands before creating or editing the Action files: - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action create-non-human-principal --legal-entity-scope optional` - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action change-principal-status --legal-entity-scope optional` - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action bind-self-api-key --legal-entity-scope optional` @@ -270,380 +149,180 @@ IMPORTANT: Execute every step in order, top to bottom. - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action bind-managed-api-key --legal-entity-scope optional` - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action set-managed-api-key-binding-status --legal-entity-scope optional` - `mise exec -- pnpm scaffold:action -- --scope core --module core.identity --action record-support-impersonation --legal-entity-scope optional` -- [x] Verify Codesmith created only Core-owned files under - `packages/core-runtime/src/modules/actions/`, patched only the reserved Core export slot, used - explicit system entrypoints, and did not create an Auth vertical or generic endpoint. +- [x] Verify Codesmith created only Core-owned files under `packages/core-runtime/src/modules/actions/`, patched only the reserved Core export slot, used explicit system entrypoints, and did not create an Auth vertical or generic endpoint. ### 2. Add Better Auth API-key and impersonation persistence -- [x] Add `@better-auth/api-key` at the exact compatible `1.6.23` cohort to - `apps/shell-super-app/package.json`; use the Admin plugin from the existing `better-auth` package - and keep API-key mock sessions disabled so one request performs one explicit verification. -- [x] Configure one user-referenced API-key configuration only. Do not enable Better Auth - organizations or store OntOS permissions in Better Auth key permissions/metadata. -- [x] Configure the Admin plugin as a mechanical prerequisite for explicitly configured support - Better Auth user IDs, but retain SpiceDB as the authoritative tenant/target permission check and - keep every raw Better Auth plugin route private. -- [x] Use the pinned Better Auth schema generator as the source for the API Key and Admin plugin - fields, adapt the complete output to `pgSchema('auth')`, add the `apikey` table to the exact Auth - inventory/relations, and add the Admin user fields plus `session.impersonatedBy`. Retain existing - tenant/legal-entity session fields and add only server-owned impersonation context fields proven - necessary for reason/action correlation. -- [x] Generate the Auth migration through `mise exec -- pnpm --filter @app/shell-super-app -db:generate`; update typed schema/catalog tests and verification so the raw key hash remains only - in Auth and no Auth table appears in `core` or `public`. +- [x] Add `@better-auth/api-key` at the exact compatible `1.6.23` cohort to `apps/shell-super-app/package.json`; use the Admin plugin from the existing `better-auth` package and keep API-key mock sessions disabled so one request performs one explicit verification. +- [x] Configure one user-referenced API-key configuration only. Do not enable Better Auth organizations or store OntOS permissions in Better Auth key permissions/metadata. +- [x] Configure the Admin plugin as a mechanical prerequisite for explicitly configured support Better Auth user IDs, but retain SpiceDB as the authoritative tenant/target permission check and keep every raw Better Auth plugin route private. +- [x] Use the pinned Better Auth schema generator as the source for the API Key and Admin plugin fields, adapt the complete output to `pgSchema('auth')`, add the `apikey` table to the exact Auth inventory/relations, and add the Admin user fields plus `session.impersonatedBy`. Retain existing tenant/legal-entity session fields and add only server-owned impersonation context fields proven necessary for reason/action correlation. +- [x] Generate the Auth migration through `mise exec -- pnpm --filter @app/shell-super-app db:generate`; update typed schema/catalog tests and verification so the raw key hash remains only in Auth and no Auth table appears in `core` or `public`. ### 3. Generalize and constrain Core identity storage -- [x] Add exported frozen principal-kind, binding-subject, and binding-status vocabularies in - `packages/core-runtime/src/db/schema.ts` and reuse them in schemas/services instead of repeating - unchecked strings. -- [x] Add a partial global unique index for Better Auth `subject_type = api_key` over provider, - subject type, and provider subject ID so one stable key ID can bind to exactly one tenant and - principal; retain tenant-scoped multi-binding behavior for `subject_type = user`. -- [x] Add binding lifecycle checks so active/disabled bindings have no `revoked_at`, revoked - bindings have a timestamp, and terminal revocation cannot be silently reactivated. -- [x] Generate the Core migration through `mise exec -- pnpm --filter @app/core-runtime -db:generate`; extend schema, catalog, migration, and tenant-isolation tests for duplicate - cross-tenant API-key IDs, user multi-tenancy, same-tenant foreign keys, and lifecycle constraints. +- [x] Add exported frozen principal-kind, binding-subject, and binding-status vocabularies in `packages/core-runtime/src/db/schema.ts` and reuse them in schemas/services instead of repeating unchecked strings. +- [x] Add a partial global unique index for Better Auth `subject_type = api_key` over provider, subject type, and provider subject ID so one stable key ID can bind to exactly one tenant and principal; retain tenant-scoped multi-binding behavior for `subject_type = user`. +- [x] Add binding lifecycle checks so active/disabled bindings have no `revoked_at`, revoked bindings have a timestamp, and terminal revocation cannot be silently reactivated. +- [x] Generate the Core migration through `mise exec -- pnpm --filter @app/core-runtime db:generate`; extend schema, catalog, migration, and tenant-isolation tests for duplicate cross-tenant API-key IDs, user multi-tenancy, same-tenant foreign keys, and lifecycle constraints. ### 4. Resolve explicit provider subjects and trusted authentication contexts -- [x] Replace user-specific internal resolver loading with an explicit decoded provider-subject - input and add exact APIs for user default/selected-tenant resolution, API-key resolution without a - tenant selector, and target/original user resolution for impersonation. Preserve the current - session-facing methods as narrow wrappers where they aid compatibility. -- [x] Make API-key resolution fail closed for zero, duplicate, inactive, revoked, or cross-tenant - bindings and for inactive principals/tenants. Return only the stable binding ID, effective - principal, tenant, display fields, and principal kind; never accept or return a raw key. -- [x] Extend resolver unit/PostgreSQL tests for human, service, and integration results, one-key-one- - binding enforcement, user multi-tenant selection, all inactive states, and redacted database - unavailability. -- [x] Strengthen `TrustedPrincipalContextSchema` with cross-field rules: session requires an active - binding and safe session reference; API key requires an active binding and safe key reference; - support impersonation requires target binding/reference plus a distinct tenant-local original - principal; system requires a safe job/run reference and forbids binding, impersonator, and legal - entity by default. -- [x] Replace loose context literals in production composition with mode-specific constructors and - update focused tests to prove malformed combinations never reach Action/Read runtimes or gateway - signing. +- [x] Replace user-specific internal resolver loading with an explicit decoded provider-subject input and add exact APIs for user default/selected-tenant resolution, API-key resolution without a tenant selector, and target/original user resolution for impersonation. Preserve the current session-facing methods as narrow wrappers where they aid compatibility. +- [x] Make API-key resolution fail closed for zero, duplicate, inactive, revoked, or cross-tenant bindings and for inactive principals/tenants. Return only the stable binding ID, effective principal, tenant, display fields, and principal kind; never accept or return a raw key. +- [x] Extend resolver unit/PostgreSQL tests for human, service, and integration results, one-key-one- binding enforcement, user multi-tenant selection, all inactive states, and redacted database unavailability. +- [x] Strengthen `TrustedPrincipalContextSchema` with cross-field rules: session requires an active binding and safe session reference; API key requires an active binding and safe key reference; support impersonation requires target binding/reference plus a distinct tenant-local original principal; system requires a safe job/run reference and forbids binding, impersonator, and legal entity by default. +- [x] Replace loose context literals in production composition with mode-specific constructors and update focused tests to prove malformed combinations never reach Action/Read runtimes or gateway signing. ### 5. Add tenant identity permissions and impersonation revalidation -- [x] Extend the SpiceDB tenant definition with explicit `identity_admin` and `support` relations - and `manage_identity` and `impersonate` permissions while preserving existing membership/access - semantics. -- [x] Extend `ContextAccess` with exact tenant permission checks and extend governed Read permission - targeting with a tenant target carrying only the approved permission. Fail conditional, - malformed, missing, or unavailable decisions closed and retryably. -- [x] Update `OperationalScopeRepository` and resolver behavior so support impersonation revalidates - the effective target tenant/principal/binding, the original administrator as an active principal - in the same tenant, and the original administrator's continuing tenant `impersonate` permission. - Definite loss of permission is `403`; authorization/provider uncertainty is retryable `503`. -- [x] Add unit and live SpiceDB/PostgreSQL tests for identity administrator allow/deny, support - allow/deny, cross-tenant original administrators, disabled original/effective principals, - unavailable checks, and non-impersonated behavior remaining unchanged. +- [x] Extend the SpiceDB tenant definition with explicit `identity_admin` and `support` relations and `manage_identity` and `impersonate` permissions while preserving existing membership/access semantics. +- [x] Extend `ContextAccess` with exact tenant permission checks and extend governed Read permission targeting with a tenant target carrying only the approved permission. Fail conditional, malformed, missing, or unavailable decisions closed and retryably. +- [x] Update `OperationalScopeRepository` and resolver behavior so support impersonation revalidates the effective target tenant/principal/binding, the original administrator as an active principal in the same tenant, and the original administrator's continuing tenant `impersonate` permission. Definite loss of permission is `403`; authorization/provider uncertainty is retryable `503`. +- [x] Add unit and live SpiceDB/PostgreSQL tests for identity administrator allow/deny, support allow/deny, cross-tenant original administrators, disabled original/effective principals, unavailable checks, and non-impersonated behavior remaining unchanged. ### 6. Implement non-human principal and binding Actions -- [x] Adapt `create-non-human-principal` to accept only `service`, `integration`, or `system`, create - the principal in `context.scope.tenantId`, use a sensitive audit profile, require idempotency, and - return no credential data. Keep `agent` representable in the schema but unavailable through this - V0 production Action. -- [x] Adapt `change-principal-status` with expected-state concurrency, active/disabled/archived - transition rules, terminal archive semantics, same-tenant target validation, and a mandatory safe - reason for disable/archive. -- [x] Implement self binding/status Actions so their target is always - `context.scope.principalId`, the principal kind is `human`, the provider subject is a stable - verified Better Auth key ID, and the payload cannot name another principal or carry raw key - material. -- [x] Implement managed binding/status Actions so only active `service` or `integration` targets in - the caller's tenant are accepted; `system`, `agent`, `human`, foreign, missing, inactive, and - archived targets fail with declared typed domain errors. -- [x] Implement active/disabled/revoked binding transitions with expected-state concurrency and a - mandatory reason for revocation. Revocation sets `revoked_at` once and is terminal. -- [x] Build every handler over transaction-scoped Core identity services, record bounded metadata- - only reads for checked targets/current state, expose no executor, and add unit/PostgreSQL Action - tests for success, conflicts, idempotency, denial, rollback, evidence, and tenant isolation. -- [x] Add SpiceDB restriction markers for all sensitive `core.identity.*` Action keys and test that - an identity Action without an explicit executor relation is denied. Document deployment - provisioning of executor relationships; do not rely on the unconfigured-Action compatibility - allow for these Actions. +- [x] Adapt `create-non-human-principal` to accept only `service`, `integration`, or `system`, create the principal in `context.scope.tenantId`, use a sensitive audit profile, require idempotency, and return no credential data. Keep `agent` representable in the schema but unavailable through this V0 production Action. +- [x] Adapt `change-principal-status` with expected-state concurrency, active/disabled/archived transition rules, terminal archive semantics, same-tenant target validation, and a mandatory safe reason for disable/archive. +- [x] Implement self binding/status Actions so their target is always `context.scope.principalId`, the principal kind is `human`, the provider subject is a stable verified Better Auth key ID, and the payload cannot name another principal or carry raw key material. +- [x] Implement managed binding/status Actions so only active `service` or `integration` targets in the caller's tenant are accepted; `system`, `agent`, `human`, foreign, missing, inactive, and archived targets fail with declared typed domain errors. +- [x] Implement active/disabled/revoked binding transitions with expected-state concurrency and a mandatory reason for revocation. Revocation sets `revoked_at` once and is terminal. +- [x] Build every handler over transaction-scoped Core identity services, record bounded metadata- only reads for checked targets/current state, expose no executor, and add unit/PostgreSQL Action tests for success, conflicts, idempotency, denial, rollback, evidence, and tenant isolation. +- [x] Add SpiceDB restriction markers for all sensitive `core.identity.*` Action keys and test that an identity Action without an explicit executor relation is denied. Document deployment provisioning of executor relationships; do not rely on the unconfigured-Action compatibility allow for these Actions. ### 7. Add governed identity administration reads -- [x] Define Core system reads for a human principal's own API-key bindings and for identity - administrators to list tenant-local service/integration principals plus their non-secret binding - metadata. Use legal-entity scope `optional`, tenant permission targets (`access` for self and - `manage_identity` for managed reads), metadata-only evidence, bounded pagination, and stable sort - order. -- [x] Return no raw key, hash, Better Auth owner/reference ID, provider metadata blob, session ID, - database diagnostics, or foreign-tenant identifier. The provider subject key ID remains a private - Shell/Core join key and must be stripped from the public response. -- [x] Add ReadRuntime unit/PostgreSQL tests for self-only filtering, managed authorization, - pagination, empty results, disabled/revoked metadata, denied evidence, unavailable evidence, and - tenant leakage. +- [x] Define Core system reads for a human principal's own API-key bindings and for identity administrators to list tenant-local service/integration principals plus their non-secret binding metadata. Use legal-entity scope `optional`, tenant permission targets (`access` for self and `manage_identity` for managed reads), metadata-only evidence, bounded pagination, and stable sort order. +- [x] Return no raw key, hash, Better Auth owner/reference ID, provider metadata blob, session ID, database diagnostics, or foreign-tenant identifier. The provider subject key ID remains a private Shell/Core join key and must be stripped from the public response. +- [x] Add ReadRuntime unit/PostgreSQL tests for self-only filtering, managed authorization, pagination, empty results, disabled/revoked metadata, denied evidence, unavailable evidence, and tenant leakage. ### 8. Implement Shell API-key lifecycle orchestration -- [x] Add an Auth-owner Effect service around Better Auth's supported create/verify/update APIs and - typed Auth Drizzle metadata access. Never expose the Auth executor, stored hash, raw provider - record, or issuing Better Auth user ID outside the private service. -- [x] Extend the strict Shell API contract/runtime with backend-only operations to create/list/change - non-human principals; issue/list/disable/re-enable/revoke/rotate self and managed API keys; and - return raw key material only in the successful issue/rotate response that created it. -- [x] For service/integration issuance, set the Better Auth key owner to the authenticated issuing - human while binding the stable key ID to the managed principal. Permit later management by any - current tenant identity administrator through the governed OntOS APIs, not the provider's - user-scoped public endpoints. -- [x] Orchestrate issue as provider create, generated Core bind Action, then one-time response. If - binding fails, disable the provider key and reveal no raw secret. Treat an unreachable cleanup as - sanitized retryable cleanup debt while the absent binding keeps the key unusable. -- [x] Orchestrate disable/revoke by closing the Core binding before disabling the provider key; - orchestrate re-enable by enabling the provider key before activating the Core binding. Make each - operation retry-safe and report provider-cleanup lag without misrepresenting Core usability. -- [x] Orchestrate rotation by creating/binding the replacement before revoking the old binding. Once - the old Core binding is closed, return the new one-time secret even if provider cleanup needs a - retry; never return a `503` that causes the caller to unknowingly lose the only copy of a newly - active secret. -- [x] Map malformed payloads to `400`, absent/invalid sessions to `401`, tenant/self/administrator - denial to `403`, lifecycle conflicts to `409` or semantic ineligibility to `422`, provider rate - limits to `429` with a safe retry hint, provider/Core unavailability to retryable `503`, and caught - defects to sanitized `500` Problem Details. -- [x] Extend client wrappers and contract/runtime tests for every operation, exact error statuses, - one-time secret behavior, all compensation paths, cross-admin managed-key operation, redaction, - and absence of raw Better Auth routes. Add no page, route component, locale copy, or Playwright UI - flow. +- [x] Add an Auth-owner Effect service around Better Auth's supported create/verify/update APIs and typed Auth Drizzle metadata access. Never expose the Auth executor, stored hash, raw provider record, or issuing Better Auth user ID outside the private service. +- [x] Extend the strict Shell API contract/runtime with backend-only operations to create/list/change non-human principals; issue/list/disable/re-enable/revoke/rotate self and managed API keys; and return raw key material only in the successful issue/rotate response that created it. +- [x] For service/integration issuance, set the Better Auth key owner to the authenticated issuing human while binding the stable key ID to the managed principal. Permit later management by any current tenant identity administrator through the governed OntOS APIs, not the provider's user-scoped public endpoints. +- [x] Orchestrate issue as provider create, generated Core bind Action, then one-time response. If binding fails, disable the provider key and reveal no raw secret. Treat an unreachable cleanup as sanitized retryable cleanup debt while the absent binding keeps the key unusable. +- [x] Orchestrate disable/revoke by closing the Core binding before disabling the provider key; orchestrate re-enable by enabling the provider key before activating the Core binding. Make each operation retry-safe and report provider-cleanup lag without misrepresenting Core usability. +- [x] Orchestrate rotation by creating/binding the replacement before revoking the old binding. Once the old Core binding is closed, return the new one-time secret even if provider cleanup needs a retry; never return a `503` that causes the caller to unknowingly lose the only copy of a newly active secret. +- [x] Map malformed payloads to `400`, absent/invalid sessions to `401`, tenant/self/administrator denial to `403`, lifecycle conflicts to `409` or semantic ineligibility to `422`, provider rate limits to `429` with a safe retry hint, provider/Core unavailability to retryable `503`, and caught defects to sanitized `500` Problem Details. +- [x] Extend client wrappers and contract/runtime tests for every operation, exact error statuses, one-time secret behavior, all compensation paths, cross-admin managed-key operation, redaction, and absence of raw Better Auth routes. Add no page, route component, locale copy, or Playwright UI flow. ### 9. Add central API-key assertion exchange -- [x] Add `POST /auth/api-key/gateway-context` to the shared/Shell Effect contract with - `X-API-Key` as the only raw credential input and payload `{ audience, legalEntityId? }`; do not - accept tenant, principal, binding, auth method, impersonator, or context-reference input. -- [x] Verify the key exactly once through Better Auth so expiration, enabled state, remaining count, - and rate limiting are provider-owned; resolve the returned stable key ID through Core and derive - tenant/principal/binding exclusively from that result. -- [x] Validate an optional legal entity through the existing Core legal-entity context and SpiceDB - access path. An omitted legal entity remains valid for exchange, but any target operation that - declares it required must reject the assertion before private code resolves. -- [x] Issue the existing 300-second assertion with `authMethod = api_key`, the active Core binding - ID, `authContextRef = better-auth-api-key:{stable-id}`, and no raw credential/provider owner data. - Reuse audience allowlisting and EdDSA signing; do not introduce a second token format. -- [x] Map missing/malformed/expired/disabled keys and inactive/missing bindings to `401` with an - API-key challenge, active credentials resolving to forbidden tenant/principal/legal-entity state - to `403`, rate limit to `429`, invalid audience/payload to `400`, dependency uncertainty to - retryable `503`, and caught defects to sanitized `500`. -- [x] Extend shared-contract, Shell contract, issuer, and integration tests to prove the raw key - stops at Shell, one key cannot select another tenant/principal, legal-entity checks fail closed, - the assertion verifies only for its audience, and receiving Action/Read runtimes persist API-key - identity evidence. +- [x] Add `POST /auth/api-key/gateway-context` to the shared/Shell Effect contract with `X-API-Key` as the only raw credential input and payload `{ audience, legalEntityId? }`; do not accept tenant, principal, binding, auth method, impersonator, or context-reference input. +- [x] Verify the key exactly once through Better Auth so expiration, enabled state, remaining count, and rate limiting are provider-owned; resolve the returned stable key ID through Core and derive tenant/principal/binding exclusively from that result. +- [x] Validate an optional legal entity through the existing Core legal-entity context and SpiceDB access path. An omitted legal entity remains valid for exchange, but any target operation that declares it required must reject the assertion before private code resolves. +- [x] Issue the existing 300-second assertion with `authMethod = api_key`, the active Core binding ID, `authContextRef = better-auth-api-key:{stable-id}`, and no raw credential/provider owner data. Reuse audience allowlisting and EdDSA signing; do not introduce a second token format. +- [x] Map missing/malformed/expired/disabled keys and inactive/missing bindings to `401` with an API-key challenge, active credentials resolving to forbidden tenant/principal/legal-entity state to `403`, rate limit to `429`, invalid audience/payload to `400`, dependency uncertainty to retryable `503`, and caught defects to sanitized `500`. +- [x] Extend shared-contract, Shell contract, issuer, and integration tests to prove the raw key stops at Shell, one key cannot select another tenant/principal, legal-entity checks fail closed, the assertion verifies only for its audience, and receiving Action/Read runtimes persist API-key identity evidence. ### 10. Construct trusted system-operation contexts -- [x] Add a constructor-produced, immutable system workload registration carrying a stable job key; - reject copied/plain objects and do not accept registrations from HTTP payloads, headers, cookies, - or gateway claims. -- [x] Implement an Effect resolver that accepts the trusted registration, tenant ID, configured - tenant-local principal ID, and non-secret run reference; reloads tenant/principal state; permits - only active `system` or explicitly approved `service` principals in that tenant; and returns a - frozen context with `authMethod = system`, no binding/impersonator/legal entity, and - `authContextRef = job:{job-key}:run:{run-ref}`. -- [x] Require system principal provisioning through the generated non-human-principal Action or an - existing trusted tenant bootstrap, never a fake Better Auth user/binding. Keep the principal ID in - trusted job configuration/registration rather than deriving it from display name. -- [x] Add unit/PostgreSQL integration tests that invoke governed Core Action and Read paths with a - resolved system context and prove inactive/foreign/wrong-kind principals, malformed refs, forged - registrations, auth bindings, impersonation, and legal-entity context fail closed. +- [x] Add a constructor-produced, immutable system workload registration carrying a stable job key; reject copied/plain objects and do not accept registrations from HTTP payloads, headers, cookies, or gateway claims. +- [x] Implement an Effect resolver that accepts the trusted registration, tenant ID, configured tenant-local principal ID, and non-secret run reference; reloads tenant/principal state; permits only active `system` or explicitly approved `service` principals in that tenant; and returns a frozen context with `authMethod = system`, no binding/impersonator/legal entity, and `authContextRef = job:{job-key}:run:{run-ref}`. +- [x] Require system principal provisioning through the generated non-human-principal Action or an existing trusted tenant bootstrap, never a fake Better Auth user/binding. Keep the principal ID in trusted job configuration/registration rather than deriving it from display name. +- [x] Add unit/PostgreSQL integration tests that invoke governed Core Action and Read paths with a resolved system context and prove inactive/foreign/wrong-kind principals, malformed refs, forged registrations, auth bindings, impersonation, and legal-entity context fail closed. ### 11. Implement complete support impersonation -- [x] Add typed backend-only `startSupportImpersonation` and `stopSupportImpersonation` Shell - endpoints. Start accepts only a target OntOS principal UUID and a trimmed 1-500 character reason; - tenant, provider user ID, permissions, auth method, and binding IDs come from trusted state. -- [x] Reject anonymous callers, already impersonated sessions, self/nested impersonation, foreign- - tenant targets, non-human targets, targets without exactly one active Better Auth user binding, - inactive principals/tenants, and support users without both the mechanical Better Auth capability - and tenant `impersonate` permission. Preserve Better Auth's safe default that an administrator - cannot impersonate another administrator. -- [x] Invoke `record-support-impersonation` as the original administrator for a `requested` - checkpoint before creating the provider session. Store the original/effective principal IDs, - safe reason, tenant, timestamp, and Action identity in sensitive audit evidence without storing a - token, cookie, provider user ID, or session token. -- [x] Create the Better Auth target session, force its active tenant to the administrator's current - tenant, clear legal-entity selection, retain the server-owned reason/action correlation, then - invoke the same Action for a `started` checkpoint with only a safe session reference. If started - evidence cannot commit, revoke/stop the new session and return a typed retryable failure. -- [x] On every impersonated session read, resolve the target user binding as effective - `principalId`/`authBindingId`, resolve the original Better Auth user to an active principal in the - same tenant as `impersonatedByPrincipalId`, set `authMethod = support_impersonation`, use the safe - session ID as `authContextRef`, and continuously recheck tenant support permission. -- [x] Keep gateway assertion issuance available during impersonation. Receiving operations recheck - target binding/principal/tenant and original principal/support permission; permission and Policy - execution then use the effective target principal so support writes have exactly the target's - powers and are attributable to both actors. -- [x] Stop through Better Auth, restore/forward all required cookies, and invoke the lifecycle Action - with the reconstructed original administrator context for a `stopped` checkpoint. Make repeated - stop safe and ensure losing support permission prevents new work while still allowing secure - session termination. -- [x] Extend current-session safe output just enough to identify an active impersonation and permit - API clients to stop it; do not expose the original provider user ID, provider role, session token, - reason to unrelated clients, or an administration UI. -- [x] Add unit and live Auth/Core/SpiceDB integration tests for start/current session/gateway/write/ - stop, original/effective evidence columns, mandatory reason, same-tenant enforcement, target - permissions, support permission revocation, admin-to-admin/nested denial, session expiry, - started-evidence compensation, cookie propagation, and complete secret redaction. +- [x] Add typed backend-only `startSupportImpersonation` and `stopSupportImpersonation` Shell endpoints. Start accepts only a target OntOS principal UUID and a trimmed 1-500 character reason; tenant, provider user ID, permissions, auth method, and binding IDs come from trusted state. +- [x] Reject anonymous callers, already impersonated sessions, self/nested impersonation, foreign- tenant targets, non-human targets, targets without exactly one active Better Auth user binding, inactive principals/tenants, and support users without both the mechanical Better Auth capability and tenant `impersonate` permission. Preserve Better Auth's safe default that an administrator cannot impersonate another administrator. +- [x] Invoke `record-support-impersonation` as the original administrator for a `requested` checkpoint before creating the provider session. Store the original/effective principal IDs, safe reason, tenant, timestamp, and Action identity in sensitive audit evidence without storing a token, cookie, provider user ID, or session token. +- [x] Create the Better Auth target session, force its active tenant to the administrator's current tenant, clear legal-entity selection, retain the server-owned reason/action correlation, then invoke the same Action for a `started` checkpoint with only a safe session reference. If started evidence cannot commit, revoke/stop the new session and return a typed retryable failure. +- [x] On every impersonated session read, resolve the target user binding as effective `principalId`/`authBindingId`, resolve the original Better Auth user to an active principal in the same tenant as `impersonatedByPrincipalId`, set `authMethod = support_impersonation`, use the safe session ID as `authContextRef`, and continuously recheck tenant support permission. +- [x] Keep gateway assertion issuance available during impersonation. Receiving operations recheck target binding/principal/tenant and original principal/support permission; permission and Policy execution then use the effective target principal so support writes have exactly the target's powers and are attributable to both actors. +- [x] Stop through Better Auth, restore/forward all required cookies, and invoke the lifecycle Action with the reconstructed original administrator context for a `stopped` checkpoint. Make repeated stop safe and ensure losing support permission prevents new work while still allowing secure session termination. +- [x] Extend current-session safe output just enough to identify an active impersonation and permit API clients to stop it; do not expose the original provider user ID, provider role, session token, reason to unrelated clients, or an administration UI. +- [x] Add unit and live Auth/Core/SpiceDB integration tests for start/current session/gateway/write/ stop, original/effective evidence columns, mandatory reason, same-tenant enforcement, target permissions, support permission revocation, admin-to-admin/nested denial, session expiry, started-evidence compensation, cookie propagation, and complete secret redaction. ### 12. Document the completed identity boundary -- [x] Update `docs/architecture/ACTIONS.md`, `DATA_ACCESS.md`, `ERRORS.md`, `MICROVERTICALS.md`, and - `README.md` with the implemented subject resolver, credential lifecycle exception, one-key-one- - tenant invariant, Shell-only raw-key boundary, assertion exchange, tenant-local support - impersonation, system context construction, compensation semantics, typed error mappings, and - exact operational provisioning requirements. -- [x] Document that `human` remains the V0 principal kind for internal/external/guest users while - SpiceDB roles and future Party relationships express their access; do not add autonomous agent - behavior or invent new principal-kind values in this feature. -- [x] Document Auth/API-key and support configuration without adding secrets or real identifiers to - `.env.example`, tests, logs, or tracked files. +- [x] Update `docs/architecture/ACTIONS.md`, `DATA_ACCESS.md`, `ERRORS.md`, `MICROVERTICALS.md`, and `README.md` with the implemented subject resolver, credential lifecycle exception, one-key-one- tenant invariant, Shell-only raw-key boundary, assertion exchange, tenant-local support impersonation, system context construction, compensation semantics, typed error mappings, and exact operational provisioning requirements. +- [x] Document that `human` remains the V0 principal kind for internal/external/guest users while SpiceDB roles and future Party relationships express their access; do not add autonomous agent behavior or invent new principal-kind values in this feature. +- [x] Document Auth/API-key and support configuration without adding secrets or real identifiers to `.env.example`, tests, logs, or tracked files. ### 13. Run all validation commands -- [ ] From `app/`, execute every command under `Validation Commands` in order and resolve all - failures without weakening typed errors, Action/Read lifecycles, tenant isolation, credential - redaction, compensation, audience scoping, or the Shell/Core ownership boundary. +- [ ] From `app/`, execute every command under `Validation Commands` in order and resolve all failures without weakening typed errors, Action/Read lifecycles, tenant isolation, credential redaction, compensation, audience scoping, or the Shell/Core ownership boundary. ## Testing Strategy ### Unit Tests -Test provider-subject decoding/classification, principal and binding lifecycle transitions, partial -uniqueness, trusted-context cross-field rules, system registration branding, system context -resolution, tenant permission request mapping, generated Action descriptors/handlers/errors, -governed identity read filtering, Better Auth key result sanitization, lifecycle orchestration and -compensation, impersonation state construction, strict API schemas, Problem Details status unions, -gateway claims, and generated/client-facing secret stripping. +Test provider-subject decoding/classification, principal and binding lifecycle transitions, partial uniqueness, trusted-context cross-field rules, system registration branding, system context resolution, tenant permission request mapping, generated Action descriptors/handlers/errors, governed identity read filtering, Better Auth key result sanitization, lifecycle orchestration and compensation, impersonation state construction, strict API schemas, Problem Details status unions, gateway claims, and generated/client-facing secret stripping. ### Integration Tests Use the existing PostgreSQL, Auth schema, and SpiceDB integration setup to prove complete flows: - interactive session contexts now carry a safe session reference without behavior regression; -- human and managed key issuance creates Auth credentials plus exactly one Core binding and returns - the raw key once; +- human and managed key issuance creates Auth credentials plus exactly one Core binding and returns the raw key once; - any authorized tenant administrator can manage a managed key regardless of provider owner; -- key verification/exchange yields an audience-scoped assertion and a receiving governed read or - Action records `api_key` evidence; -- disabled, revoked, expired, rate-limited, cross-tenant, duplicated, and dependency-unavailable - paths fail closed with declared errors; -- system contexts can invoke governed operations only for trusted registrations and valid - tenant-local system/service principals; and -- support start, assertion issuance, target-authorized write, continuing permission recheck, and - stop preserve effective/original actor evidence and compensation guarantees. - -Browser/E2E tests are not required because the accepted scope explicitly excludes administration -UI. Existing login E2E behavior must remain green through the repository build and focused Shell -tests. +- key verification/exchange yields an audience-scoped assertion and a receiving governed read or Action records `api_key` evidence; +- disabled, revoked, expired, rate-limited, cross-tenant, duplicated, and dependency-unavailable paths fail closed with declared errors; +- system contexts can invoke governed operations only for trusted registrations and valid tenant-local system/service principals; and +- support start, assertion issuance, target-authorized write, continuing permission recheck, and stop preserve effective/original actor evidence and compensation guarantees. + +Browser/E2E tests are not required because the accepted scope explicitly excludes administration UI. Existing login E2E behavior must remain green through the repository build and focused Shell tests. ### Edge Cases -- One Better Auth user has active human bindings in several tenants; sessions retain explicit - tenant selection while each API key binds to only one of them. +- One Better Auth user has active human bindings in several tenants; sessions retain explicit tenant selection while each API key binds to only one of them. - One Better Auth key ID is submitted for a second tenant or principal. - A key is valid in Better Auth but missing, disabled, or revoked in Core. - A Core binding is active while the provider key is disabled, expired, exhausted, or rate-limited. - Provider creation succeeds and Core binding fails; no raw key is returned. -- Core disable/revoke succeeds and provider cleanup is unavailable; the key remains unusable in - OntOS and cleanup can be retried. -- Re-enable succeeds in the provider but Core activation conflicts; the still-inactive Core binding - prevents use. -- A rotation response is interrupted after the replacement binding commits; metadata remains - listable and the unreachable replacement can be revoked without exposing its hash. +- Core disable/revoke succeeds and provider cleanup is unavailable; the key remains unusable in OntOS and cleanup can be retried. +- Re-enable succeeds in the provider but Core activation conflicts; the still-inactive Core binding prevents use. +- A rotation response is interrupted after the replacement binding commits; metadata remains listable and the unreachable replacement can be revoked without exposing its hash. - A human tries to use the self endpoint for another principal. - A managed endpoint targets a human, system, agent, inactive, archived, or foreign principal. - A revoked binding or archived principal is reactivated. - API-key exchange omits legal entity and the target operation requires one. - API-key exchange supplies a legal entity outside the bound tenant or principal's permission. - The assertion audience is missing, unknown, or different at verification. -- A raw key, key hash, session token, cookie, provider owner ID, or signature diagnostic reaches a - response, log, Action payload/evidence, gateway claim, or Core table. -- A system caller forges a registration, supplies a human principal, crosses tenants, includes an - auth binding/legal entity, or omits the job/run reference. -- A support administrator has no binding in the target tenant, loses support permission after - session creation, or becomes disabled during an issued assertion's lifetime. +- A raw key, key hash, session token, cookie, provider owner ID, or signature diagnostic reaches a response, log, Action payload/evidence, gateway claim, or Core table. +- A system caller forges a registration, supplies a human principal, crosses tenants, includes an auth binding/legal entity, or omits the job/run reference. +- A support administrator has no binding in the target tenant, loses support permission after session creation, or becomes disabled during an issued assertion's lifetime. - The target user/binding/tenant becomes disabled or revoked during impersonation. - Support attempts self, nested, admin-to-admin, cross-tenant, or reasonless impersonation. - Better Auth creates an impersonation session but started evidence fails; the session is revoked. -- Stop is repeated, the original session is expired, or evidence persistence is temporarily - unavailable. +- Stop is repeated, the original session is expired, or evidence persistence is temporarily unavailable. ## Acceptance Criteria -- [x] Existing interactive Better Auth login/session/tenant/legal-entity behavior remains working - and now supplies `authMethod = session`, active target binding ID, and a safe non-secret session - reference to governed operation evidence. -- [x] The Shell Auth schema and runtime use Better Auth `1.6.23` API Key/Admin plugins, and Better - Auth alone stores raw-key hashes, expiration, rate-limit/counter state, enabled state, roles, and - impersonation sessions. -- [x] No raw API key, API-key hash, session token, cookie value, provider user ID, or provider - diagnostic is stored in Core or exposed through logs, Problem Details, assertions, lifecycle - lists, or Action/read evidence. -- [x] A Better Auth API-key ID can have at most one Core binding globally; multi-tenant integrations - require separate keys, while Better Auth user bindings continue to support explicit tenant - selection. -- [x] Human keys resolve to their human principal, and managed keys resolve to the selected active - service/integration principal regardless of which authorized human issued the provider key. -- [x] Backend lifecycle APIs support non-human principal create/status plus API-key issue, list, - disable, re-enable, revoke, and rotate for self and managed principals with typed errors and no UI. -- [x] Every Core principal/binding mutation and support impersonation checkpoint runs through its - generated restricted `core.identity.*` Action with idempotency, sensitive evidence where - applicable, permission checks, typed failures, and transaction-scoped services. -- [x] Credential/session mechanics remain the documented Shell-owned exception and are orchestrated - so partial provider/Core failure is fail-closed; raw one-time secrets are never lost behind a - misleading retryable error after becoming active. -- [x] `POST /auth/api-key/gateway-context` verifies `X-API-Key` only at Shell, derives one - tenant/principal/binding from Core, validates optional legal entity, and returns the existing - five-minute assertion for exactly one allowed MicroVertical audience. -- [x] MicroVerticals receive only the assertion and business payload; they never receive or verify a - raw external API key. -- [x] Receiving governed Actions/reads revalidate API-key scope and persist `principal_id`, - `auth_binding_id`, `auth_method = api_key`, and a safe key reference. -- [x] Trusted system-operation construction is unavailable over HTTP and succeeds only for a - constructor-produced workload registration plus an active tenant-local system/service principal; - it records `auth_method = system`, no binding, and a bounded job/run reference. -- [x] Support impersonation requires a reason, active same-tenant original and target user - identities, Better Auth mechanical capability, and SpiceDB support permission; it supports writes - only with the target's authorization/Policies. -- [x] Impersonated Action/read evidence records the target as `principal_id`, the target user's - binding as `auth_binding_id`, `auth_method = support_impersonation`, the original administrator as - `impersonated_by_principal_id`, and a safe session reference. -- [x] Support permission and both principals are revalidated for ongoing impersonated work; losing - permission fails closed but does not prevent secure stop/cleanup. -- [x] Started-evidence failure revokes the newly created impersonation session, and repeated stop is - safe. -- [x] Agent principals remain model-only; Better Auth organizations, a global support principal, - autonomous agents, Auth MicroVertical, generic Action endpoint, and administration UI are absent. -- [x] Tenant leakage tests cover user multi-binding, global API-key uniqueness, lifecycle APIs, - legal-entity exchange, managed principals, system contexts, and impersonation. +- [x] Existing interactive Better Auth login/session/tenant/legal-entity behavior remains working and now supplies `authMethod = session`, active target binding ID, and a safe non-secret session reference to governed operation evidence. +- [x] The Shell Auth schema and runtime use Better Auth `1.6.23` API Key/Admin plugins, and Better Auth alone stores raw-key hashes, expiration, rate-limit/counter state, enabled state, roles, and impersonation sessions. +- [x] No raw API key, API-key hash, session token, cookie value, provider user ID, or provider diagnostic is stored in Core or exposed through logs, Problem Details, assertions, lifecycle lists, or Action/read evidence. +- [x] A Better Auth API-key ID can have at most one Core binding globally; multi-tenant integrations require separate keys, while Better Auth user bindings continue to support explicit tenant selection. +- [x] Human keys resolve to their human principal, and managed keys resolve to the selected active service/integration principal regardless of which authorized human issued the provider key. +- [x] Backend lifecycle APIs support non-human principal create/status plus API-key issue, list, disable, re-enable, revoke, and rotate for self and managed principals with typed errors and no UI. +- [x] Every Core principal/binding mutation and support impersonation checkpoint runs through its generated restricted `core.identity.*` Action with idempotency, sensitive evidence where applicable, permission checks, typed failures, and transaction-scoped services. +- [x] Credential/session mechanics remain the documented Shell-owned exception and are orchestrated so partial provider/Core failure is fail-closed; raw one-time secrets are never lost behind a misleading retryable error after becoming active. +- [x] `POST /auth/api-key/gateway-context` verifies `X-API-Key` only at Shell, derives one tenant/principal/binding from Core, validates optional legal entity, and returns the existing five-minute assertion for exactly one allowed MicroVertical audience. +- [x] MicroVerticals receive only the assertion and business payload; they never receive or verify a raw external API key. +- [x] Receiving governed Actions/reads revalidate API-key scope and persist `principal_id`, `auth_binding_id`, `auth_method = api_key`, and a safe key reference. +- [x] Trusted system-operation construction is unavailable over HTTP and succeeds only for a constructor-produced workload registration plus an active tenant-local system/service principal; it records `auth_method = system`, no binding, and a bounded job/run reference. +- [x] Support impersonation requires a reason, active same-tenant original and target user identities, Better Auth mechanical capability, and SpiceDB support permission; it supports writes only with the target's authorization/Policies. +- [x] Impersonated Action/read evidence records the target as `principal_id`, the target user's binding as `auth_binding_id`, `auth_method = support_impersonation`, the original administrator as `impersonated_by_principal_id`, and a safe session reference. +- [x] Support permission and both principals are revalidated for ongoing impersonated work; losing permission fails closed but does not prevent secure stop/cleanup. +- [x] Started-evidence failure revokes the newly created impersonation session, and repeated stop is safe. +- [x] Agent principals remain model-only; Better Auth organizations, a global support principal, autonomous agents, Auth MicroVertical, generic Action endpoint, and administration UI are absent. +- [x] Tenant leakage tests cover user multi-binding, global API-key uniqueness, lifecycle APIs, legal-entity exchange, managed principals, system contexts, and impersonation. ## Validation Commands Execute every command to validate the feature with zero regressions. -- `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — validate all generated identity - Action descriptors, errors, handlers, and runtime invariants. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — run Core identity, operation-context, - Action/read, PostgreSQL, SpiceDB, and tenant-isolation tests. -- `mise exec -- pnpm --filter @app/shared-contracts test:unit` — validate gateway request, claims, - assertion client, and secret-stripping contracts. -- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — validate Auth schema, strict API - contracts, typed mappings, and existing Shell behavior. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — run live Better Auth/Core - session, key lifecycle, exchange, compensation, and impersonation flows. -- `mise exec -- pnpm db:verify` — compare the migrated Core/Auth catalogs and typed table access with - the exact owner inventories. +- `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — validate all generated identity Action descriptors, errors, handlers, and runtime invariants. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — run Core identity, operation-context, Action/read, PostgreSQL, SpiceDB, and tenant-isolation tests. +- `mise exec -- pnpm --filter @app/shared-contracts test:unit` — validate gateway request, claims, assertion client, and secret-stripping contracts. +- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — validate Auth schema, strict API contracts, typed mappings, and existing Shell behavior. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — run live Better Auth/Core session, key lifecycle, exchange, compensation, and impersonation flows. +- `mise exec -- pnpm db:verify` — compare the migrated Core/Auth catalogs and typed table access with the exact owner inventories. - `mise exec -- pnpm check` — run the final repository quality gate. -- `mise exec -- pnpm build` — build the Shell strict Effect BFF and shared gateway contract with the - completed identity modes. +- `mise exec -- pnpm build` — build the Shell strict Effect BFF and shared gateway contract with the completed identity modes. ## Review Checklist @@ -655,72 +334,33 @@ Execute every command to validate the feature with zero regressions. ## Implementation Evidence -Implementation and both final review axes are complete. The spec remains `in_progress` only because -the local live database/SpiceDB validation environment is not in the required migrated, -least-privilege state. +Implementation and both final review axes are complete. The spec remains `in_progress` only because the local live database/SpiceDB validation environment is not in the required migrated, least-privilege state. -- Generated all seven Core identity Actions with the mandatory Codesmith commands before adapting - them, and generated both Core and Auth migrations with the repository generators. +- Generated all seven Core identity Actions with the mandatory Codesmith commands before adapting them, and generated both Core and Auth migrations with the repository generators. - `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — passed, 58/58 tests. - `mise exec -- pnpm --filter @app/shared-contracts test:unit` — passed, 5/5 tests. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed, 130/130 tests. -- `mise exec -- pnpm check` — passed, including formatting, lint, Action tests, typechecking, skills, - API/database/module boundaries, contracts, and performance readiness. -- `mise exec -- pnpm build` — passed, including the stricter TS-Go BFF compile, deployment output, - Module Federation types, and performance readiness. +- `mise exec -- pnpm check` — passed, including formatting, lint, Action tests, typechecking, skills, API/database/module boundaries, contracts, and performance readiness. +- `mise exec -- pnpm build` — passed, including the stricter TS-Go BFF compile, deployment output, Module Federation types, and performance readiness. - `git diff --check` — passed. - Fresh spec review after the fix/review loop — no P0-P2 findings. - Fresh `AGENTS.md` and referenced-standards review after the fix/review loop — no P0-P2 findings. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — attempted; database-backed cases are - blocked by the local environment (`DATABASE_ADMIN_URL` is unavailable and the configured local - SpiceDB pre-shared key is rejected). Non-environmental tests completed cleanly before the blocked - run was stopped. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — attempted; 2/6 passed and the - remaining cases are blocked by the unapplied local Auth migration or missing - `DATABASE_ADMIN_URL`. -- `mise exec -- pnpm db:verify` — attempted; blocked because the configured runtime database role is - currently a superuser/has `BYPASSRLS`, which intentionally fails the least-privilege verifier. - -To finish validation, apply the generated Core/Auth migrations to the disposable integration -database, provide its administrator connection to the existing test harness, configure the matching -local SpiceDB key, and rerun the three blocked commands in their documented order. No application -code finding remains open. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — attempted; database-backed cases are blocked by the local environment (`DATABASE_ADMIN_URL` is unavailable and the configured local SpiceDB pre-shared key is rejected). Non-environmental tests completed cleanly before the blocked run was stopped. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — attempted; 2/6 passed and the remaining cases are blocked by the unapplied local Auth migration or missing `DATABASE_ADMIN_URL`. +- `mise exec -- pnpm db:verify` — attempted; blocked because the configured runtime database role is currently a superuser/has `BYPASSRLS`, which intentionally fails the least-privilege verifier. + +To finish validation, apply the generated Core/Auth migrations to the disposable integration database, provide its administrator connection to the existing test harness, configure the matching local SpiceDB key, and rerun the three blocked commands in their documented order. No application code finding remains open. ## Notes -- Decisions fixed in planning: implement every documented mode, route raw API keys only through - Shell/Core, include complete backend lifecycle but no UI, support human and dedicated - service/integration keys, omit Better Auth organizations, map one key to exactly one tenant and - principal, let any authorized tenant administrator manage a managed key, preserve tenant-local - support administrators, and allow impersonated writes. -- Better Auth API-key ownership is provider context, not OntOS actor identity. For managed keys the - issuing human is Better Auth's owner, while the bound service/integration principal is the actor - recorded and authorized for API calls. -- Better Auth's API-key plugin supports create, verify, update, expiration, enabled state, counters, - rate limiting, and user ownership. Its user-scoped delete/list endpoints are not exposed; the - Shell Auth owner may use typed owner-local persistence for authorized cross-admin metadata and - disable/cleanup, consistent with the provider's documented owner-independent administration - guidance. -- Better Auth's Admin plugin stores the original provider user ID on the impersonation session. - Core resolves that ID to a tenant-local original administrator on every Shell session read but - persists only the OntOS principal ID and safe session reference in operation evidence. -- The target user's Core binding is `auth_binding_id` during impersonation because it must match the - effective principal. The original administrator is represented by - `impersonated_by_principal_id`; no second binding column or fake impersonation binding is added. -- Authentication lifecycle operations are Shell-owned mechanics under the explicit exception in - `docs/architecture/ACTIONS.md`. Creating/changing a Core principal, Core binding, or Core audit - checkpoint is not exempt and always uses a generated Action. -- `human` remains the physical V0 principal kind for internal users, external operator users, and - guests. Their distinctions belong in authorization roles and future Party/domain relationships, - not additional authentication modes in this feature. -- System operations may use a `system` principal or an explicitly configured `service` principal - as documented in `README.md`. They never create a Better Auth binding and are not callable - through the public gateway. -- Existing Action permission behavior allows an unconfigured Action for compatibility. Every new - sensitive identity Action must have a restriction marker and explicit executor provisioning so - that compatibility behavior is never its production authorization posture. -- API-key issuance cannot replay a raw one-time secret after a lost client response. A retry may - issue another key; authorized metadata listing and revocation make the unreachable key - recoverable without exposing its hash. Core binding Actions remain idempotent by stable provider - key ID. +- Decisions fixed in planning: implement every documented mode, route raw API keys only through Shell/Core, include complete backend lifecycle but no UI, support human and dedicated service/integration keys, omit Better Auth organizations, map one key to exactly one tenant and principal, let any authorized tenant administrator manage a managed key, preserve tenant-local support administrators, and allow impersonated writes. +- Better Auth API-key ownership is provider context, not OntOS actor identity. For managed keys the issuing human is Better Auth's owner, while the bound service/integration principal is the actor recorded and authorized for API calls. +- Better Auth's API-key plugin supports create, verify, update, expiration, enabled state, counters, rate limiting, and user ownership. Its user-scoped delete/list endpoints are not exposed; the Shell Auth owner may use typed owner-local persistence for authorized cross-admin metadata and disable/cleanup, consistent with the provider's documented owner-independent administration guidance. +- Better Auth's Admin plugin stores the original provider user ID on the impersonation session. Core resolves that ID to a tenant-local original administrator on every Shell session read but persists only the OntOS principal ID and safe session reference in operation evidence. +- The target user's Core binding is `auth_binding_id` during impersonation because it must match the effective principal. The original administrator is represented by `impersonated_by_principal_id`; no second binding column or fake impersonation binding is added. +- Authentication lifecycle operations are Shell-owned mechanics under the explicit exception in `docs/architecture/ACTIONS.md`. Creating/changing a Core principal, Core binding, or Core audit checkpoint is not exempt and always uses a generated Action. +- `human` remains the physical V0 principal kind for internal users, external operator users, and guests. Their distinctions belong in authorization roles and future Party/domain relationships, not additional authentication modes in this feature. +- System operations may use a `system` principal or an explicitly configured `service` principal as documented in `README.md`. They never create a Better Auth binding and are not callable through the public gateway. +- Existing Action permission behavior allows an unconfigured Action for compatibility. Every new sensitive identity Action must have a restriction marker and explicit executor provisioning so that compatibility behavior is never its production authorization posture. +- API-key issuance cannot replay a raw one-time secret after a lost client response. A retry may issue another key; authorized metadata listing and revocation make the unreachable key recoverable without exposing its hash. Core binding Actions remain idempotent by stable provider key ID. - No unresolved product or architecture decision blocks implementation. diff --git a/app/specs/feature-complete-protected-entrypoint-authorization.md b/app/specs/feature-complete-protected-entrypoint-authorization.md index 16bf625b2..a5a43ae5e 100644 --- a/app/specs/feature-complete-protected-entrypoint-authorization.md +++ b/app/specs/feature-complete-protected-entrypoint-authorization.md @@ -14,9 +14,7 @@ This plan complements PR #315. PR #315 supplies the Action permission relation, ## User Story -As an OntOS security and deployment operator -I want every protected entrypoint to declare and prove its authorization policy before production enforcement -So that missing configuration cannot silently grant access and the fail-closed migration can be measured, reviewed, and promoted without breaking legitimate traffic +As an OntOS security and deployment operator I want every protected entrypoint to declare and prove its authorization policy before production enforcement So that missing configuration cannot silently grant access and the fail-closed migration can be measured, reviewed, and promoted without breaking legitimate traffic ## Problem Statement diff --git a/app/specs/feature-complete-shell-runtime-composition.md b/app/specs/feature-complete-shell-runtime-composition.md index fc94509f8..da9c47f6d 100644 --- a/app/specs/feature-complete-shell-runtime-composition.md +++ b/app/specs/feature-complete-shell-runtime-composition.md @@ -14,9 +14,7 @@ The feature covers the complete Shell-owned surface: responsive layout, tenant a ## User Story -As an authenticated OntOS operator -I want the Shell to compose navigation, modules, resources, search, timelines, and media tools for my selected tenant and legal entity -So that I can move safely across independently deployed business capabilities without seeing or loading code and data that are inactive, inaccessible, or outside my current company context +As an authenticated OntOS operator I want the Shell to compose navigation, modules, resources, search, timelines, and media tools for my selected tenant and legal entity So that I can move safely across independently deployed business capabilities without seeing or loading code and data that are inactive, inaccessible, or outside my current company context ## Problem Statement diff --git a/app/specs/feature-coresdk-tenant-legal-entity-isolation.md b/app/specs/feature-coresdk-tenant-legal-entity-isolation.md index c4b5fdde1..bc2eb4505 100644 --- a/app/specs/feature-coresdk-tenant-legal-entity-isolation.md +++ b/app/specs/feature-coresdk-tenant-legal-entity-isolation.md @@ -8,91 +8,44 @@ created: 2026-08-07 ## Feature Description -Make tenant and legal-entity isolation a CoreSDK invariant for every governed write and read. The -current Shell resolves an active tenant and authorized legal entity correctly, but the generic -Action runtime accepts a caller-provided `TrustedPrincipalContext`, validates only its shape, and -gives handlers a general Drizzle CRUD surface. Public Shell reads perform explicit authorization -but do not run through a reusable read/evidence runtime. A forgotten predicate can therefore still -become a tenant leak. +Make tenant and legal-entity isolation a CoreSDK invariant for every governed write and read. The current Shell resolves an active tenant and authorized legal entity correctly, but the generic Action runtime accepts a caller-provided `TrustedPrincipalContext`, validates only its shape, and gives handlers a general Drizzle CRUD surface. Public Shell reads perform explicit authorization but do not run through a reusable read/evidence runtime. A forgotten predicate can therefore still become a tenant leak. Add defense in depth at four layers: -1. Every Action and governed read explicitly declares whether legal-entity context is `required`, - `optional`, or `forbidden` in addition to the existing tenant/system entrypoint scope. -2. CoreSDK revalidates the tenant, principal, optional auth binding, legal entity, active statuses, - same-tenant relationship, and SpiceDB legal-entity access before private code can execute. -3. Private handlers receive only owner-local transaction-scoped services. They do not receive raw - Drizzle CRUD methods or a global database service. Tenant-scoped business tables use PostgreSQL - row-level security (RLS) under a least-privilege runtime role as the final backstop. -4. Public reads, lists, searches, downloads, reports, and exports run through a typed Core read - runtime that owns context, module state, authorization, Policy, transaction scope, result - decoding, and durable allowed/denied data-access evidence. - -The implementation must preserve MicroVertical ownership. Core supplies the execution protocol and -opaque transaction capability; each MicroVertical continues to own its schema, migrations, -repositories, repository factory, handlers, and independently deployable BFF. +1. Every Action and governed read explicitly declares whether legal-entity context is `required`, `optional`, or `forbidden` in addition to the existing tenant/system entrypoint scope. +2. CoreSDK revalidates the tenant, principal, optional auth binding, legal entity, active statuses, same-tenant relationship, and SpiceDB legal-entity access before private code can execute. +3. Private handlers receive only owner-local transaction-scoped services. They do not receive raw Drizzle CRUD methods or a global database service. Tenant-scoped business tables use PostgreSQL row-level security (RLS) under a least-privilege runtime role as the final backstop. +4. Public reads, lists, searches, downloads, reports, and exports run through a typed Core read runtime that owns context, module state, authorization, Policy, transaction scope, result decoding, and durable allowed/denied data-access evidence. + +The implementation must preserve MicroVertical ownership. Core supplies the execution protocol and opaque transaction capability; each MicroVertical continues to own its schema, migrations, repositories, repository factory, handlers, and independently deployable BFF. ## User Story -As an authenticated OntOS user -I want every operation to see and modify only data belonging to my resolved tenant and selected legal entity -So that another customer's or legal entity's data cannot leak because an individual handler forgot a predicate +As an authenticated OntOS user I want every operation to see and modify only data belonging to my resolved tenant and selected legal entity So that another customer's or legal entity's data cannot leak because an individual handler forgot a predicate ## Problem Statement -The current code proves the interactive Shell selection flow but not the platform invariant required -by `../docs/09_AUTHN_AUTHZ_MODEL.md` and -`../docs/evidence/mvp/22_MVP2_CORESDK_IMPLEMENTATION_REQUIREMENTS.md`: - -- `TrustedPrincipalContext.legalEntityId` is optional without an operation-level declaration saying - whether it must or must not be present. -- `ActionRuntime` schema-decodes trusted IDs but does not authoritatively recheck their persisted - tenant relationship, active state, or legal-entity permission. -- `ActionHandlerContext.transaction` exposes unrestricted `select`, `insert`, `update`, `delete`, - and relational `query`; omitting a tenant/legal-entity predicate remains possible. -- arbitrary Action Effect requirements can include a database service, and repository checks do not - currently reject that bypass. -- Core rows that carry both `tenant_id` and a foreign identifier generally use independent foreign - keys rather than composite same-tenant foreign keys. +The current code proves the interactive Shell selection flow but not the platform invariant required by `../docs/09_AUTHN_AUTHZ_MODEL.md` and `../docs/evidence/mvp/22_MVP2_CORESDK_IMPLEMENTATION_REQUIREMENTS.md`: + +- `TrustedPrincipalContext.legalEntityId` is optional without an operation-level declaration saying whether it must or must not be present. +- `ActionRuntime` schema-decodes trusted IDs but does not authoritatively recheck their persisted tenant relationship, active state, or legal-entity permission. +- `ActionHandlerContext.transaction` exposes unrestricted `select`, `insert`, `update`, `delete`, and relational `query`; omitting a tenant/legal-entity predicate remains possible. +- arbitrary Action Effect requirements can include a database service, and repository checks do not currently reject that bypass. +- Core rows that carry both `tenant_id` and a foreign identifier generally use independent foreign keys rather than composite same-tenant foreign keys. - the local runtime connects as the Compose-created PostgreSQL superuser, which would bypass RLS. -- Shell resource/search gates do not create standalone `core.data_access_events`, and the current - data-access schema lacks an allowed/denied/failed outcome vocabulary. -- generated search/report provider payloads include caller-visible context fields instead of deriving - identity from a verified server-side assertion. -- tests prove legal-entity lookup and SpiceDB object qualification, but not that an intentionally - unscoped handler query is blocked by both the CoreSDK capability boundary and PostgreSQL. +- Shell resource/search gates do not create standalone `core.data_access_events`, and the current data-access schema lacks an allowed/denied/failed outcome vocabulary. +- generated search/report provider payloads include caller-visible context fields instead of deriving identity from a verified server-side assertion. +- tests prove legal-entity lookup and SpiceDB object qualification, but not that an intentionally unscoped handler query is blocked by both the CoreSDK capability boundary and PostgreSQL. ## Solution Statement -Introduce one internal `OperationalScope` produced only by CoreSDK after trusted-context -revalidation. Add an explicit `legalEntityScope` declaration to Action and read descriptors. A -`required` operation rejects missing context; `optional` validates it when present; `forbidden` -rejects it when present. Definite mismatches and denials fail before handler resolution, while -database or SpiceDB uncertainty remains a typed retryable failure. - -Replace the handler-facing `ActionTransactionExecutor` with a registration-owned, owner-local -service factory. Core invokes that private factory only after opening its transaction, setting -transaction-local tenant/legal-entity PostgreSQL settings, and completing the locked tenant/module -recheck. The factory may build typed owner repositories over an opaque scoped executor; the handler -receives only the returned services plus collector methods. Core database clients and the scoped -executor are not public handler dependencies, and repository boundary validation rejects direct DB -imports from Actions and BFF handlers. - -Use Drizzle `pgPolicy`/`enableRLS` for expressible policies on tenant-scoped business tables and a -small reviewed migration statement for `FORCE ROW LEVEL SECURITY` if Drizzle Kit cannot express it. -RLS reads `ontos.tenant_id` and optional `ontos.legal_entity_id` set with parameterized -transaction-local `set_config`; missing settings deny all rows. The runtime connection uses a -non-superuser, non-`BYPASSRLS` role. Migration/admin credentials remain separate. Core's global -catalog/outbox infrastructure is not made tenant-RLS-dependent in this increment; it is protected -from business handlers by package/capability boundaries and gains composite same-tenant -constraints wherever tenant-qualified references exist. - -Add a typed read registration/runtime parallel to Actions, without introducing a generic HTTP -endpoint. Owner-specific BFF endpoints call it with a verified session or gateway assertion. It -records metadata-only evidence by default, records definite authorization/Policy denials without -executing the handler, never returns an allowed result until its evidence commits, and does not -store raw queries or result payloads unless the descriptor opts into an already-supported explicit -evidence mode. +Introduce one internal `OperationalScope` produced only by CoreSDK after trusted-context revalidation. Add an explicit `legalEntityScope` declaration to Action and read descriptors. A `required` operation rejects missing context; `optional` validates it when present; `forbidden` rejects it when present. Definite mismatches and denials fail before handler resolution, while database or SpiceDB uncertainty remains a typed retryable failure. + +Replace the handler-facing `ActionTransactionExecutor` with a registration-owned, owner-local service factory. Core invokes that private factory only after opening its transaction, setting transaction-local tenant/legal-entity PostgreSQL settings, and completing the locked tenant/module recheck. The factory may build typed owner repositories over an opaque scoped executor; the handler receives only the returned services plus collector methods. Core database clients and the scoped executor are not public handler dependencies, and repository boundary validation rejects direct DB imports from Actions and BFF handlers. + +Use Drizzle `pgPolicy`/`enableRLS` for expressible policies on tenant-scoped business tables and a small reviewed migration statement for `FORCE ROW LEVEL SECURITY` if Drizzle Kit cannot express it. RLS reads `ontos.tenant_id` and optional `ontos.legal_entity_id` set with parameterized transaction-local `set_config`; missing settings deny all rows. The runtime connection uses a non-superuser, non-`BYPASSRLS` role. Migration/admin credentials remain separate. Core's global catalog/outbox infrastructure is not made tenant-RLS-dependent in this increment; it is protected from business handlers by package/capability boundaries and gains composite same-tenant constraints wherever tenant-qualified references exist. + +Add a typed read registration/runtime parallel to Actions, without introducing a generic HTTP endpoint. Owner-specific BFF endpoints call it with a verified session or gateway assertion. It records metadata-only evidence by default, records definite authorization/Policy denials without executing the handler, never returns an allowed result until its evidence commits, and does not store raw queries or result payloads unless the descriptor opts into an already-supported explicit evidence mode. ## Relevant Files @@ -164,29 +117,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Document and encode the two-dimensional operation scope: existing entrypoint `tenant`/`system` -scope plus explicit legal-entity `required`/`optional`/`forbidden` scope. Introduce a Core-owned -context validator that turns an authenticated assertion/session principal into immutable -OperationalScope only after exact persisted tenant/principal/legal-entity checks and SpiceDB access. -Add composite same-tenant constraints to Core and separate runtime database credentials from -admin/migration credentials so later RLS tests cannot pass through a superuser bypass. +Document and encode the two-dimensional operation scope: existing entrypoint `tenant`/`system` scope plus explicit legal-entity `required`/`optional`/`forbidden` scope. Introduce a Core-owned context validator that turns an authenticated assertion/session principal into immutable OperationalScope only after exact persisted tenant/principal/legal-entity checks and SpiceDB access. Add composite same-tenant constraints to Core and separate runtime database credentials from admin/migration credentials so later RLS tests cannot pass through a superuser bypass. ### Phase 2: Core Implementation -Remove the raw Drizzle executor from handlers. Store each owner-local service factory privately in -its Action/read registration and invoke it only within a Core transaction after transaction-local -scope is installed. Add reusable Drizzle RLS helpers and catalog verification for owner business -tables. Implement the governed read runtime and extend `data_access_events` so allowed and definite -denied reads have durable, sanitized evidence independent of Action invocations. +Remove the raw Drizzle executor from handlers. Store each owner-local service factory privately in its Action/read registration and invoke it only within a Core transaction after transaction-local scope is installed. Add reusable Drizzle RLS helpers and catalog verification for owner business tables. Implement the governed read runtime and extend `data_access_events` so allowed and definite denied reads have durable, sanitized evidence independent of Action invocations. ### Phase 3: Integration -Update Codesmith before changing generated business artifacts. Generated Actions explicitly choose -legal-entity scope and use scoped services. Generated module API/search/report BFFs remove identity -from payloads, verify the existing audience-scoped Shell assertion, and call the Core read runtime. -Compose current Shell search/detail orchestration through that runtime, while keeping media -attachment unavailable until it is backed by a generated Action. Add boundary checks, live leakage -tests, and repository quality gates. +Update Codesmith before changing generated business artifacts. Generated Actions explicitly choose legal-entity scope and use scoped services. Generated module API/search/report BFFs remove identity from payloads, verify the existing audience-scoped Shell assertion, and call the Core read runtime. Compose current Shell search/detail orchestration through that runtime, while keeping media attachment unavailable until it is backed by a generated Action. Add boundary checks, live leakage tests, and repository quality gates. ## Step by Step Tasks @@ -285,21 +224,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test legal-entity scope declarations, OperationalScope classification, stale and mismatched context, -typed error sanitization, private registration storage, absence of raw transaction methods, RLS -setting construction, read lifecycle ordering, Policy/permission fail-closed behavior, evidence -materialization, result decoding, generator output, and static database-boundary diagnostics. Existing -Shell legal-entity selection tests remain and should prove that the new shared validator does not -change one/many/zero selection behavior. +Test legal-entity scope declarations, OperationalScope classification, stale and mismatched context, typed error sanitization, private registration storage, absence of raw transaction methods, RLS setting construction, read lifecycle ordering, Policy/permission fail-closed behavior, evidence materialization, result decoding, generator output, and static database-boundary diagnostics. Existing Shell legal-entity selection tests remain and should prove that the new shared validator does not change one/many/zero selection behavior. ### Integration Tests -Use live PostgreSQL and SpiceDB. Connect migrations/fixtures with the admin identity and exercise -application paths with the least-privilege runtime identity. Prove composite same-tenant foreign -keys, forced RLS, transaction-local scope reset, Action rollback/commit, standalone read evidence, -definite denial evidence, assertion verification, receiving-deployment reauthorization, and -cross-tenant/entity isolation with colliding resource IDs. Complete the current Shell integration -tests with a generated disposable owner fixture; no production demo vertical is required. +Use live PostgreSQL and SpiceDB. Connect migrations/fixtures with the admin identity and exercise application paths with the least-privilege runtime identity. Prove composite same-tenant foreign keys, forced RLS, transaction-local scope reset, Action rollback/commit, standalone read evidence, definite denial evidence, assertion verification, receiving-deployment reauthorization, and cross-tenant/entity isolation with colliding resource IDs. Complete the current Shell integration tests with a generated disposable owner fixture; no production demo vertical is required. ### Edge Cases diff --git a/app/specs/feature-crm-ares-adapter.md b/app/specs/feature-crm-ares-adapter.md index 970bb7eb0..136003ebc 100644 --- a/app/specs/feature-crm-ares-adapter.md +++ b/app/specs/feature-crm-ares-adapter.md @@ -8,28 +8,19 @@ created: 2026-08-17 ## Feature Description -Add a private CRM-owned Effect adapter for the Czech ARES consolidated economic-subject endpoint. -The adapter normalizes one valid IČO into Customer-compatible business fields and deliberately drops -all address, provenance, CZ-NACE, and activity data. +Add a private CRM-owned Effect adapter for the Czech ARES consolidated economic-subject endpoint. The adapter normalizes one valid IČO into Customer-compatible business fields and deliberately drops all address, provenance, CZ-NACE, and activity data. ## User Story -As the CRM ARES lookup API -I want a typed and resilient server-side ARES adapter -So that upstream transport and schema details never leak into Customer UI or contracts +As the CRM ARES lookup API I want a typed and resilient server-side ARES adapter So that upstream transport and schema details never leak into Customer UI or contracts ## Problem Statement -ARES is an external public service with documented input, availability, and blocking conditions. -Calling it directly from a browser would duplicate validation and couple the UI to unstable CORS, -raw Czech field names, upstream errors, and response evolution. +ARES is an external public service with documented input, availability, and blocking conditions. Calling it directly from a browser would duplicate validation and couple the UI to unstable CORS, raw Czech field names, upstream errors, and response evolution. ## Solution Statement -Create a private `verticals/crm/src/integrations/ares/` Effect service using the repository's Effect -HTTP facilities. Decode only the consolidated response fields required by Customer, map them to the -canonical names, apply one request per valid IČO with timeout and bounded retry, and expose a closed -internal error union for the governed lookup read. +Create a private `verticals/crm/src/integrations/ares/` Effect service using the repository's Effect HTTP facilities. Decode only the consolidated response fields required by Customer, map them to the canonical names, apply one request per valid IČO with timeout and bounded retry, and expose a closed internal error union for the governed lookup read. ## Relevant Files @@ -51,18 +42,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Define internal raw ARES response codecs, a Customer-prefill value, and tagged failures without -publishing them as a module API or importing browser/framework concerns. +Define internal raw ARES response codecs, a Customer-prefill value, and tagged failures without publishing them as a module API or importing browser/framework concerns. ### Phase 2: Core Implementation -Implement the exact consolidated GET, safe URL construction, timeout, cache/coalescing, concurrency -limit, and retry/error classification in Effect. +Implement the exact consolidated GET, safe URL construction, timeout, cache/coalescing, concurrency limit, and retry/error classification in Effect. ### Phase 3: Integration -Verify mapping and resilience with deterministic fake HTTP responses and document operational -limits without requiring live ARES in automated tests. +Verify mapping and resilience with deterministic fake HTTP responses and document operational limits without requiring live ARES in automated tests. ## Step by Step Tasks @@ -96,13 +84,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Substitute the Effect HTTP client and a test clock to prove request construction, decoding, failure -classification, retry timing, cache/coalescing, and cancellation deterministically. +Substitute the Effect HTTP client and a test clock to prove request construction, decoding, failure classification, retry timing, cache/coalescing, and cancellation deterministically. ### Integration Tests -Not required for this task: the following governed BFF spec provides the cross-boundary integration -test. Automated validation must not depend on ARES availability. +Not required for this task: the following governed BFF spec provides the cross-boundary integration test. Automated validation must not depend on ARES availability. ### Edge Cases diff --git a/app/specs/feature-crm-ares-lookup-bff.md b/app/specs/feature-crm-ares-lookup-bff.md index cf83b12b0..5bb66f20d 100644 --- a/app/specs/feature-crm-ares-lookup-bff.md +++ b/app/specs/feature-crm-ares-lookup-bff.md @@ -8,29 +8,19 @@ created: 2026-08-17 ## Feature Description -Expose one CRM-owned, generated Effect module API read that accepts an eight-digit IČO and returns -flat Customer-compatible values from the private ARES adapter. The operation is a governed read, -not an Action, because it does not mutate OntOS state. +Expose one CRM-owned, generated Effect module API read that accepts an eight-digit IČO and returns flat Customer-compatible values from the private ARES adapter. The operation is a governed read, not an Action, because it does not mutate OntOS state. ## User Story -As the Customer create feature -I want to look up a Czech business through the generated CRM BFF client -So that the browser never calls ARES or a private backend implementation directly +As the Customer create feature I want to look up a Czech business through the generated CRM BFF client So that the browser never calls ARES or a private backend implementation directly ## Problem Statement -The private adapter alone is not a legal frontend boundary. OntOS requires every module API to use -the generated descriptor, Read runtime, registered server, shared HttpApi contract, and generated -Effect client with typed errors. +The private adapter alone is not a legal frontend boundary. OntOS requires every module API to use the generated descriptor, Read runtime, registered server, shared HttpApi contract, and generated Effect client with typed errors. ## Solution Statement -Generate `customer-ares-lookup` with Codesmith before editing any API files. Adapt its request, -response, read handler, server mapping, registration, and client to use the private adapter. Publish -only invalid/authentication/forbidden/not-found/unavailable/internal Problem Details supported by -the current governed Read runtime; retain upstream timeout/throttling distinctions in internal -diagnostics while exposing them safely as retryable unavailability. +Generate `customer-ares-lookup` with Codesmith before editing any API files. Adapt its request, response, read handler, server mapping, registration, and client to use the private adapter. Publish only invalid/authentication/forbidden/not-found/unavailable/internal Problem Details supported by the current governed Read runtime; retain upstream timeout/throttling distinctions in internal diagnostics while exposing them safely as retryable unavailability. ## Relevant Files @@ -59,18 +49,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Run the mandatory module API generator and retain all generated identities and wiring before adapting -the skeleton contract. +Run the mandatory module API generator and retain all generated identities and wiring before adapting the skeleton contract. ### Phase 2: Core Implementation -Connect the governed read to the private adapter, normalize the response to flat Customer fields, -and exhaustively map all Read/runtime/integration failures. +Connect the governed read to the private adapter, normalize the response to flat Customer fields, and exhaustively map all Read/runtime/integration failures. ### Phase 3: Integration -Prove the generated client reaches the registered read through the real BFF and never exposes raw -ARES JSON, address, or private implementation types. +Prove the generated client reaches the registered read through the real BFF and never exposes raw ARES JSON, address, or private implementation types. ## Step by Step Tasks @@ -109,13 +96,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Verify exact generated identities, schema decoding, public errors, adapter-error mapping, and complete -registration/API composition. +Verify exact generated identities, schema decoding, public errors, adapter-error mapping, and complete registration/API composition. ### Integration Tests -Run the generated client against the CRM Effect BFF and governed Read runtime with a deterministic -ARES layer, proving authentication, module access, evidence, typed errors, and response decoding. +Run the generated client against the CRM Effect BFF and governed Read runtime with a deterministic ARES layer, proving authentication, module access, evidence, typed errors, and response decoding. ### Edge Cases @@ -161,30 +146,19 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Generated the CRM `customer-ares-lookup` module API with Codesmith, then adapted its contract, - governed Read, server, generated Effect client, manifest, private registration, and CRM client seam. -- Connected the owner-private ARES service through a substitutable Effect layer and exposed only the - flat Customer-compatible result with the required sanitized Problem Details union. -- Added unit and real BFF/Read-runtime integration coverage for success, authorization, evidence, - correlation, validation, provider failures, decoding defects, and information-leak prevention. +- Generated the CRM `customer-ares-lookup` module API with Codesmith, then adapted its contract, governed Read, server, generated Effect client, manifest, private registration, and CRM client seam. +- Connected the owner-private ARES service through a substitutable Effect layer and exposed only the flat Customer-compatible result with the required sanitized Problem Details union. +- Added unit and real BFF/Read-runtime integration coverage for success, authorization, evidence, correlation, validation, provider failures, decoding defects, and information-leak prevention. ### Changed Files -- 13 files changed, 1,092 insertions, 0 deletions: CRM API/runtime/client wiring, four generated - module-API files, manifest and private registration, two new CRM test files, one existing - API-contract test, and this plan. +- 13 files changed, 1,092 insertions, 0 deletions: CRM API/runtime/client wiring, four generated module-API files, manifest and private registration, two new CRM test files, one existing API-contract test, and this plan. ### Tests Written or Updated -- `verticals/crm/tests/unit/customer-ares-lookup.test.ts` — proves the exact descriptor, codecs, - public status union, complete adapter-error mapping, correlation/evidence, generated publication, - and absence of an ARES Action. -- `verticals/crm/tests/integration/customer-ares-lookup-bff.test.ts` — proves the generated client - through the real CRM BFF and governed Read runtime for success, invalid input, authentication, - permission denial, not found, retryable unavailability, sanitized internal failure, correlation, - and durable metadata-only evidence with a substituted ARES service. -- `verticals/crm/tests/unit/customer-contact-api-contract.test.ts` — extends the exact CRM operation - surface with `lookupCustomerAres`. +- `verticals/crm/tests/unit/customer-ares-lookup.test.ts` — proves the exact descriptor, codecs, public status union, complete adapter-error mapping, correlation/evidence, generated publication, and absence of an ARES Action. +- `verticals/crm/tests/integration/customer-ares-lookup-bff.test.ts` — proves the generated client through the real CRM BFF and governed Read runtime for success, invalid input, authentication, permission denial, not found, retryable unavailability, sanitized internal failure, correlation, and durable metadata-only evidence with a substituted ARES service. +- `verticals/crm/tests/unit/customer-contact-api-contract.test.ts` — extends the exact CRM operation surface with `lookupCustomerAres`. ### Validation @@ -194,32 +168,17 @@ Execute every command to validate the feature with zero regressions. - `mise exec -- pnpm api:check` — passed. - `mise exec -- pnpm module-entrypoints:check` — passed. - `mise exec -- pnpm check:module-contracts` — passed. -- `mise exec -- pnpm --filter @app/crm build` — the dirty implementation worktree correctly refused - a promotable envelope with `sourceRevision "workspace"`; the same source in an isolated clean - snapshot passed the complete CRM build and Node deployment package with an explicit Git revision. -- `mise exec -- pnpm check` — passed, including format, lint, Core Action tests, type checking, - skills, i18n/API/database/module-entrypoint/module-contract/workspace checks, and performance readiness. -- `mise exec -- pnpm build` — the dirty implementation worktree stopped at the same provenance - guard; the source-equivalent clean snapshot passed the complete CRM and Shell build, deployment - packaging, Module Federation type assertion, and performance readiness. +- `mise exec -- pnpm --filter @app/crm build` — the dirty implementation worktree correctly refused a promotable envelope with `sourceRevision "workspace"`; the same source in an isolated clean snapshot passed the complete CRM build and Node deployment package with an explicit Git revision. +- `mise exec -- pnpm check` — passed, including format, lint, Core Action tests, type checking, skills, i18n/API/database/module-entrypoint/module-contract/workspace checks, and performance readiness. +- `mise exec -- pnpm build` — the dirty implementation worktree stopped at the same provenance guard; the source-equivalent clean snapshot passed the complete CRM and Shell build, deployment packaging, Module Federation type assertion, and performance readiness. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, - `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, - `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, - `docs/architecture/MODULE_MANIFESTS.md`, `docs/architecture/DATA_ACCESS.md`, - `docs/integrations/ares.md`, and both dependency specifications. -- Final review confirmed the generated Effect client remains the only public seam, the private ARES - adapter remains owner-local, the operation is a metadata-evidenced governed Read rather than an - Action, and every public failure is declared, status-matched, typed, and sanitized. -- Fixed the review findings surfaced by the repository gate: switch-case style, Effect-catch lint - annotation, type-only imports, Promise callback structure, and sequential test assertions. -- No UI/browser review or screenshots were applicable because this specification changes only the - CRM BFF/read boundary. +- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, `docs/architecture/MODULE_MANIFESTS.md`, `docs/architecture/DATA_ACCESS.md`, `docs/integrations/ares.md`, and both dependency specifications. +- Final review confirmed the generated Effect client remains the only public seam, the private ARES adapter remains owner-local, the operation is a metadata-evidenced governed Read rather than an Action, and every public failure is declared, status-matched, typed, and sanitized. +- Fixed the review findings surfaced by the repository gate: switch-case style, Effect-catch lint annotation, type-only imports, Promise callback structure, and sequential test assertions. +- No UI/browser review or screenshots were applicable because this specification changes only the CRM BFF/read boundary. ### Deviations and Follow-ups -- Promotable release envelopes intentionally require a clean Git tree. Because this implementation - may not create a commit, build validation used a source-equivalent clean snapshot with the current - HEAD revision; no product-code bypass or build-configuration change was introduced. +- Promotable release envelopes intentionally require a clean Git tree. Because this implementation may not create a commit, build validation used a source-equivalent clean snapshot with the current HEAD revision; no product-code bypass or build-configuration change was introduced. diff --git a/app/specs/feature-crm-contact-create-page.md b/app/specs/feature-crm-contact-create-page.md index 45d526ade..fec5f6c54 100644 --- a/app/specs/feature-crm-contact-create-page.md +++ b/app/specs/feature-crm-contact-create-page.md @@ -8,71 +8,27 @@ created: 2026-08-16 ## Feature Description -Add the generated CRM `ContactCreate` page at localized URL -`/cs/crm/customers/:id/contacts/new` (canonical generator URL -`/crm/customers/:id/contacts/new`, also exposed under `/en`). The authenticated Shell continues to -own dashboard composition, legal-entity selection, exact page resolution, module-state gating, and -the lazy CRM remote load. The CRM-owned page treats `:id` as the untrusted parent Customer UUID, -validates it at the owner boundary, renders an empty Contact form, and submits `{ customerId, name, -email, phone }` through the existing generated `createContact` Effect client. The CRM BFF endpoint -must execute the existing `CreateContactAction`; the page must not import or call the Action, -persistence service, backend handler, or HTTP endpoint directly. - -Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame -`Resource Detail — Běžný` (`6:780`, 1440×900) only as an arrangement wireframe. Preserve the -Shell-owned left navigation, compact Back link, page heading, and single main content surface, but -replace the read-only detail rows with inputs. Do not copy Figma styling or add the example's inert -Overview/Documents/Timeline/Audit tabs. Use the installed `@techsio/ui-kit` components and tokens, -with CRM-prefixed Tailwind utilities only for responsive layout composition. - -Create a separate owner-private `ContactForm` presentation component for the editable `name`, -`email`, and `phone` values so a later Contact-edit page can reuse the same field, validation, -pending, field-error, form-status, cancel, and submit contract. It receives plain values/states and -semantic callbacks; it does not read route params, navigate, call the BFF, run Effects, access -permissions, or depend on Contact DTO/client-error types. +Add the generated CRM `ContactCreate` page at localized URL `/cs/crm/customers/:id/contacts/new` (canonical generator URL `/crm/customers/:id/contacts/new`, also exposed under `/en`). The authenticated Shell continues to own dashboard composition, legal-entity selection, exact page resolution, module-state gating, and the lazy CRM remote load. The CRM-owned page treats `:id` as the untrusted parent Customer UUID, validates it at the owner boundary, renders an empty Contact form, and submits `{ customerId, name, email, phone }` through the existing generated `createContact` Effect client. The CRM BFF endpoint must execute the existing `CreateContactAction`; the page must not import or call the Action, persistence service, backend handler, or HTTP endpoint directly. + +Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame `Resource Detail — Běžný` (`6:780`, 1440×900) only as an arrangement wireframe. Preserve the Shell-owned left navigation, compact Back link, page heading, and single main content surface, but replace the read-only detail rows with inputs. Do not copy Figma styling or add the example's inert Overview/Documents/Timeline/Audit tabs. Use the installed `@techsio/ui-kit` components and tokens, with CRM-prefixed Tailwind utilities only for responsive layout composition. + +Create a separate owner-private `ContactForm` presentation component for the editable `name`, `email`, and `phone` values so a later Contact-edit page can reuse the same field, validation, pending, field-error, form-status, cancel, and submit contract. It receives plain values/states and semantic callbacks; it does not read route params, navigate, call the BFF, run Effects, access permissions, or depend on Contact DTO/client-error types. ## User Story -As an authenticated CRM user with write access -I want to add a Contact to a specific Customer from a dedicated localized page -So that the Contact is created through the governed CRM Action boundary and remains associated with -the intended Customer +As an authenticated CRM user with write access I want to add a Contact to a specific Customer from a dedicated localized page So that the Contact is created through the governed CRM Action boundary and remains associated with the intended Customer ## Problem Statement -CRM already owns Contact persistence, `CreateContactAction`, the strict Effect BFF mutation, and the -generated `createContact` client, but it has no governed page for entering a Contact. Direct endpoint -use would bypass the intended frontend seam and provide no localized, accessible validation, -pending, denial, conflict, retry, or success experience. The dynamic nested route also requires -Codesmith-owned manifest, registration, federation, Shell connector, route-parameter, and locale -wiring before the business UI can be adapted safely. Without a presentation boundary, the later -Contact-edit page would duplicate the same three fields and their interaction behavior. +CRM already owns Contact persistence, `CreateContactAction`, the strict Effect BFF mutation, and the generated `createContact` client, but it has no governed page for entering a Contact. Direct endpoint use would bypass the intended frontend seam and provide no localized, accessible validation, pending, denial, conflict, retry, or success experience. The dynamic nested route also requires Codesmith-owned manifest, registration, federation, Shell connector, route-parameter, and locale wiring before the business UI can be adapted safely. Without a presentation boundary, the later Contact-edit page would duplicate the same three fields and their interaction behavior. ## Solution Statement -Run the mandatory MicroVertical page generator with stable identity `contact-create` and canonical -URL `/crm/customers/:id/contacts/new`. Preserve its private/non-indexable exact-page descriptor, -dynamic non-navigation behavior, owner-private registration, Module Federation exposure, approved -Shell lazy client, and bounded `id` propagation. Adapt the generated CRM page and federation wrapper -to receive the resolved target so write availability remains explicit. Decode the owner-side -`routeParams.id` with `CrmUuidSchema`; an absent, malformed, or overlong value renders a localized -not-found state and never invokes the mutation. - -Create the explicitly requested owner-private `ContactForm` directly after the page scaffold. Use -`FormInput` for name and email, the compound `PhoneInput` for telephone entry, `Button` for -submit/cancel, and `StatusText` for field/form feedback. Keep validation aligned with the existing -Action input schemas: trimmed non-empty name (maximum 200), trimmed email matching the current -3–320-character CRM email contract, and trimmed non-empty phone (maximum 100). Do not enable -`PhoneInput`'s stricter libphonenumber native validation because `CrmPhoneSchema` does not currently -require a valid E.164 number. - -Use the established page-local TanStack Query mutation pattern to bridge `createContact` at the -framework edge, retain its operation-specific typed error union, and map every expected failure into -field or form UI state. Generate one idempotency key per logical `{ customerId, name, email, phone }` -intent, reuse it only after an uncertain failure when all normalized values are unchanged, and -generate a fresh correlation ID for every attempt. On success, navigate to the localized existing -Customer-detail route `/${language}/crm/customers/${customerId}`; Back and Cancel use the same -destination without invoking the Action. +Run the mandatory MicroVertical page generator with stable identity `contact-create` and canonical URL `/crm/customers/:id/contacts/new`. Preserve its private/non-indexable exact-page descriptor, dynamic non-navigation behavior, owner-private registration, Module Federation exposure, approved Shell lazy client, and bounded `id` propagation. Adapt the generated CRM page and federation wrapper to receive the resolved target so write availability remains explicit. Decode the owner-side `routeParams.id` with `CrmUuidSchema`; an absent, malformed, or overlong value renders a localized not-found state and never invokes the mutation. + +Create the explicitly requested owner-private `ContactForm` directly after the page scaffold. Use `FormInput` for name and email, the compound `PhoneInput` for telephone entry, `Button` for submit/cancel, and `StatusText` for field/form feedback. Keep validation aligned with the existing Action input schemas: trimmed non-empty name (maximum 200), trimmed email matching the current 3–320-character CRM email contract, and trimmed non-empty phone (maximum 100). Do not enable `PhoneInput`'s stricter libphonenumber native validation because `CrmPhoneSchema` does not currently require a valid E.164 number. + +Use the established page-local TanStack Query mutation pattern to bridge `createContact` at the framework edge, retain its operation-specific typed error union, and map every expected failure into field or form UI state. Generate one idempotency key per logical `{ customerId, name, email, phone }` intent, reuse it only after an uncertain failure when all normalized values are unchanged, and generate a fresh correlation ID for every attempt. On success, navigate to the localized existing Customer-detail route `/${language}/crm/customers/${customerId}`; Back and Cancel use the same destination without invoking the Action. ## Relevant Files @@ -137,27 +93,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate the exact nested page and all owner/Shell wiring before adapting business code. Verify the -generator retains the canonical route without a locale prefix, carries only `id`, omits dynamic -navigation, and creates stable `contact-create` identities. Then create the explicitly approved -owner-private `ContactForm`, reusing the repository-pinned UI-kit and current CRM test/query setup; -add no backend contract, Action, persistence, dependency, shared component, or token override. +Generate the exact nested page and all owner/Shell wiring before adapting business code. Verify the generator retains the canonical route without a locale prefix, carries only `id`, omits dynamic navigation, and creates stable `contact-create` identities. Then create the explicitly approved owner-private `ContactForm`, reusing the repository-pinned UI-kit and current CRM test/query setup; add no backend contract, Action, persistence, dependency, shared component, or token override. ### Phase 2: Core Implementation -Implement `ContactForm` as a reusable presentation contract using the installed UI-kit field and -feedback components. Adapt the generated ContactCreate page and federation wrapper to validate the -parent ID, honor `target.writable`, render an empty ready form, and submit through the generated -`createContact` Effect client with correct typed error and logical-idempotency behavior. Add focused -tests beside each reusable form and page behavior. +Implement `ContactForm` as a reusable presentation contract using the installed UI-kit field and feedback components. Adapt the generated ContactCreate page and federation wrapper to validate the parent ID, honor `target.writable`, render an empty ready form, and submit through the generated `createContact` Effect client with correct typed error and logical-idempotency behavior. Add focused tests beside each reusable form and page behavior. ### Phase 3: Integration -Complete Czech/English copy, generated manifest/registration/federation/Shell verification, -responsive and keyboard behavior, localized parent navigation, and browser coverage. Reuse the real -CRM BFF/Action integration suites as the authoritative proof that the page's client method reaches -`CreateContactAction` through the strict BFF and governed Action runtime. Finish with independent -CRM/Shell checks, boundary validators, builds, and the final repository quality gate. +Complete Czech/English copy, generated manifest/registration/federation/Shell verification, responsive and keyboard behavior, localized parent navigation, and browser coverage. Reuse the real CRM BFF/Action integration suites as the authoritative proof that the page's client method reaches `CreateContactAction` through the strict BFF and governed Action runtime. Finish with independent CRM/Shell checks, boundary validators, builds, and the final repository quality gate. ## Step by Step Tasks @@ -217,23 +161,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use the existing CRM Node unit tests for Action/API schemas and add Rstest/Testing Library coverage -for `ContactForm` and the generated ContactCreate page. Mock only the generated frontend Effect -client seam in page tests. Prove reusable presentation ownership, Action-aligned field validation, -keyboard/focus/accessibility behavior, valid/invalid parent IDs, write gating, exact -`createContact` payload/options, typed failure mapping, logical idempotency, localized parent -navigation, and absence of forbidden frontend dependencies. Use Shell Rstest coverage for exact page -resolution, bounded `id` propagation, and lazy remote props. +Use the existing CRM Node unit tests for Action/API schemas and add Rstest/Testing Library coverage for `ContactForm` and the generated ContactCreate page. Mock only the generated frontend Effect client seam in page tests. Prove reusable presentation ownership, Action-aligned field validation, keyboard/focus/accessibility behavior, valid/invalid parent IDs, write gating, exact `createContact` payload/options, typed failure mapping, logical idempotency, localized parent navigation, and absence of forbidden frontend dependencies. Use Shell Rstest coverage for exact page resolution, bounded `id` propagation, and lazy remote props. ### Integration Tests -Run the existing CRM integration suites that execute Contact creation through the contract-derived -client/BFF and the real Action runtime. They prove assertion verification, `CreateContactAction` -dispatch, Customer parent lookup, idempotency, tenant/module/write scope, persistence, data-access and -audit evidence, typed Problem Details decoding, rollback, and tenant isolation. Add focused Shell -browser coverage for privacy, form behavior, real page-to-BFF request construction, localized -navigation, and responsive layout while mocking the terminal BFF response to avoid introducing new -Action cleanup responsibilities into the browser fixture. +Run the existing CRM integration suites that execute Contact creation through the contract-derived client/BFF and the real Action runtime. They prove assertion verification, `CreateContactAction` dispatch, Customer parent lookup, idempotency, tenant/module/write scope, persistence, data-access and audit evidence, typed Problem Details decoding, rollback, and tenant isolation. Add focused Shell browser coverage for privacy, form behavior, real page-to-BFF request construction, localized navigation, and responsive layout while mocking the terminal BFF response to avoid introducing new Action cleanup responsibilities into the browser fixture. ### Edge Cases diff --git a/app/specs/feature-crm-contact-detail-page.md b/app/specs/feature-crm-contact-detail-page.md index d65c29bf5..820e9f609 100644 --- a/app/specs/feature-crm-contact-detail-page.md +++ b/app/specs/feature-crm-contact-detail-page.md @@ -8,55 +8,25 @@ created: 2026-08-16 ## Feature Description -Add the generated CRM MicroVertical page `ContactDetail` at canonical route -`/crm/customers/:id/contacts/:contactId`, exposed by the locale-aware Shell as -`/cs/crm/customers/:id/contacts/:contactId` and `/en/crm/customers/:id/contacts/:contactId`. -The authenticated page presents one Contact within its parent Customer using the arrangement from -Figma file `ERP`, page `Pre-Alpha Repo`, frame `Resource Detail — Běžný` (`6:780`, 1440×900): a -compact return link, Contact heading, and responsive overview rows inside the existing Shell -dashboard layout. - -The page must obtain Contact data by executing the existing CRM contract-derived `getContact` -Effect client operation through the CRM BFF. This is the implemented frontend/client spelling of -the requested `GetContactAction`; authoritative OntOS guidance models it as a governed Read because -it does not change state. The page must not generate a new Action, create a duplicate endpoint, -import a backend handler, read CRM persistence directly, or issue an ad hoc `fetch`. - -Figma is a wireframe for component arrangement only. Use the installed `@techsio/ui-kit` components -and tokens without copying Figma colors, spacing, typography, borders, or component styling. Do not -add the wireframe's Documents, Timeline, or Audit tabs because this feature has no corresponding CRM -contracts. +Add the generated CRM MicroVertical page `ContactDetail` at canonical route `/crm/customers/:id/contacts/:contactId`, exposed by the locale-aware Shell as `/cs/crm/customers/:id/contacts/:contactId` and `/en/crm/customers/:id/contacts/:contactId`. The authenticated page presents one Contact within its parent Customer using the arrangement from Figma file `ERP`, page `Pre-Alpha Repo`, frame `Resource Detail — Běžný` (`6:780`, 1440×900): a compact return link, Contact heading, and responsive overview rows inside the existing Shell dashboard layout. + +The page must obtain Contact data by executing the existing CRM contract-derived `getContact` Effect client operation through the CRM BFF. This is the implemented frontend/client spelling of the requested `GetContactAction`; authoritative OntOS guidance models it as a governed Read because it does not change state. The page must not generate a new Action, create a duplicate endpoint, import a backend handler, read CRM persistence directly, or issue an ad hoc `fetch`. + +Figma is a wireframe for component arrangement only. Use the installed `@techsio/ui-kit` components and tokens without copying Figma colors, spacing, typography, borders, or component styling. Do not add the wireframe's Documents, Timeline, or Audit tabs because this feature has no corresponding CRM contracts. ## User Story -As a signed-in CRM user -I want to open a Contact within a Customer URL and see its current details -So that I can verify the Contact's identity, communication data, and lifecycle without leaving the -authenticated CRM workspace +As a signed-in CRM user I want to open a Contact within a Customer URL and see its current details So that I can verify the Contact's identity, communication data, and lifecycle without leaving the authenticated CRM workspace ## Problem Statement -CRM already persists Contacts and exposes an authenticated governed `getContact` BFF read, but it -has no Contact detail page or stable localized deep link. Users cannot inspect one Contact in the -context of its parent Customer, and the application has no page-level mapping for loading, -malformed IDs, parent/Contact mismatches, not-found, forbidden, authentication-expired, transport, -decode, unavailable, or internal failures. +CRM already persists Contacts and exposes an authenticated governed `getContact` BFF read, but it has no Contact detail page or stable localized deep link. Users cannot inspect one Contact in the context of its parent Customer, and the application has no page-level mapping for loading, malformed IDs, parent/Contact mismatches, not-found, forbidden, authentication-expired, transport, decode, unavailable, or internal failures. ## Solution Statement -Run the mandatory MicroVertical page generator with stable page identity `contact-detail` and the -canonical two-parameter URL. Adapt only the generated CRM page to validate both route parameters as -CRM UUIDs, call `getContact({ contactId })` through the generated CRM Effect BFF client, and retain -the operation's typed error union until route integration maps it to a closed presentation model. -Include both IDs in the query key and verify that a successful Contact response has -`customerId === routeParams.id`; render a safe not-found state instead of Contact data when the -hierarchical URL is inconsistent. +Run the mandatory MicroVertical page generator with stable page identity `contact-detail` and the canonical two-parameter URL. Adapt only the generated CRM page to validate both route parameters as CRM UUIDs, call `getContact({ contactId })` through the generated CRM Effect BFF client, and retain the operation's typed error union until route integration maps it to a closed presentation model. Include both IDs in the query key and verify that a successful Contact response has `customerId === routeParams.id`; render a safe not-found state instead of Contact data when the hierarchical URL is inconsistent. -Follow the implemented Customer-detail page's page-local TanStack Query, Effect bridge, UI-kit, -localization, accessibility, and responsive patterns. Link back to the localized parent Customer -detail route. Render only fields present in the existing Contact DTO: Contact ID, Customer ID, -email, phone, lifecycle derived from `archivedAt`, created time, and updated time, with the Contact -name as the heading. +Follow the implemented Customer-detail page's page-local TanStack Query, Effect bridge, UI-kit, localization, accessibility, and responsive patterns. Link back to the localized parent Customer detail route. Render only fields present in the existing Contact DTO: Contact ID, Customer ID, email, phone, lifecycle derived from `archivedAt`, created time, and updated time, with the Contact name as the heading. ## Relevant Files @@ -117,24 +87,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate the two-parameter private Contact-detail page and all Shell/manifest/registration/Module -Federation wiring through Codesmith. Verify the exact stable identities, route template, parameter -order, private metadata, and post-gate prop contract before adapting generated source. +Generate the two-parameter private Contact-detail page and all Shell/manifest/registration/Module Federation wiring through Codesmith. Verify the exact stable identities, route template, parameter order, private metadata, and post-gate prop contract before adapting generated source. ### Phase 2: Core Implementation -Reuse the existing `getContact` Effect BFF client, validate both route UUIDs, create a hierarchical -query key, map the complete client failure union to closed view states, reject a response whose -Customer does not match the URL, and render the Contact DTO using existing UI-kit components and -semantic HTML. Add focused component tests beside each behavior. +Reuse the existing `getContact` Effect BFF client, validate both route UUIDs, create a hierarchical query key, map the complete client failure union to closed view states, reject a response whose Customer does not match the URL, and render the Contact DTO using existing UI-kit components and semantic HTML. Add focused component tests beside each behavior. ### Phase 3: Integration -Verify that Shell authentication, legal-entity selection, module state, page permission, exact -target resolution, and approved remote loading all occur before CRM code or `getContact` executes. -Add Shell and browser coverage for both locales, exact ID propagation, wrong-parent suppression, -normal/loading/not-found/forbidden/unavailable behavior, retry, and mobile layout. Finish with all -focused commands and the complete repository quality gate. +Verify that Shell authentication, legal-entity selection, module state, page permission, exact target resolution, and approved remote loading all occur before CRM code or `getContact` executes. Add Shell and browser coverage for both locales, exact ID propagation, wrong-parent suppression, normal/loading/not-found/forbidden/unavailable behavior, retry, and mobile layout. Finish with all focused commands and the complete repository quality gate. ## Step by Step Tasks @@ -199,21 +160,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use the scaffold generator's disposable workspace to prove exact two-parameter output and atomic -reruns. Use the new CRM component test to prove UUID decoding, hierarchical query identity, exact -`getContact` Effect-client invocation, parent consistency, closed error classification, ready and -loading models, lifecycle/timestamp formatting, localization parity, semantic markup, retry/focus, -and frontend import boundaries. Extend Shell unit tests for ordered parameter selection and the -post-gate approved-remote prop contract. +Use the scaffold generator's disposable workspace to prove exact two-parameter output and atomic reruns. Use the new CRM component test to prove UUID decoding, hierarchical query identity, exact `getContact` Effect-client invocation, parent consistency, closed error classification, ready and loading models, lifecycle/timestamp formatting, localization parity, semantic markup, retry/focus, and frontend import boundaries. Extend Shell unit tests for ordered parameter selection and the post-gate approved-remote prop contract. ### Integration Tests -Retain the existing CRM BFF/integration suite as proof that `getContact` verifies its audience, -runs through the governed Read lifecycle, enforces tenant/module/access boundaries, returns only the -declared DTO/errors, and commits Data Access evidence before success. Extend Playwright coverage for -the complete Shell route → approved remote → generated Effect BFF → rendered Contact path in both -locales, including anonymous pre-gate behavior, exact payload, wrong-parent suppression, declared -failures, retry, and mobile layout. +Retain the existing CRM BFF/integration suite as proof that `getContact` verifies its audience, runs through the governed Read lifecycle, enforces tenant/module/access boundaries, returns only the declared DTO/errors, and commits Data Access evidence before success. Extend Playwright coverage for the complete Shell route → approved remote → generated Effect BFF → rendered Contact path in both locales, including anonymous pre-gate behavior, exact payload, wrong-parent suppression, declared failures, retry, and mobile layout. ### Edge Cases diff --git a/app/specs/feature-crm-contact-edit-page.md b/app/specs/feature-crm-contact-edit-page.md index 3b93fc997..cc6919548 100644 --- a/app/specs/feature-crm-contact-edit-page.md +++ b/app/specs/feature-crm-contact-edit-page.md @@ -8,66 +8,29 @@ created: 2026-08-16 ## Feature Description -Add the generated CRM `ContactEdit` page at localized URL -`/cs/crm/customers/:id/contacts/:contactId/edit` (canonical generator URL -`/crm/customers/:id/contacts/:contactId/edit`, also exposed under `/en`). The authenticated Shell -continues to own legal-entity selection, exact page resolution, module-state and page-permission -gating, dashboard composition, and the approved lazy CRM remote load. The CRM page treats both route -parameters as untrusted business input, loads the addressed Contact through the existing -contract-derived `getContact` Effect BFF client, verifies that the Contact belongs to the Customer -named by the hierarchical URL, and pre-populates the existing owner-private `ContactForm`. - -Valid edits submit `{ contactId, name, email, phone }` through the existing generated `editContact` -Effect client. Its strict CRM BFF handler must dispatch the already generated `EditContactAction` -through the governed Action runtime; the page must not import or call the Action, BFF server, -persistence service, database, or HTTP endpoint directly. - -Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame -`Resource Detail — Běžný` (`6:780`, 1440×900) only as an arrangement wireframe. Preserve the -Shell-owned left navigation, compact Back link, page heading, and one main content surface, while -replacing the read-only detail rows with the existing Contact inputs. Do not copy Figma styling or -add the example's inert Overview/Documents/Timeline/Audit tabs. Use installed -`@techsio/ui-kit` components and tokens, with CRM-prefixed Tailwind utilities only for responsive -layout composition. +Add the generated CRM `ContactEdit` page at localized URL `/cs/crm/customers/:id/contacts/:contactId/edit` (canonical generator URL `/crm/customers/:id/contacts/:contactId/edit`, also exposed under `/en`). The authenticated Shell continues to own legal-entity selection, exact page resolution, module-state and page-permission gating, dashboard composition, and the approved lazy CRM remote load. The CRM page treats both route parameters as untrusted business input, loads the addressed Contact through the existing contract-derived `getContact` Effect BFF client, verifies that the Contact belongs to the Customer named by the hierarchical URL, and pre-populates the existing owner-private `ContactForm`. + +Valid edits submit `{ contactId, name, email, phone }` through the existing generated `editContact` Effect client. Its strict CRM BFF handler must dispatch the already generated `EditContactAction` through the governed Action runtime; the page must not import or call the Action, BFF server, persistence service, database, or HTTP endpoint directly. + +Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame `Resource Detail — Běžný` (`6:780`, 1440×900) only as an arrangement wireframe. Preserve the Shell-owned left navigation, compact Back link, page heading, and one main content surface, while replacing the read-only detail rows with the existing Contact inputs. Do not copy Figma styling or add the example's inert Overview/Documents/Timeline/Audit tabs. Use installed `@techsio/ui-kit` components and tokens, with CRM-prefixed Tailwind utilities only for responsive layout composition. ## User Story -As an authenticated CRM user with write access -I want to edit an existing Contact within its Customer context -So that corrected communication details are persisted through the governed CRM Action boundary +As an authenticated CRM user with write access I want to edit an existing Contact within its Customer context So that corrected communication details are persisted through the governed CRM Action boundary ## Problem Statement -CRM already owns Contact persistence, `EditContactAction`, the strict Effect BFF mutation, -`getContact`, `editContact`, a localized Contact-detail page, and a reusable Contact create/edit -form. It has no governed Contact-edit route that combines those capabilities. Users therefore -cannot load current Contact values, correct them, receive accessible validation and typed failure -feedback, or return safely to the Contact detail without bypassing the generated page/BFF seams. +CRM already owns Contact persistence, `EditContactAction`, the strict Effect BFF mutation, `getContact`, `editContact`, a localized Contact-detail page, and a reusable Contact create/edit form. It has no governed Contact-edit route that combines those capabilities. Users therefore cannot load current Contact values, correct them, receive accessible validation and typed failure feedback, or return safely to the Contact detail without bypassing the generated page/BFF seams. -The nested dynamic page also requires Codesmith-owned manifest, registration, Module Federation, -Shell connector, route-parameter, metadata, and locale wiring. Hand-authoring that initial wiring -would violate the repository's generator and module-entrypoint rules. +The nested dynamic page also requires Codesmith-owned manifest, registration, Module Federation, Shell connector, route-parameter, metadata, and locale wiring. Hand-authoring that initial wiring would violate the repository's generator and module-entrypoint rules. ## Solution Statement -Run the mandatory MicroVertical page generator with stable identity `contact-edit` and canonical -URL `/crm/customers/:id/contacts/:contactId/edit`. Preserve its private/non-indexable exact-page -descriptor, dynamic non-navigation behavior, owner-private registration, Module Federation -exposure, approved Shell lazy client, and bounded propagation of only `id` and `contactId`. Adapt -the generated CRM page and federation wrapper to accept the resolved target so write availability -remains explicit. - -At the owner boundary, bound and decode both parameters with `CrmUuidSchema`. Load the Contact only -through `getContact({ contactId })`, retain the operation-specific typed Effect error union, and -require `contact.customerId === id` before exposing any values. Render explicit loading, -authentication-expired, forbidden, not-found, unavailable/retry, read-only, and ready states. - -In the ready state, reuse `verticals/crm/src/features/contacts/contact-form.tsx` unchanged with the -loaded `name`, `email`, and `phone`. Submit normalized values only through `editContact` using one -idempotency key per logical edit intent and a fresh correlation ID per network attempt. Preserve -uncertain retry semantics for transport, decode, and retryable backend failures. On success, update -the Contact-detail query cache and navigate to the localized existing Contact-detail route; -Back and Cancel use that same destination without mutation. +Run the mandatory MicroVertical page generator with stable identity `contact-edit` and canonical URL `/crm/customers/:id/contacts/:contactId/edit`. Preserve its private/non-indexable exact-page descriptor, dynamic non-navigation behavior, owner-private registration, Module Federation exposure, approved Shell lazy client, and bounded propagation of only `id` and `contactId`. Adapt the generated CRM page and federation wrapper to accept the resolved target so write availability remains explicit. + +At the owner boundary, bound and decode both parameters with `CrmUuidSchema`. Load the Contact only through `getContact({ contactId })`, retain the operation-specific typed Effect error union, and require `contact.customerId === id` before exposing any values. Render explicit loading, authentication-expired, forbidden, not-found, unavailable/retry, read-only, and ready states. + +In the ready state, reuse `verticals/crm/src/features/contacts/contact-form.tsx` unchanged with the loaded `name`, `email`, and `phone`. Submit normalized values only through `editContact` using one idempotency key per logical edit intent and a fresh correlation ID per network attempt. Preserve uncertain retry semantics for transport, decode, and retryable backend failures. On success, update the Contact-detail query cache and navigate to the localized existing Contact-detail route; Back and Cancel use that same destination without mutation. ## Relevant Files @@ -136,29 +99,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate `contact-edit` and all owner/Shell wiring before adapting business code. Verify the -generator retains the locale-free two-parameter route, private/non-indexable metadata, ordered -route parameters, dynamic non-navigation behavior, exact CRM identities, owner-private -registration, and approved lazy-client boundary. Reuse the existing CRM query/test dependencies and -`ContactForm`; add no Action, API, persistence service, form component, shared abstraction, -dependency, UI-kit component, or token override. +Generate `contact-edit` and all owner/Shell wiring before adapting business code. Verify the generator retains the locale-free two-parameter route, private/non-indexable metadata, ordered route parameters, dynamic non-navigation behavior, exact CRM identities, owner-private registration, and approved lazy-client boundary. Reuse the existing CRM query/test dependencies and `ContactForm`; add no Action, API, persistence service, form component, shared abstraction, dependency, UI-kit component, or token override. ### Phase 2: Core Implementation -Adapt the generated page and federation wrapper to validate both route IDs, load the current -Contact with `getContact`, reject a parent mismatch, preserve typed query failures, honor -`target.writable`, and render `ContactForm` with loaded values. Submit only through `editContact` -with logical idempotency and exhaustive error mapping, then update the detail cache and navigate to -the localized Contact-detail page. +Adapt the generated page and federation wrapper to validate both route IDs, load the current Contact with `getContact`, reject a parent mismatch, preserve typed query failures, honor `target.writable`, and render `ContactForm` with loaded values. Submit only through `editContact` with logical idempotency and exhaustive error mapping, then update the detail cache and navigate to the localized Contact-detail page. ### Phase 3: Integration -Complete Czech/English copy, generated manifest/registration/federation/Shell verification, -responsive and accessible loading/error/form states, focused component and Shell tests, and -localized browser coverage. Reuse the real CRM BFF/Action integration suites as the authoritative -proof that the page's client method reaches `EditContactAction` through the strict BFF and governed -Action runtime. Finish with the independent CRM/Shell checks, boundary validators, build, and final -repository quality gate. +Complete Czech/English copy, generated manifest/registration/federation/Shell verification, responsive and accessible loading/error/form states, focused component and Shell tests, and localized browser coverage. Reuse the real CRM BFF/Action integration suites as the authoritative proof that the page's client method reaches `EditContactAction` through the strict BFF and governed Action runtime. Finish with the independent CRM/Shell checks, boundary validators, build, and final repository quality gate. ## Step by Step Tasks @@ -218,26 +167,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use existing CRM Node unit tests for Contact Action/API schemas and add Rstest/Testing Library -coverage for the generated ContactEdit page. Mock only the generated frontend Effect client seam. -Prove two-parameter decoding, parent consistency, hierarchical query/cache identity, complete typed -query and mutation classification, loaded form values, target writability, Action-aligned -validation, exact `getContact`/`editContact` payloads and options, logical idempotency, correlation, -cache/navigation outcomes, localization, keyboard/accessibility behavior, and absence of forbidden -frontend dependencies. Run the unchanged `ContactForm` suite as regression protection for its -reusable create/edit contract. Use Shell unit tests for post-gate ordered route parameters and -approved remote props. +Use existing CRM Node unit tests for Contact Action/API schemas and add Rstest/Testing Library coverage for the generated ContactEdit page. Mock only the generated frontend Effect client seam. Prove two-parameter decoding, parent consistency, hierarchical query/cache identity, complete typed query and mutation classification, loaded form values, target writability, Action-aligned validation, exact `getContact`/`editContact` payloads and options, logical idempotency, correlation, cache/navigation outcomes, localization, keyboard/accessibility behavior, and absence of forbidden frontend dependencies. Run the unchanged `ContactForm` suite as regression protection for its reusable create/edit contract. Use Shell unit tests for post-gate ordered route parameters and approved remote props. ### Integration Tests -Run the existing CRM integration suites that execute Contact read/edit through the -contract-derived client/BFF and real governed Read/Action runtimes. They prove audience assertion -verification, `EditContactAction` dispatch, idempotency, immutable Customer ownership, Contact -lifecycle preservation, persistence, audit/data-access evidence, typed Problem Details decoding, -rollback, and tenant isolation. Add focused Shell browser coverage for private route gating, real -page request construction, localized form behavior/navigation, and responsive layout while mocking -only terminal public BFF responses when deterministic browser cleanup would otherwise duplicate -Action-runtime ownership. +Run the existing CRM integration suites that execute Contact read/edit through the contract-derived client/BFF and real governed Read/Action runtimes. They prove audience assertion verification, `EditContactAction` dispatch, idempotency, immutable Customer ownership, Contact lifecycle preservation, persistence, audit/data-access evidence, typed Problem Details decoding, rollback, and tenant isolation. Add focused Shell browser coverage for private route gating, real page request construction, localized form behavior/navigation, and responsive layout while mocking only terminal public BFF responses when deterministic browser cleanup would otherwise duplicate Action-runtime ownership. ### Edge Cases diff --git a/app/specs/feature-crm-customer-action-fields.md b/app/specs/feature-crm-customer-action-fields.md index b89e36e22..d89f21b55 100644 --- a/app/specs/feature-crm-customer-action-fields.md +++ b/app/specs/feature-crm-customer-action-fields.md @@ -8,27 +8,19 @@ created: 2026-08-17 ## Feature Description -Extend the existing generated Customer create/edit Actions and lifecycle results to govern all -approved Customer business fields, including a typed same-tenant duplicate-IČO conflict. +Extend the existing generated Customer create/edit Actions and lifecycle results to govern all approved Customer business fields, including a typed same-tenant duplicate-IČO conflict. ## User Story -As a CRM user -I want Customer business identity changes to use the normal governed Actions -So that manually entered or ARES-prefilled values receive the same validation, idempotency, audit, and transaction guarantees +As a CRM user I want Customer business identity changes to use the normal governed Actions So that manually entered or ARES-prefilled values receive the same validation, idempotency, audit, and transaction guarantees ## Problem Statement -The current create/edit Actions accept only `name`, and persistence maps every database failure to -unavailability. That cannot represent complete Customer writes or a recoverable duplicate-IČO -conflict. +The current create/edit Actions accept only `name`, and persistence maps every database failure to unavailability. That cannot represent complete Customer writes or a recoverable duplicate-IČO conflict. ## Solution Statement -Adapt the already-generated create/edit Customer Actions and BFF mappings to the expanded canonical -payloads. Add a typed CRM domain error for the known IČO uniqueness conflict, preserve complete -Customer results for all four lifecycle Actions, and rely on Core request hashing over the complete -normalized payload for idempotency. +Adapt the already-generated create/edit Customer Actions and BFF mappings to the expanded canonical payloads. Add a typed CRM domain error for the known IČO uniqueness conflict, preserve complete Customer results for all four lifecycle Actions, and rely on Core request hashing over the complete normalized payload for idempotency. ## Relevant Files @@ -51,18 +43,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Consume the canonical schemas and typed persistence outcomes from dependencies 1 and 2. Do not run -`scaffold:action`: these Actions already exist and must retain their generated identities. +Consume the canonical schemas and typed persistence outcomes from dependencies 1 and 2. Do not run `scaffold:action`: these Actions already exist and must retain their generated identities. ### Phase 2: Core Implementation -Expand create/edit services and results, add duplicate-IČO as a declared domain conflict, and map it -to the existing typed `409` BFF Problem Details contract. +Expand create/edit services and results, add duplicate-IČO as a declared domain conflict, and map it to the existing typed `409` BFF Problem Details contract. ### Phase 3: Integration -Prove complete payload hashing, retries, tenant isolation, audit/evidence, lifecycle results, and -rollback using the real Action runtime and strict BFF. +Prove complete payload hashing, retries, tenant isolation, audit/evidence, lifecycle results, and rollback using the real Action runtime and strict BFF. ## Step by Step Tasks @@ -99,13 +88,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Validate Action descriptors, complete schemas, domain error unions, generated identities, and -exhaustive BFF mapping. +Validate Action descriptors, complete schemas, domain error unions, generated identities, and exhaustive BFF mapping. ### Integration Tests -Run the real Core Action runtime with CRM persistence and strict BFF to prove atomic writes, -idempotency, rollback, evidence, isolation, and typed duplicate conflicts. +Run the real Core Action runtime with CRM persistence and strict BFF to prove atomic writes, idempotency, rollback, evidence, isolation, and typed duplicate conflicts. ### Edge Cases @@ -149,41 +136,27 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Expanded the existing generated Customer create/edit Action payloads to govern every approved - business field with shared normalization and lifecycle-date validation. -- Added a closed `CrmCustomerIcoConflict` domain error, exact PostgreSQL constraint classification, - and exhaustive mapping to the existing declared `409` Problem Details contract. -- Persisted complete create/edit payloads atomically and updated the generated-client page - integrations so edit prefill, null clearing, and uncertain-retry identity include every field. -- Preserved the existing Action keys, owners, entrypoints, permissions, idempotency, evidence, - legal-entity scopes, lifecycle payloads, and complete lifecycle result schema. +- Expanded the existing generated Customer create/edit Action payloads to govern every approved business field with shared normalization and lifecycle-date validation. +- Added a closed `CrmCustomerIcoConflict` domain error, exact PostgreSQL constraint classification, and exhaustive mapping to the existing declared `409` Problem Details contract. +- Persisted complete create/edit payloads atomically and updated the generated-client page integrations so edit prefill, null clearing, and uncertain-retry identity include every field. +- Preserved the existing Action keys, owners, entrypoints, permissions, idempotency, evidence, legal-entity scopes, lifecycle payloads, and complete lifecycle result schema. ### Changed Files - `verticals/crm/shared/apis/customer-detail.ts` — complete mutation schemas and typed IČO conflict. -- `verticals/crm/src/actions/create-customer.action.ts` and - `verticals/crm/src/actions/edit-customer.action.ts` — declared domain error unions. -- `verticals/crm/src/services/customer-contact-persistence.service.ts` — complete atomic writes and - exact uniqueness-error classification. +- `verticals/crm/src/actions/create-customer.action.ts` and `verticals/crm/src/actions/edit-customer.action.ts` — declared domain error unions. +- `verticals/crm/src/services/customer-contact-persistence.service.ts` — complete atomic writes and exact uniqueness-error classification. - `verticals/crm/api/index.ts` — exhaustive safe `409` mapping. -- Customer create/edit route integration — complete generated-client payloads, edit prefill, and - complete logical retry comparison. +- Customer create/edit route integration — complete generated-client payloads, edit prefill, and complete logical retry comparison. - CRM unit, component, BFF integration, and database-backed Action integration tests. ### Tests Written or Updated -- Contract tests cover complete/nullable schemas, normalization, date ordering, excluded ARES - metadata, lifecycle payload closure, unchanged Action identities, complete result schemas, and - declared error unions. -- Persistence tests cover complete create/edit values, null clearing, exact named-constraint - mapping, unrelated uniqueness failures, and diagnostic redaction. -- Component tests cover complete create payloads, complete edit prefill/submission, and changed-field - idempotency intent. -- Governed runtime tests cover complete create/edit, clearing, validation rollback, active and - archived duplicate IČO, edit conflicts and rollback, cross-tenant reuse, lifecycle results, - durable audit/evidence, same-payload replay, and changed-payload hash conflict. -- Strict BFF tests prove `400`, `409`, and retryable `503` remain distinct through the generated - Effect client and do not expose internal constraint or tenant details. +- Contract tests cover complete/nullable schemas, normalization, date ordering, excluded ARES metadata, lifecycle payload closure, unchanged Action identities, complete result schemas, and declared error unions. +- Persistence tests cover complete create/edit values, null clearing, exact named-constraint mapping, unrelated uniqueness failures, and diagnostic redaction. +- Component tests cover complete create payloads, complete edit prefill/submission, and changed-field idempotency intent. +- Governed runtime tests cover complete create/edit, clearing, validation rollback, active and archived duplicate IČO, edit conflicts and rollback, cross-tenant reuse, lifecycle results, durable audit/evidence, same-payload replay, and changed-payload hash conflict. +- Strict BFF tests prove `400`, `409`, and retryable `503` remain distinct through the generated Effect client and do not expose internal constraint or tenant details. ### Validation @@ -198,21 +171,12 @@ Execute every command to validate the feature with zero regressions. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, and the relevant MicroVertical, Action, error, database, - governed-data-access, module-entrypoint, module-manifest, UltraModern, and frontend guidance. -- Reviewed the complete diff against every task, acceptance criterion, edge case, and review item. - The final review found no remaining correctness, security, boundary, or scope issues. -- Confirmed no new Action, page, Outbox Message, Policy, ARES mutation, manifest entry, or other - generator-owned artifact was created. The existing generated Actions were adapted as the plan - explicitly requires, so `scaffold:action` was intentionally not run. +- Re-read `../AGENTS.md`, `AGENTS.md`, and the relevant MicroVertical, Action, error, database, governed-data-access, module-entrypoint, module-manifest, UltraModern, and frontend guidance. +- Reviewed the complete diff against every task, acceptance criterion, edge case, and review item. The final review found no remaining correctness, security, boundary, or scope issues. +- Confirmed no new Action, page, Outbox Message, Policy, ARES mutation, manifest entry, or other generator-owned artifact was created. The existing generated Actions were adapted as the plan explicitly requires, so `scaffold:action` was intentionally not run. ### Deviations -- The first database-backed test invocation stopped at the repository's typed missing-configuration - error because a new worktree has no database environment. Validation then used an isolated - temporary PostgreSQL 17 instance, the repository migration/bootstrap commands, and separate - least-privilege admin/runtime URLs; the unchanged test command passed with those URLs supplied. -- The literal dirty-worktree build compiled the CRM client/server and passed TS-Go, then the - release-envelope guard correctly rejected placeholder revision `workspace`. The final build used - the immutable base revision shown above and passed completely. +- The first database-backed test invocation stopped at the repository's typed missing-configuration error because a new worktree has no database environment. Validation then used an isolated temporary PostgreSQL 17 instance, the repository migration/bootstrap commands, and separate least-privilege admin/runtime URLs; the unchanged test command passed with those URLs supplied. +- The literal dirty-worktree build compiled the CRM client/server and passed TS-Go, then the release-envelope guard correctly rejected placeholder revision `workspace`. The final build used the immutable base revision shown above and passed completely. - No implementation scope or architecture deviations remain. diff --git a/app/specs/feature-crm-customer-ares-loader.md b/app/specs/feature-crm-customer-ares-loader.md index 6a844f94b..334d41c76 100644 --- a/app/specs/feature-crm-customer-ares-loader.md +++ b/app/specs/feature-crm-customer-ares-loader.md @@ -8,27 +8,19 @@ created: 2026-08-17 ## Feature Description -Add an owner-private Customer presentation component containing exactly one IČO input and one lookup -button. It validates and emits a normalized IČO; its parent owns the generated BFF Effect, typed -errors, and returned Customer data. +Add an owner-private Customer presentation component containing exactly one IČO input and one lookup button. It validates and emits a normalized IČO; its parent owns the generated BFF Effect, typed errors, and returned Customer data. ## User Story -As a CRM user creating a Customer -I want a small ARES lookup control -So that I can request business data only after entering a valid IČO +As a CRM user creating a Customer I want a small ARES lookup control So that I can request business data only after entering a valid IČO ## Problem Statement -The create page needs a reusable interaction surface, but frontend architecture prohibits reusable -presentation from fetching, executing Effects, receiving query objects, or decoding domain errors. -It must also avoid accidentally submitting the adjacent Customer form. +The create page needs a reusable interaction surface, but frontend architecture prohibits reusable presentation from fetching, executing Effects, receiving query objects, or decoding domain errors. It must also avoid accidentally submitting the adjacent Customer form. ## Solution Statement -Create `CustomerAresLoader` inside the Customer feature. Compose UI-kit `FormInput`, `Button`, and -`StatusText`; keep local input/validation interaction only; emit `onLookup(ico)` once per valid user -intent; and receive pending/disabled/status state as plain props. +Create `CustomerAresLoader` inside the Customer feature. Compose UI-kit `FormInput`, `Button`, and `StatusText`; keep local input/validation interaction only; emit `onLookup(ico)` once per valid user intent; and receive pending/disabled/status state as plain props. ## Relevant Files @@ -48,18 +40,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Define plain copy/status/callback props and an exact IČO normalization rule, without importing the -generated lookup client or Customer domain contract. +Define plain copy/status/callback props and an exact IČO normalization rule, without importing the generated lookup client or Customer domain contract. ### Phase 2: Core Implementation -Compose one input, one button, inline validation, loading/disabled behavior, and keyboard submission -using existing UI-kit components. +Compose one input, one button, inline validation, loading/disabled behavior, and keyboard submission using existing UI-kit components. ### Phase 3: Integration -Prove the component can render as a sibling form next to `CustomerForm`, with all application and -result handling left to the future create-page owner. +Prove the component can render as a sibling form next to `CustomerForm`, with all application and result handling left to the future create-page owner. ## Step by Step Tasks @@ -93,13 +82,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Render with plain callbacks and test IČO validation, keyboard/pointer behavior, interaction guards, -accessible errors/status, and sibling-form isolation. +Render with plain callbacks and test IČO validation, keyboard/pointer behavior, interaction guards, accessible errors/status, and sibling-form isolation. ### Integration Tests -Not required here; the create-page integration spec owns the real generated BFF client and returned -data flow. +Not required here; the create-page integration spec owns the real generated BFF client and returned data flow. ### Edge Cases diff --git a/app/specs/feature-crm-customer-business-fields.md b/app/specs/feature-crm-customer-business-fields.md index 3d21c8d4f..96a4522d9 100644 --- a/app/specs/feature-crm-customer-business-fields.md +++ b/app/specs/feature-crm-customer-business-fields.md @@ -8,29 +8,19 @@ created: 2026-08-17 ## Feature Description -Extend the CRM-owned Customer record with the Czech business identity fields needed for manual entry -and ARES-assisted creation. Store the fields directly on `crm.customers`; do not introduce an ARES -subobject, synchronization metadata, address columns, an address table, CZ-NACE codes, or registered -activity records. +Extend the CRM-owned Customer record with the Czech business identity fields needed for manual entry and ARES-assisted creation. Store the fields directly on `crm.customers`; do not introduce an ARES subobject, synchronization metadata, address columns, an address table, CZ-NACE codes, or registered activity records. ## User Story -As a CRM user -I want a Customer to retain its Czech business identity -So that the same canonical record can be created manually or prefilled from ARES +As a CRM user I want a Customer to retain its Czech business identity So that the same canonical record can be created manually or prefilled from ARES ## Problem Statement -The physical Customer record currently persists only `name`. Later contracts, Actions, and pages -cannot safely adopt IČO, DIČ, legal form, and lifecycle dates until the owning schema has typed -columns, tenant invariants, a generated migration, and verified DTO support. +The physical Customer record currently persists only `name`. Later contracts, Actions, and pages cannot safely adopt IČO, DIČ, legal form, and lifecycle dates until the owning schema has typed columns, tenant invariants, a generated migration, and verified DTO support. ## Solution Statement -Add nullable `ico`, `dic`, `legalFormCode`, `establishedOn`, and `dissolvedOn` columns to the existing -Customer table. Keep `name` as the canonical business name populated from ARES `obchodniJmeno`. -Constrain normalized formats in Drizzle, make non-null IČO unique per tenant across active and -archived Customers, generate the CRM-owned migration, and update persistence mapping/tests. +Add nullable `ico`, `dic`, `legalFormCode`, `establishedOn`, and `dissolvedOn` columns to the existing Customer table. Keep `name` as the canonical business name populated from ARES `obchodniJmeno`. Constrain normalized formats in Drizzle, make non-null IČO unique per tenant across active and archived Customers, generate the CRM-owned migration, and update persistence mapping/tests. ## Relevant Files @@ -57,19 +47,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Define canonical result/field schemas plus normalized columns and constraints directly on -`crm.customers`, preserving all existing IDs, timestamps, lifecycle behavior, RLS, and table -inventory. +Define canonical result/field schemas plus normalized columns and constraints directly on `crm.customers`, preserving all existing IDs, timestamps, lifecycle behavior, RLS, and table inventory. ### Phase 2: Core Implementation -Generate the CRM migration and expand Customer result/DTO mapping. Mutation payloads, write-service -mapping, and duplicate-IČO domain errors remain in the later Action task. +Generate the CRM migration and expand Customer result/DTO mapping. Mutation payloads, write-service mapping, and duplicate-IČO domain errors remain in the later Action task. ### Phase 3: Integration -Verify migration output, exact schema inventory, tenant isolation, archived-record uniqueness, and -compatibility with existing rows whose new fields are null. +Verify migration output, exact schema inventory, tenant isolation, archived-record uniqueness, and compatibility with existing rows whose new fields are null. ## Step by Step Tasks @@ -109,13 +95,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Assert the typed Drizzle schema, exact inventory, normalized checks, index identity, inferred record -types, and DTO date/null conversion. +Assert the typed Drizzle schema, exact inventory, normalized checks, index identity, inferred record types, and DTO date/null conversion. ### Integration Tests -Apply the CRM migration to the test database and prove tenant isolation, uniqueness, compatibility -with existing rows, and complete persistence round trips. +Apply the CRM migration to the test database and prove tenant isolation, uniqueness, compatibility with existing rows, and complete persistence round trips. ### Edge Cases @@ -160,12 +144,9 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Added flat reusable Customer business-field schemas, nullable CRM-owned columns, normalized checks, - lifecycle-date ordering, and a tenant/IČO unique index that includes archived Customers. -- Generated the in-place CRM migration and metadata, expanded flat Customer DTO mapping, and made - physical verification select every typed CRM column. -- Added unit and live PostgreSQL coverage for complete and legacy-null records, normalized formats, - multiple null IČOs, tenant uniqueness, archived uniqueness, and lifecycle dates. +- Added flat reusable Customer business-field schemas, nullable CRM-owned columns, normalized checks, lifecycle-date ordering, and a tenant/IČO unique index that includes archived Customers. +- Generated the in-place CRM migration and metadata, expanded flat Customer DTO mapping, and made physical verification select every typed CRM column. +- Added unit and live PostgreSQL coverage for complete and legacy-null records, normalized formats, multiple null IČOs, tenant uniqueness, archived uniqueness, and lifecycle dates. ### Changed Files @@ -175,17 +156,11 @@ Execute every command to validate the feature with zero regressions. ### Tests Written or Updated -- `verticals/crm/tests/unit/customer-contact-persistence.service.test.ts` — proves complete and - legacy-null rows map to flat date-only/null Customer DTOs. -- `verticals/crm/tests/unit/customer-contact-action-contract.test.ts` — proves reusable IČO, DIČ, - legal-form, and real calendar-date schemas plus strict flat Customer results. -- `verticals/crm/tests/unit/schema-contract.test.ts` — proves inferred record shapes, exact columns, - checks, index identity, migration scope, and unchanged table inventory. -- `verticals/crm/tests/integration/database-boundary.test.ts` — proves nullable migration - compatibility, complete round trips, invalid-value rejection, multiple null IČOs, same-tenant - active/archived uniqueness, cross-tenant allowance, and equal/reversed lifecycle dates. -- `verticals/crm/tests/integration/customer-contact-bff.test.ts` — keeps the real BFF fixture aligned - with the expanded canonical Customer result. +- `verticals/crm/tests/unit/customer-contact-persistence.service.test.ts` — proves complete and legacy-null rows map to flat date-only/null Customer DTOs. +- `verticals/crm/tests/unit/customer-contact-action-contract.test.ts` — proves reusable IČO, DIČ, legal-form, and real calendar-date schemas plus strict flat Customer results. +- `verticals/crm/tests/unit/schema-contract.test.ts` — proves inferred record shapes, exact columns, checks, index identity, migration scope, and unchanged table inventory. +- `verticals/crm/tests/integration/database-boundary.test.ts` — proves nullable migration compatibility, complete round trips, invalid-value rejection, multiple null IČOs, same-tenant active/archived uniqueness, cross-tenant allowance, and equal/reversed lifecycle dates. +- `verticals/crm/tests/integration/customer-contact-bff.test.ts` — keeps the real BFF fixture aligned with the expanded canonical Customer result. ### Validation @@ -197,20 +172,13 @@ Execute every command to validate the feature with zero regressions. - `mise exec -- pnpm --filter @app/crm test:integration` — passed (3 tests) against a disposable migrated PostgreSQL database. - `mise exec -- pnpm --filter @app/crm typecheck` — passed after bootstrapping fresh-worktree dependency declarations. - `mise exec -- pnpm database-access:check` — passed. -- `mise exec -- pnpm check` — passed, including format, lint, Action tests, workspace typecheck, API, - database, module-entrypoint, contract, and performance gates. +- `mise exec -- pnpm check` — passed, including format, lint, Action tests, workspace typecheck, API, database, module-entrypoint, contract, and performance gates. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, - `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, - `docs/architecture/ULTRAMODERN.md`, `docs/architecture/DATABASE.md`, and - `docs/architecture/DATA_ACCESS.md`; no generator, Action, Effect error, data-access, or deployment - seam was bypassed. -- Fixed review findings by making `db:verify` touch every typed CRM column and explicitly proving - multiple null IČOs in one tenant. Also fixed the formatter and lint findings surfaced by the gate. -- DIČ uses one trimmed non-empty 20-character bound consistently in the contract and database; the - related ARES schema publishes no maximum, and no DIČ/IČO coupling was introduced. +- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, `docs/architecture/ULTRAMODERN.md`, `docs/architecture/DATABASE.md`, and `docs/architecture/DATA_ACCESS.md`; no generator, Action, Effect error, data-access, or deployment seam was bypassed. +- Fixed review findings by making `db:verify` touch every typed CRM column and explicitly proving multiple null IČOs in one tenant. Also fixed the formatter and lint findings surfaced by the gate. +- DIČ uses one trimmed non-empty 20-character bound consistently in the contract and database; the related ARES schema publishes no maximum, and no DIČ/IČO coupling was introduced. - No browser review or screenshots were applicable because this change has no user-facing UI. ### Deviations and Follow-ups diff --git a/app/specs/feature-crm-customer-contact-actions.md b/app/specs/feature-crm-customer-contact-actions.md index d56f0509f..783d24673 100644 --- a/app/specs/feature-crm-customer-contact-actions.md +++ b/app/specs/feature-crm-customer-contact-actions.md @@ -8,69 +8,27 @@ created: 2026-08-14 ## Feature Description -Expose the existing CRM-owned Customer and Contact records through authenticated, typed Effect -operations. Add generated state-changing Actions for creating, editing, archiving, and unarchiving -both entities. Add governed reads for Customer detail/list and Contact detail/list, with the Contact -list always scoped by one Customer. Publish every operation through the CRM MicroVertical's Effect -BFF so frontend code can call a generated client method without importing backend code or using an -ad hoc HTTP request. Every successfully completed requested `Get*` operation must commit one -runtime-owned Data Access Event to `core.data_access_events` before its result is released. - -The feature retains the persistence model already established in `crm.customers` and -`crm.contacts`: Customer has a required name; Contact has a required name, email, phone, and one -immutable parent Customer; both use a nullable `archived_at` lifecycle marker and tenant RLS. It -adds no UI, cross-MicroVertical dependency, Policy, additional tenant-role permission, public -Domain Event, or Outbox Message. +Expose the existing CRM-owned Customer and Contact records through authenticated, typed Effect operations. Add generated state-changing Actions for creating, editing, archiving, and unarchiving both entities. Add governed reads for Customer detail/list and Contact detail/list, with the Contact list always scoped by one Customer. Publish every operation through the CRM MicroVertical's Effect BFF so frontend code can call a generated client method without importing backend code or using an ad hoc HTTP request. Every successfully completed requested `Get*` operation must commit one runtime-owned Data Access Event to `core.data_access_events` before its result is released. + +The feature retains the persistence model already established in `crm.customers` and `crm.contacts`: Customer has a required name; Contact has a required name, email, phone, and one immutable parent Customer; both use a nullable `archived_at` lifecycle marker and tenant RLS. It adds no UI, cross-MicroVertical dependency, Policy, additional tenant-role permission, public Domain Event, or Outbox Message. ## User Story -As a signed-in CRM user -I want to create, edit, inspect, list, archive, and restore Customers and their Contacts -So that frontend CRM features can manage the canonical records through one typed and auditable BFF -boundary +As a signed-in CRM user I want to create, edit, inspect, list, archive, and restore Customers and their Contacts So that frontend CRM features can manage the canonical records through one typed and auditable BFF boundary ## Problem Statement -CRM currently persists Customers and Contacts but exposes only its generated readiness endpoint. -Frontend code therefore has no supported Effect client methods for the records, and direct database, -backend-handler, or fetch access would bypass the MicroVertical, governed operation, authentication, -module-state, audit/evidence, and typed error boundaries. +CRM currently persists Customers and Contacts but exposes only its generated readiness endpoint. Frontend code therefore has no supported Effect client methods for the records, and direct database, backend-handler, or fetch access would bypass the MicroVertical, governed operation, authentication, module-state, audit/evidence, and typed error boundaries. -The requested operation names also describe four reads as Actions. OntOS Actions are write-only and -would incorrectly make reads require idempotency/invocation records and become unavailable when the -module is `read_only` or `deprecated`. Those operations need the governed Read runtime while keeping -the requested `getCustomerDetail`, `getCustomerList`, `getContact`, and `getContactList` frontend -method names. +The requested operation names also describe four reads as Actions. OntOS Actions are write-only and would incorrectly make reads require idempotency/invocation records and become unavailable when the module is `read_only` or `deprecated`. Those operations need the governed Read runtime while keeping the requested `getCustomerDetail`, `getCustomerList`, `getContact`, and `getContactList` frontend method names. ## Solution Statement -Run the mandatory Action generator for eight writes: create, edit, archive, and unarchive Customer, -and the same four Contact operations. Adapt the generated registrations with concrete public input -and result schemas, owner-local service factories over the Core-supplied scoped transaction, typed -domain failures, metadata-only access evidence, required idempotency, `legalEntityScope: 'optional'`, -and `policies: []`. Do not declare an additional tenant permission or provision an Action-specific -SpiceDB executor relation; the normal authenticated context, tenant/module gates, runtime -availability checks, and unconfigured-Action compatibility behavior still apply. - -Generate four module APIs as the supported starting point for Customer detail/list and Contact -detail/list reads. Adapt their `defineRead` registrations to tenant-level access, optional legal- -entity context, metadata-only evidence, empty Policy lists, CRM table queries, and typed not-found or -unavailable failures. Customer and Contact lists are bounded and deterministically ordered; both -default to active records and accept an explicit active/archived/all filter. Contact list input must -contain `customerId`, verifies that the same-tenant Customer exists, returns `404` when it does not, -and returns an empty list when it exists without matching Contacts. Each read handler returns -bounded evidence metadata with the released result count, and `ReadRuntime` atomically persists the -corresponding allowed row in `core.data_access_events` in the governed read transaction. Evidence -persistence failure is a typed retryable failure and no read result may escape without its durable -record. - -Compose the generated Action identity boundary, Action/Read runtimes, strict Effect HttpApi -contracts, handlers, and contract-derived clients into the existing CRM BFF. Export exactly these -frontend methods: `createCustomer`, `editCustomer`, `getCustomerDetail`, `getCustomerList`, -`archiveCustomer`, `unarchiveCustomer`, `createContact`, `editContact`, `getContact`, -`getContactList`, `archiveContact`, and `unarchiveContact`. Each mutation accepts explicit -idempotency/correlation input and obtains a fresh CRM-audience Shell assertion for each invocation. -All declared backend, transport, and decode errors stay typed in the client Effect error channel. +Run the mandatory Action generator for eight writes: create, edit, archive, and unarchive Customer, and the same four Contact operations. Adapt the generated registrations with concrete public input and result schemas, owner-local service factories over the Core-supplied scoped transaction, typed domain failures, metadata-only access evidence, required idempotency, `legalEntityScope: 'optional'`, and `policies: []`. Do not declare an additional tenant permission or provision an Action-specific SpiceDB executor relation; the normal authenticated context, tenant/module gates, runtime availability checks, and unconfigured-Action compatibility behavior still apply. + +Generate four module APIs as the supported starting point for Customer detail/list and Contact detail/list reads. Adapt their `defineRead` registrations to tenant-level access, optional legal- entity context, metadata-only evidence, empty Policy lists, CRM table queries, and typed not-found or unavailable failures. Customer and Contact lists are bounded and deterministically ordered; both default to active records and accept an explicit active/archived/all filter. Contact list input must contain `customerId`, verifies that the same-tenant Customer exists, returns `404` when it does not, and returns an empty list when it exists without matching Contacts. Each read handler returns bounded evidence metadata with the released result count, and `ReadRuntime` atomically persists the corresponding allowed row in `core.data_access_events` in the governed read transaction. Evidence persistence failure is a typed retryable failure and no read result may escape without its durable record. + +Compose the generated Action identity boundary, Action/Read runtimes, strict Effect HttpApi contracts, handlers, and contract-derived clients into the existing CRM BFF. Export exactly these frontend methods: `createCustomer`, `editCustomer`, `getCustomerDetail`, `getCustomerList`, `archiveCustomer`, `unarchiveCustomer`, `createContact`, `editContact`, `getContact`, `getContactList`, `archiveContact`, and `unarchiveContact`. Each mutation accepts explicit idempotency/correlation input and obtains a fresh CRM-audience Shell assertion for each invocation. All declared backend, transport, and decode errors stay typed in the client Effect error channel. ## Relevant Files @@ -127,31 +85,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate all eight state-changing Actions first, using the existing `crm.core` module contract and -optional legal-entity scope. Generate the four read module APIs next; the first module-API generator -also creates CRM's Action identity boundary because it is not currently present. Confirm every -generated artifact is patched into only the owner manifest/registration slots and no raw Shell or -cross-vertical import is introduced. +Generate all eight state-changing Actions first, using the existing `crm.core` module contract and optional legal-entity scope. Generate the four read module APIs next; the first module-API generator also creates CRM's Action identity boundary because it is not currently present. Confirm every generated artifact is patched into only the owner manifest/registration slots and no raw Shell or cross-vertical import is introduced. ### Phase 2: Core Implementation -Define browser-safe Customer/Contact DTOs and operation inputs, then implement the eight generated -Actions and four generated Reads. Handlers receive only owner-local service methods built over the -Core-supplied scoped transaction. They use typed Drizzle table references, trusted scope tenant ID, -metadata-only evidence, deterministic pagination, and typed failures. Customer archive does not -cascade to Contacts, Contact's parent is immutable after creation, edits preserve archive state, -and archive/unarchive reject an already-achieved lifecycle state as a typed conflict. Every -successful governed read supplies an exact result count so `ReadRuntime` can durably commit its Data -Access Event before returning the Customer or Contact result. +Define browser-safe Customer/Contact DTOs and operation inputs, then implement the eight generated Actions and four generated Reads. Handlers receive only owner-local service methods built over the Core-supplied scoped transaction. They use typed Drizzle table references, trusted scope tenant ID, metadata-only evidence, deterministic pagination, and typed failures. Customer archive does not cascade to Contacts, Contact's parent is immutable after creation, edits preserve archive state, and archive/unarchive reject an already-achieved lifecycle state as a typed conflict. Every successful governed read supplies an exact result count so `ReadRuntime` can durably commit its Data Access Event before returning the Customer or Contact result. ### Phase 3: Integration -Add the mutation endpoints and all twelve named client methods to the existing CRM BFF, compose the -generated read server layers plus Action/Read runtime dependencies, and exhaustively map verification, -module-state, Action, Read, domain, persistence, and unexpected failures to declared RFC 9457 -schemas. Add focused unit and live integration coverage for authenticated invocation, no custom -permissions/Policies, tenant isolation, Customer-scoped Contact listing, archive visibility, -idempotency, durable evidence, and contract-derived client decoding. +Add the mutation endpoints and all twelve named client methods to the existing CRM BFF, compose the generated read server layers plus Action/Read runtime dependencies, and exhaustively map verification, module-state, Action, Read, domain, persistence, and unexpected failures to declared RFC 9457 schemas. Add focused unit and live integration coverage for authenticated invocation, no custom permissions/Policies, tenant isolation, Customer-scoped Contact listing, archive visibility, idempotency, durable evidence, and contract-derived client decoding. ## Step by Step Tasks @@ -225,22 +167,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test all Action and Read descriptors, payload/result/DTO schemas, validation and normalization, -archive filter/pagination helpers, typed domain failures, no custom Policy/permission declaration, -manifest and private registration identity, assertion verification, Problem Details statuses, exact -BFF method names, exact read evidence metadata, and contract-derived Effect error types. Keep -presentation/browser tests out of scope because the feature adds no UI. +Test all Action and Read descriptors, payload/result/DTO schemas, validation and normalization, archive filter/pagination helpers, typed domain failures, no custom Policy/permission declaration, manifest and private registration identity, assertion verification, Problem Details statuses, exact BFF method names, exact read evidence metadata, and contract-derived Effect error types. Keep presentation/browser tests out of scope because the feature adds no UI. ### Integration Tests -Use the migrated Core and CRM PostgreSQL schemas with deterministic tenant/principal/module fixtures. -Run writes through the real Action runtime and reads through the real Read runtime so transaction -scope, forced RLS, module-state semantics, idempotency, audit/read evidence, parent integrity, and -tenant isolation are exercised rather than bypassed with direct repository calls. Run the strict -Effect BFF in process with signed and invalid assertions and call it through the generated clients -to prove request metadata and declared errors survive the complete horizontal seam. Inspect -`core.data_access_events` after every successful requested `Get*` call and simulate evidence-storage -failure to prove that durable read evidence is a prerequisite for releasing a result. +Use the migrated Core and CRM PostgreSQL schemas with deterministic tenant/principal/module fixtures. Run writes through the real Action runtime and reads through the real Read runtime so transaction scope, forced RLS, module-state semantics, idempotency, audit/read evidence, parent integrity, and tenant isolation are exercised rather than bypassed with direct repository calls. Run the strict Effect BFF in process with signed and invalid assertions and call it through the generated clients to prove request metadata and declared errors survive the complete horizontal seam. Inspect `core.data_access_events` after every successful requested `Get*` call and simulate evidence-storage failure to prove that durable read evidence is a prerequisite for releasing a result. ### Edge Cases @@ -313,50 +244,20 @@ Execute every command to validate the feature with zero regressions. ## Notes -- The request is one cohesive feature because all twelve operations share the same CRM persistence, - authenticated BFF, runtime layers, public DTOs, error vocabulary, and integration tests. -- `GetCustomerDetailAction`, `GetCustomerListAction`, `GetContactAction`, and - `GetContactListAction` are interpreted as requested operation/client names. Authoritative OntOS - guidance requires them to be governed Reads, not Actions. This is not an unresolved developer - decision. +- The request is one cohesive feature because all twelve operations share the same CRM persistence, authenticated BFF, runtime layers, public DTOs, error vocabulary, and integration tests. +- `GetCustomerDetailAction`, `GetCustomerListAction`, `GetContactAction`, and `GetContactListAction` are interpreted as requested operation/client names. Authoritative OntOS guidance requires them to be governed Reads, not Actions. This is not an unresolved developer decision. - `EditCContactAction` is treated as a typographical error for `EditContactAction`. -- Customer and Contact are tenant-wide records in the completed persistence feature, so all new - operations use `legalEntityScope: 'optional'`: a selected legal entity is revalidated when present - but is not persisted as record ownership. -- "No permission nor policy restrictions" means no Action-specific executor provisioning, no - additional tenant-role permission, and `policies: []`. Mandatory authentication, trusted-context - validation, tenant/module state gates, tenant baseline access for Reads, RLS, fail-closed - infrastructure checks, and Action runtime compatibility behavior are not bypassed. -- The requested `Get*` Data Access record is the governed `ReadRuntime` evidence row in - `core.data_access_events`, not a CRM-owned log table and not an Action Invocation Log. Successful - detail/list results persist allowed evidence atomically before release; definite authorization or - Policy denials retain the runtime's separate sanitized denied-evidence behavior. -- List defaults and archive semantics are conservative: active-only by default, explicit historical - filtering, non-cascading Customer archive, immutable Contact parent, and edits that preserve but - are not blocked by archive state. +- Customer and Contact are tenant-wide records in the completed persistence feature, so all new operations use `legalEntityScope: 'optional'`: a selected legal entity is revalidated when present but is not persisted as record ownership. +- "No permission nor policy restrictions" means no Action-specific executor provisioning, no additional tenant-role permission, and `policies: []`. Mandatory authentication, trusted-context validation, tenant/module state gates, tenant baseline access for Reads, RLS, fail-closed infrastructure checks, and Action runtime compatibility behavior are not bypassed. +- The requested `Get*` Data Access record is the governed `ReadRuntime` evidence row in `core.data_access_events`, not a CRM-owned log table and not an Action Invocation Log. Successful detail/list results persist allowed evidence atomically before release; definite authorization or Policy denials retain the runtime's separate sanitized denied-evidence behavior. +- List defaults and archive semantics are conservative: active-only by default, explicit historical filtering, non-cascading Customer archive, immutable Contact parent, and edits that preserve but are not blocked by archive state. - No unresolved decision blocks implementation. ## Implementation Evidence -- Implemented in worktree - `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-feature-crm-customer-contact-actions` - on branch `codex/feature-crm-customer-contact-actions`, based on - `d1818d84db23c1ddcb4e3ca8214b3daf1d74be61`. -- Ran the mandatory Codesmith generators from `app/` for the eight Customer/Contact Actions and the - four Customer/Contact module APIs before adapting their generated output. Generated source headers - and manifest/registration slots were retained. -- Added the twelve typed CRM operations, strict authenticated BFF handlers, governed Action/Read - runtime composition, contract-derived frontend clients, canonical DTOs, typed Problem Details, - tenant-scoped persistence services, stable pagination, lifecycle conflicts, idempotency, and - durable read evidence. -- Added 19 focused unit tests and 3 PostgreSQL-backed integration tests. The live governed-runtime - test exercises all eight mutations, all four read registrations, idempotent replay, missing - idempotency, lifecycle filtering, and committed standalone read evidence. -- Passed `test:unit`, `test:integration`, CRM `typecheck`, `api:check`, - `database-access:check`, `module-entrypoints:check`, `check:module-contracts`, CRM `build`, the - scaffold boundary tests, `contract:check`, and the repository-wide `pnpm check` quality gate. - The CRM build used the explicit base revision because the release-envelope check intentionally - rejects a dirty worktree revision during implementation. -- Used an isolated migrated PostgreSQL database for integration validation and removed it afterward. - No UI, route, Policy, permission provisioning, Contact reassignment, cascade archive, Domain Event, - Outbox Message, or cross-vertical dependency was added. +- Implemented in worktree `/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-feature-crm-customer-contact-actions` on branch `codex/feature-crm-customer-contact-actions`, based on `d1818d84db23c1ddcb4e3ca8214b3daf1d74be61`. +- Ran the mandatory Codesmith generators from `app/` for the eight Customer/Contact Actions and the four Customer/Contact module APIs before adapting their generated output. Generated source headers and manifest/registration slots were retained. +- Added the twelve typed CRM operations, strict authenticated BFF handlers, governed Action/Read runtime composition, contract-derived frontend clients, canonical DTOs, typed Problem Details, tenant-scoped persistence services, stable pagination, lifecycle conflicts, idempotency, and durable read evidence. +- Added 19 focused unit tests and 3 PostgreSQL-backed integration tests. The live governed-runtime test exercises all eight mutations, all four read registrations, idempotent replay, missing idempotency, lifecycle filtering, and committed standalone read evidence. +- Passed `test:unit`, `test:integration`, CRM `typecheck`, `api:check`, `database-access:check`, `module-entrypoints:check`, `check:module-contracts`, CRM `build`, the scaffold boundary tests, `contract:check`, and the repository-wide `pnpm check` quality gate. The CRM build used the explicit base revision because the release-envelope check intentionally rejects a dirty worktree revision during implementation. +- Used an isolated migrated PostgreSQL database for integration validation and removed it afterward. No UI, route, Policy, permission provisioning, Contact reassignment, cascade archive, Domain Event, Outbox Message, or cross-vertical dependency was added. diff --git a/app/specs/feature-crm-customer-contact-persistence.md b/app/specs/feature-crm-customer-contact-persistence.md index 6820dc464..fec42eec5 100644 --- a/app/specs/feature-crm-customer-contact-persistence.md +++ b/app/specs/feature-crm-customer-contact-persistence.md @@ -8,44 +8,23 @@ created: 2026-08-13 ## Feature Description -Add the first CRM-owned business entities to PostgreSQL: a Customer with a name and a Contact with -a name, email, phone, and exactly one parent Customer. A Customer represents either a company or a -person without introducing a discriminator yet. Both entities have durable UUID identity, -tenant ownership, timestamps, and a nullable archive timestamp. +Add the first CRM-owned business entities to PostgreSQL: a Customer with a name and a Contact with a name, email, phone, and exactly one parent Customer. A Customer represents either a company or a person without introducing a discriminator yet. Both entities have durable UUID identity, tenant ownership, timestamps, and a nullable archive timestamp. -The change establishes the CRM MicroVertical as an independent Drizzle migration owner for the -PostgreSQL schema named exactly `crm`, adds owner-private typed database access and inferred entity -types, generates and applies the migration, and verifies the live schema and tenant isolation. It -does not add Actions, reads, BFF endpoints, public resource descriptors, or UI. +The change establishes the CRM MicroVertical as an independent Drizzle migration owner for the PostgreSQL schema named exactly `crm`, adds owner-private typed database access and inferred entity types, generates and applies the migration, and verifies the live schema and tenant isolation. It does not add Actions, reads, BFF endpoints, public resource descriptors, or UI. ## User Story -As an OntOS CRM developer -I want typed Customer and Contact persistence owned by the CRM MicroVertical -So that later generated Actions can safely create, update, archive, and read CRM records without -reopening the database ownership design +As an OntOS CRM developer I want typed Customer and Contact persistence owned by the CRM MicroVertical So that later generated Actions can safely create, update, archive, and read CRM records without reopening the database ownership design ## Problem Statement -The CRM MicroVertical exists but owns no database schema, migration history, or domain tables. -Later CRM behavior therefore has no canonical, tenant-isolated place to persist Customers and their -Contacts. Adding the tables through Core or `public` would violate MicroVertical ownership, while -an untyped or globally shared database client would bypass the governed data-access architecture. +The CRM MicroVertical exists but owns no database schema, migration history, or domain tables. Later CRM behavior therefore has no canonical, tenant-isolated place to persist Customers and their Contacts. Adding the tables through Core or `public` would violate MicroVertical ownership, while an untyped or globally shared database client would bypass the governed data-access architecture. ## Solution Statement -Create an owner-local Drizzle configuration and database boundary in `verticals/crm`, with the -distinct journal `drizzle.__drizzle_migrations_crm`. Define `crm.customers` and `crm.contacts` as -explicit typed Drizzle tables. Both are tenant-owned and protected by enabled and forced tenant -RLS; Contact uses a composite `(tenant_id, customer_id)` foreign key so it cannot belong to a -Customer from another tenant. Archiving is represented by nullable `archived_at`, and no hard-delete -or archive operation is exposed in this increment. +Create an owner-local Drizzle configuration and database boundary in `verticals/crm`, with the distinct journal `drizzle.__drizzle_migrations_crm`. Define `crm.customers` and `crm.contacts` as explicit typed Drizzle tables. Both are tenant-owned and protected by enabled and forced tenant RLS; Contact uses a composite `(tenant_id, customer_id)` foreign key so it cannot belong to a Customer from another tenant. Archiving is represented by nullable `archived_at`, and no hard-delete or archive operation is exposed in this increment. -Keep the current business shape deliberately small. Customer has only `name`; Contact has `name`, -`email`, and `phone`. Colocate `CustomerRecord`, `NewCustomerRecord`, `ContactRecord`, and -`NewContactRecord` as Drizzle-inferred, owner-private entity types in the CRM schema module instead -of inventing a separate unused domain abstraction. Future Action generators will own operation -payload/result schemas without coupling public contracts to persistence row types. +Keep the current business shape deliberately small. Customer has only `name`; Contact has `name`, `email`, and `phone`. Colocate `CustomerRecord`, `NewCustomerRecord`, `ContactRecord`, and `NewContactRecord` as Drizzle-inferred, owner-private entity types in the CRM schema module instead of inventing a separate unused domain abstraction. Future Action generators will own operation payload/result schemas without coupling public contracts to persistence row types. ## Relevant Files @@ -94,23 +73,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Create a fresh worktree and branch from the intended `develop` base, then establish CRM as the -third independent database owner. Reuse Core's public database configuration and RLS helpers while -keeping the CRM pool, Drizzle schema, executor types, migrations, and verification private to the -CRM package. +Create a fresh worktree and branch from the intended `develop` base, then establish CRM as the third independent database owner. Reuse Core's public database configuration and RLS helpers while keeping the CRM pool, Drizzle schema, executor types, migrations, and verification private to the CRM package. ### Phase 2: Core Implementation -Define the minimal Customer and Contact tables and inferred entity types. Add exact schema contract -tests beside the schema, including archive representation, required business fields, composite -same-tenant parent integrity, indexes, and tenant RLS. Add integration coverage that proves the -runtime role sees and writes only the installed tenant scope. +Define the minimal Customer and Contact tables and inferred entity types. Add exact schema contract tests beside the schema, including archive representation, required business fields, composite same-tenant parent integrity, indexes, and tenant RLS. Add integration coverage that proves the runtime role sees and writes only the installed tenant scope. ### Phase 3: Integration -Add CRM to root migration, grants, tests, and global verification without registering CRM tables in -Core or Auth. Generate and inspect the Drizzle migration, apply it to PostgreSQL, rerun it to prove -idempotence, verify the exact live catalog, and finish with all focused and repository-wide gates. +Add CRM to root migration, grants, tests, and global verification without registering CRM tables in Core or Auth. Generate and inspect the Drizzle migration, apply it to PostgreSQL, rerun it to prove idempotence, verify the exact live catalog, and finish with all focused and repository-wide gates. ## Step by Step Tasks @@ -164,17 +135,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test exact CRM table ownership, required/minimal fields, archive timestamp nullability, inferred row -and insert types, generated defaults, non-empty string checks, indexes, composite same-tenant -foreign key, RLS/policy definitions, exact catalog comparisons, and scoped pool lifecycle/errors. +Test exact CRM table ownership, required/minimal fields, archive timestamp nullability, inferred row and insert types, generated defaults, non-empty string checks, indexes, composite same-tenant foreign key, RLS/policy definitions, exact catalog comparisons, and scoped pool lifecycle/errors. ### Integration Tests -Apply the generated CRM migration to PostgreSQL and exercise the live tables as the least-privilege -runtime role. Verify exact migration bookkeeping, admin ownership, runtime grants, forced tenant -RLS, cross-tenant denial, same-tenant Customer/Contact integrity, multiple Contacts per Customer, -and non-destructive archive timestamps. No browser or BFF test is required because this increment -adds no public operation or UI surface. +Apply the generated CRM migration to PostgreSQL and exercise the live tables as the least-privilege runtime role. Verify exact migration bookkeeping, admin ownership, runtime grants, forced tenant RLS, cross-tenant denial, same-tenant Customer/Contact integrity, multiple Contacts per Customer, and non-destructive archive timestamps. No browser or BFF test is required because this increment adds no public operation or UI surface. ### Edge Cases diff --git a/app/specs/feature-crm-customer-contacts-table.md b/app/specs/feature-crm-customer-contacts-table.md index 619063fd7..8c6e3affa 100644 --- a/app/specs/feature-crm-customer-contacts-table.md +++ b/app/specs/feature-crm-customer-contacts-table.md @@ -14,9 +14,7 @@ The layout follows the `Pre-Alpha Repo` / `Audit Log — Naplněný` Figma wiref ## User Story -As a CRM user -I want to see a Customer's Contacts on the Customer detail page -So that I can review the relevant names, email addresses, and phone numbers without leaving the Customer context +As a CRM user I want to see a Customer's Contacts on the Customer detail page So that I can review the relevant names, email addresses, and phone numbers without leaving the Customer context ## Problem Statement diff --git a/app/specs/feature-crm-customer-contracts-reads.md b/app/specs/feature-crm-customer-contracts-reads.md index 20490cf50..88e883bfd 100644 --- a/app/specs/feature-crm-customer-contracts-reads.md +++ b/app/specs/feature-crm-customer-contracts-reads.md @@ -8,26 +8,19 @@ created: 2026-08-17 ## Feature Description -Publish the Customer business fields as ordinary top-level members of the canonical CRM Customer -contract and carry them through the existing Customer detail and list reads. Preserve the generated -Effect BFF seam and typed error behavior. +Publish the Customer business fields as ordinary top-level members of the canonical CRM Customer contract and carry them through the existing Customer detail and list reads. Preserve the generated Effect BFF seam and typed error behavior. ## User Story -As a CRM frontend feature -I want every Customer read to return the complete business identity -So that create, edit, detail, and lifecycle flows share one canonical Customer representation +As a CRM frontend feature I want every Customer read to return the complete business identity So that create, edit, detail, and lifecycle flows share one canonical Customer representation ## Problem Statement -The expanded persistence and canonical Customer result still need to cross the existing detail/list -read descriptors and generated clients consistently before frontend pages can consume the fields. +The expanded persistence and canonical Customer result still need to cross the existing detail/list read descriptors and generated clients consistently before frontend pages can consume the fields. ## Solution Statement -Carry the canonical nullable business fields through existing detail/list read results, clients, -public exports, fixtures, and contract tests without nesting or source metadata. Mutation payload -expansion remains owned by the following Action spec. +Carry the canonical nullable business fields through existing detail/list read results, clients, public exports, fixtures, and contract tests without nesting or source metadata. Mutation payload expansion remains owned by the following Action spec. ## Relevant Files @@ -54,13 +47,11 @@ Reuse the business-field formats established by dependency 1 in a single canonic ### Phase 2: Core Implementation -Expand existing generated module API contracts and persistence DTO results while preserving their -descriptors, endpoint identities, errors, and generated headers. +Expand existing generated module API contracts and persistence DTO results while preserving their descriptors, endpoint identities, errors, and generated headers. ### Phase 3: Integration -Regenerate or update contract-derived types as required and prove exact round trips through detail -and list clients without changing list pagination or lifecycle semantics. +Regenerate or update contract-derived types as required and prove exact round trips through detail and list clients without changing list pagination or lifecycle semantics. ## Step by Step Tasks @@ -94,13 +85,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Decode valid complete and nullable Customer results; reject malformed IČO, legal form, dates, -unknown nested ARES data, and undeclared result members. +Decode valid complete and nullable Customer results; reject malformed IČO, legal form, dates, unknown nested ARES data, and undeclared result members. ### Integration Tests -Use the generated clients against the CRM BFF to verify detail/list success and existing typed error -states with the expanded persisted DTO. +Use the generated clients against the CRM BFF to verify detail/list success and existing typed error states with the expanded persisted DTO. ### Edge Cases diff --git a/app/specs/feature-crm-customer-create-ares-prefill.md b/app/specs/feature-crm-customer-create-ares-prefill.md index c9dd11b0b..1d0cf29d5 100644 --- a/app/specs/feature-crm-customer-create-ares-prefill.md +++ b/app/specs/feature-crm-customer-create-ares-prefill.md @@ -8,28 +8,19 @@ created: 2026-08-17 ## Feature Description -Integrate the owner-private ARES loader and generated lookup read into the existing Customer create -page. Successful lookup applies flat Customer values to the controlled form, lets the user review -and edit them, and persists the final values through the existing CreateCustomerAction. +Integrate the owner-private ARES loader and generated lookup read into the existing Customer create page. Successful lookup applies flat Customer values to the controlled form, lets the user review and edit them, and persists the final values through the existing CreateCustomerAction. ## User Story -As a CRM user creating a Czech Customer -I want to prefill its business identity from ARES -So that I can avoid retyping public data while retaining control of the saved Customer +As a CRM user creating a Czech Customer I want to prefill its business identity from ARES So that I can avoid retyping public data while retaining control of the saved Customer ## Problem Statement -The lookup API, loader, and controlled Customer form are separate building blocks. The create route -must own their application state, typed Effect execution, mapping, retry, prefill policy, -idempotency, and final mutation without coupling presentation to data infrastructure. +The lookup API, loader, and controlled Customer form are separate building blocks. The create route must own their application state, typed Effect execution, mapping, retry, prefill policy, idempotency, and final mutation without coupling presentation to data infrastructure. ## Solution Statement -Render `CustomerAresLoader` as a sibling before `CustomerForm`. Use a page-owned TanStack mutation -adapter to run the generated ARES lookup Effect on explicit valid IČO submission. On success, -replace `name` and `ico`, apply non-null optional ARES values, and retain manually entered optional -values when ARES omits them. Save the resulting controlled Customer values through `createCustomer`. +Render `CustomerAresLoader` as a sibling before `CustomerForm`. Use a page-owned TanStack mutation adapter to run the generated ARES lookup Effect on explicit valid IČO submission. On success, replace `name` and `ico`, apply non-null optional ARES values, and retain manually entered optional values when ARES omits them. Save the resulting controlled Customer values through `createCustomer`. ## Relevant Files @@ -49,18 +40,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Consume completed Action, lookup BFF, controlled form, and loader dependencies; preserve the existing -generated page identity, route, target gating, and navigation. +Consume completed Action, lookup BFF, controlled form, and loader dependencies; preserve the existing generated page identity, route, target gating, and navigation. ### Phase 2: Core Implementation -Own lookup and form state in the page, map the complete typed lookup error union to presentation, -apply the deterministic prefill policy, and submit the final payload with correct idempotency. +Own lookup and form state in the page, map the complete typed lookup error union to presentation, apply the deterministic prefill policy, and submit the final payload with correct idempotency. ### Phase 3: Integration -Complete localized states, accessibility, responsive composition, focused page tests, and real BFF -coverage for both lookup and Customer creation. +Complete localized states, accessibility, responsive composition, focused page tests, and real BFF coverage for both lookup and Customer creation. ## Step by Step Tasks @@ -101,13 +89,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use component tests with mocked generated Effect clients and a real query provider to validate -state mapping, prefill, manual editing, mutation payloads, idempotency, accessibility, and navigation. +Use component tests with mocked generated Effect clients and a real query provider to validate state mapping, prefill, manual editing, mutation payloads, idempotency, accessibility, and navigation. ### Integration Tests -Run the strict CRM BFF with a substituted ARES service and real Action runtime to prove the complete -lookup-to-confirmed-create flow without external network dependency. +Run the strict CRM BFF with a substituted ARES service and real Action runtime to prove the complete lookup-to-confirmed-create flow without external network dependency. ### Edge Cases diff --git a/app/specs/feature-crm-customer-create-page.md b/app/specs/feature-crm-customer-create-page.md index 3bd0ef225..3626e42f3 100644 --- a/app/specs/feature-crm-customer-create-page.md +++ b/app/specs/feature-crm-customer-create-page.md @@ -8,58 +8,25 @@ created: 2026-08-16 ## Feature Description -Add the generated CRM `CustomerCreate` page at localized URL -`/cs/crm/customers/:id/new` (canonical generator URL `/crm/customers/:id/new`, also exposed under -`/en`). The authenticated Shell continues to own dashboard/sidebar composition, legal-entity -selection, exact page resolution, module-state gating, and the lazy remote load. The CRM-owned page -renders the existing owner-private `CustomerForm` with empty initial values and submits the valid -name through the generated `createCustomer` Effect client method. That BFF endpoint must execute the -existing `CreateCustomerAction`; the page must not call the Action handler, persistence service, or -HTTP endpoint directly. - -Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame -`Resource Detail — Běžný` (`6:780`) only as an arrangement wireframe. Preserve the authenticated -Shell, compact Back link, page heading, and one main content surface, but replace the read-only -detail rows with the form controls. Do not copy Figma styling or add its inert Overview/Documents/ -Timeline/Audit tabs. Use the existing `@techsio/ui-kit` components and tokens, with CRM-prefixed -Tailwind utilities only for responsive layout composition. - -The generated route carries the declared `id` parameter through the Shell boundary because the -requested URL contains it. The current `CreateCustomerPayloadSchema` accepts only `name`, so this -feature treats `id` as untrusted navigation context and never sends it to `createCustomer`, derives -trusted context from it, or changes the Action contract to accommodate it. +Add the generated CRM `CustomerCreate` page at localized URL `/cs/crm/customers/:id/new` (canonical generator URL `/crm/customers/:id/new`, also exposed under `/en`). The authenticated Shell continues to own dashboard/sidebar composition, legal-entity selection, exact page resolution, module-state gating, and the lazy remote load. The CRM-owned page renders the existing owner-private `CustomerForm` with empty initial values and submits the valid name through the generated `createCustomer` Effect client method. That BFF endpoint must execute the existing `CreateCustomerAction`; the page must not call the Action handler, persistence service, or HTTP endpoint directly. + +Use Figma file `ERP`, page `Pre-Alpha Repo` (not `Pre-Alpha`), frame `Resource Detail — Běžný` (`6:780`) only as an arrangement wireframe. Preserve the authenticated Shell, compact Back link, page heading, and one main content surface, but replace the read-only detail rows with the form controls. Do not copy Figma styling or add its inert Overview/Documents/ Timeline/Audit tabs. Use the existing `@techsio/ui-kit` components and tokens, with CRM-prefixed Tailwind utilities only for responsive layout composition. + +The generated route carries the declared `id` parameter through the Shell boundary because the requested URL contains it. The current `CreateCustomerPayloadSchema` accepts only `name`, so this feature treats `id` as untrusted navigation context and never sends it to `createCustomer`, derives trusted context from it, or changes the Action contract to accommodate it. ## User Story -As an authenticated CRM user with write access -I want to enter a new Customer name on a dedicated localized page -So that I can create the canonical Customer through the governed CRM Action boundary +As an authenticated CRM user with write access I want to enter a new Customer name on a dedicated localized page So that I can create the canonical Customer through the governed CRM Action boundary ## Problem Statement -CRM already owns Customer persistence, `CreateCustomerAction`, the strict Effect BFF mutation, and -the generated `createCustomer` client, but there is no governed page where a user can create a -Customer. Calling the endpoint directly would bypass the intended frontend integration and would -provide no accessible validation, pending, denial, retry, or localized success/failure experience. -The requested dynamic route also requires Codesmith-owned manifest, registration, federation, -Shell connector, route-parameter, and locale wiring before business UI can be adapted safely. +CRM already owns Customer persistence, `CreateCustomerAction`, the strict Effect BFF mutation, and the generated `createCustomer` client, but there is no governed page where a user can create a Customer. Calling the endpoint directly would bypass the intended frontend integration and would provide no accessible validation, pending, denial, retry, or localized success/failure experience. The requested dynamic route also requires Codesmith-owned manifest, registration, federation, Shell connector, route-parameter, and locale wiring before business UI can be adapted safely. ## Solution Statement -Run the mandatory MicroVertical page generator with stable identity `customer-create` and canonical -URL `/crm/customers/:id/new`. Preserve its private/non-indexable exact-page descriptor, dynamic -non-navigation behavior, owner-private registration, Module Federation exposure, approved Shell -lazy client, and bounded `id` propagation. Adapt the generated CRM page and federation wrapper to -receive the resolved target so write availability remains explicit. - -Reuse `verticals/crm/src/features/customers/customer-form.tsx` unchanged with -`initialValues={{ name: '' }}` and create-specific localized copy. Keep route, BFF, query, Effect, -permission, and navigation behavior in the generated page integration. Use the existing page-local -TanStack Query mutation pattern to bridge the generated `createCustomer` Effect at the framework -edge, retain its operation-specific typed error union, and map every expected failure into the -form's existing field/form status contract. Generate one idempotency key per logical submission, -reuse it only after an uncertain same-name failure, and replace it after the user changes the -intent. On success, navigate to the localized generated Customers list. +Run the mandatory MicroVertical page generator with stable identity `customer-create` and canonical URL `/crm/customers/:id/new`. Preserve its private/non-indexable exact-page descriptor, dynamic non-navigation behavior, owner-private registration, Module Federation exposure, approved Shell lazy client, and bounded `id` propagation. Adapt the generated CRM page and federation wrapper to receive the resolved target so write availability remains explicit. + +Reuse `verticals/crm/src/features/customers/customer-form.tsx` unchanged with `initialValues={{ name: '' }}` and create-specific localized copy. Keep route, BFF, query, Effect, permission, and navigation behavior in the generated page integration. Use the existing page-local TanStack Query mutation pattern to bridge the generated `createCustomer` Effect at the framework edge, retain its operation-specific typed error union, and map every expected failure into the form's existing field/form status contract. Generate one idempotency key per logical submission, reuse it only after an uncertain same-name failure, and replace it after the user changes the intent. On success, navigate to the localized generated Customers list. ## Relevant Files @@ -116,27 +83,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate the exact dynamic page and all owner/Shell wiring before adapting business code. Verify the -generator retains the canonical route without a locale prefix, carries only `id`, omits dynamic -navigation, and creates stable `customer-create` identities. Reuse the already implemented Customer -Action/BFF and CRM UI/query/test infrastructure; add no backend contract, persistence, dependency, -or UI-kit component. +Generate the exact dynamic page and all owner/Shell wiring before adapting business code. Verify the generator retains the canonical route without a locale prefix, carries only `id`, omits dynamic navigation, and creates stable `customer-create` identities. Reuse the already implemented Customer Action/BFF and CRM UI/query/test infrastructure; add no backend contract, persistence, dependency, or UI-kit component. ### Phase 2: Core Implementation -Adapt the generated remote page and federation wrapper to receive `target.writable`. Render the -existing `CustomerForm` with empty values and create-specific localized copy. Compose -`createCustomer` through a typed TanStack mutation, preserve all generated client failure families, -and implement logical-submission idempotency without leaking route context into the Action payload. -Add focused tests beside the writable, form, mutation, error, and navigation behavior. +Adapt the generated remote page and federation wrapper to receive `target.writable`. Render the existing `CustomerForm` with empty values and create-specific localized copy. Compose `createCustomer` through a typed TanStack mutation, preserve all generated client failure families, and implement logical-submission idempotency without leaking route context into the Action payload. Add focused tests beside the writable, form, mutation, error, and navigation behavior. ### Phase 3: Integration -Complete Czech/English copy, generated manifest/registration/federation/Shell verification, -responsive and keyboard behavior, and exact route-param tests. Use the existing real CRM integration -suites as the proof that the client call reaches `CreateCustomerAction` through the strict BFF and -commits under the governed Action lifecycle. Finish with the independent CRM build, Shell unit -suite, repository boundary checks, and final quality gate. +Complete Czech/English copy, generated manifest/registration/federation/Shell verification, responsive and keyboard behavior, and exact route-param tests. Use the existing real CRM integration suites as the proof that the client call reaches `CreateCustomerAction` through the strict BFF and commits under the governed Action lifecycle. Finish with the independent CRM build, Shell unit suite, repository boundary checks, and final quality gate. ## Step by Step Tasks @@ -185,22 +140,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use the existing CRM Node unit tests for the Action/API descriptors and add Rstest/Testing Library -coverage for the generated CustomerCreate page. Mock only the generated frontend Effect client seam -in page tests. Prove empty-form composition, validation/focus/keyboard behavior, writable gating, -exact `createCustomer` payload/options, typed failure mapping, logical idempotency, accessible status, -localized navigation, route-param non-propagation to business input, and absence of forbidden -frontend dependencies. Retain the existing `CustomerForm` tests as the reusable presentation proof. +Use the existing CRM Node unit tests for the Action/API descriptors and add Rstest/Testing Library coverage for the generated CustomerCreate page. Mock only the generated frontend Effect client seam in page tests. Prove empty-form composition, validation/focus/keyboard behavior, writable gating, exact `createCustomer` payload/options, typed failure mapping, logical idempotency, accessible status, localized navigation, route-param non-propagation to business input, and absence of forbidden frontend dependencies. Retain the existing `CustomerForm` tests as the reusable presentation proof. ### Integration Tests -Run the existing CRM integration suites that execute Customer creation through both the generated -contract-derived client/BFF and the real Action runtime. They prove assertion verification, -`CreateCustomerAction` dispatch, idempotency, tenant/module/write scope, persistence, audit evidence, -typed Problem Details decoding, and rollback/isolation without weakening the MicroVertical seam. -Run Shell unit tests for exact page resolution, bounded `id` propagation, and lazy remote props. A -new Playwright test is not required because the current repository browser server does not -orchestrate both independently deployable Shell and CRM services. +Run the existing CRM integration suites that execute Customer creation through both the generated contract-derived client/BFF and the real Action runtime. They prove assertion verification, `CreateCustomerAction` dispatch, idempotency, tenant/module/write scope, persistence, audit evidence, typed Problem Details decoding, and rollback/isolation without weakening the MicroVertical seam. Run Shell unit tests for exact page resolution, bounded `id` propagation, and lazy remote props. A new Playwright test is not required because the current repository browser server does not orchestrate both independently deployable Shell and CRM services. ### Edge Cases diff --git a/app/specs/feature-crm-customer-detail-business-fields.md b/app/specs/feature-crm-customer-detail-business-fields.md index 44a9197ec..3a9f82616 100644 --- a/app/specs/feature-crm-customer-detail-business-fields.md +++ b/app/specs/feature-crm-customer-detail-business-fields.md @@ -8,27 +8,19 @@ created: 2026-08-17 ## Feature Description -Display all canonical Customer business fields on the existing Customer detail page as one ordinary -Customer information set. Do not create a separate ARES panel, synchronization history, address -section, or activity list. +Display all canonical Customer business fields on the existing Customer detail page as one ordinary Customer information set. Do not create a separate ARES panel, synchronization history, address section, or activity list. ## User Story -As a CRM user -I want to see a Customer's complete Czech business identity -So that I can review the canonical record without opening the edit form +As a CRM user I want to see a Customer's complete Czech business identity So that I can review the canonical record without opening the edit form ## Problem Statement -The current detail page shows name, IDs, lifecycle, and timestamps only. After contracts expand, the -page needs localized labels, date formatting, null handling, and responsive/accessibility coverage -for the new fields while preserving contacts and lifecycle controls. +The current detail page shows name, IDs, lifecycle, and timestamps only. After contracts expand, the page needs localized labels, date formatting, null handling, and responsive/accessibility coverage for the new fields while preserving contacts and lifecycle controls. ## Solution Statement -Extend the existing ready-state view model and detail definition layout with IČO, DIČ, legal-form -code, establishment date, and dissolution date. Use existing UI-kit/layout patterns and locale-aware -date-only formatting; show a consistent unavailable-value label for null fields. +Extend the existing ready-state view model and detail definition layout with IČO, DIČ, legal-form code, establishment date, and dissolution date. Use existing UI-kit/layout patterns and locale-aware date-only formatting; show a consistent unavailable-value label for null fields. ## Relevant Files @@ -45,13 +37,11 @@ Use these files to implement the feature: ### Phase 1: Foundation -Consume the expanded Customer detail DTO and extend only the existing ready-state view model and -copy contract. +Consume the expanded Customer detail DTO and extend only the existing ready-state view model and copy contract. ### Phase 2: Core Implementation -Render the fields in the current Customer information layout using semantic markup, stable code -display, locale-aware dates, and consistent null values. +Render the fields in the current Customer information layout using semantic markup, stable code display, locale-aware dates, and consistent null values. ### Phase 3: Integration @@ -89,13 +79,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test the view-model/date formatter with complete/null values and locale/timezone boundaries where it -is extracted as a pure helper. +Test the view-model/date formatter with complete/null values and locale/timezone boundaries where it is extracted as a pure helper. ### Integration Tests -Use existing component integration with the generated detail client; the contract/BFF spec already -proves the real read seam, so no duplicate external runtime test is required. +Use existing component integration with the generated detail client; the contract/BFF spec already proves the real read seam, so no duplicate external runtime test is required. ### Edge Cases @@ -138,60 +126,36 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Extended the existing Customer detail ready model and semantic definition list with IČO, DIČ, - legal-form code, establishment date, and dissolution date while preserving the canonical name, - lifecycle controls, contacts, and existing UI states. -- Added UTC-pinned locale-aware date-only formatting, one localized unavailable value, stable code - display, and wrapping-safe narrow-layout styles. +- Extended the existing Customer detail ready model and semantic definition list with IČO, DIČ, legal-form code, establishment date, and dissolution date while preserving the canonical name, lifecycle controls, contacts, and existing UI states. +- Added UTC-pinned locale-aware date-only formatting, one localized unavailable value, stable code display, and wrapping-safe narrow-layout styles. ### Changed Files -`git diff --stat` reports 4 tracked files changed, 160 insertions, and 10 deletions. This -implementation plan is a new file carried from the original checkout into the isolated worktree. +`git diff --stat` reports 4 tracked files changed, 160 insertions, and 10 deletions. This implementation plan is a new file carried from the original checkout into the isolated worktree. ### Tests Written or Updated -- `verticals/crm/tests/components/customer-detail-page.test.tsx` — proves complete and null Customer - rendering, leading-zero IČO, English/Czech date-only formatting, archived state, semantic - label/value relationships, wrapping classes, contacts coexistence, loading geometry, locale - parity, and absence of ARES/address sections. +- `verticals/crm/tests/components/customer-detail-page.test.tsx` — proves complete and null Customer rendering, leading-zero IČO, English/Czech date-only formatting, archived state, semantic label/value relationships, wrapping classes, contacts coexistence, loading geometry, locale parity, and absence of ARES/address sections. ### Validation - `mise exec -- pnpm --filter @app/crm test:component` — passed; 9 files and 187 tests. -- `TZ=America/Los_Angeles mise exec -- pnpm --filter @app/crm test:component` — passed; 9 files and - 187 tests with a negative-offset timezone. -- `mise exec -- pnpm --filter @app/crm typecheck` — passed after the fresh worktree's shared - declaration cache was generated. +- `TZ=America/Los_Angeles mise exec -- pnpm --filter @app/crm test:component` — passed; 9 files and 187 tests with a negative-offset timezone. +- `mise exec -- pnpm --filter @app/crm typecheck` — passed after the fresh worktree's shared declaration cache was generated. - `mise exec -- pnpm i18n:boundaries` — passed. -- `mise exec -- pnpm --filter @app/crm build` — compiled the CRM server/client and federated types, - then the expected dirty-worktree release-envelope guard rejected `sourceRevision "workspace"`. +- `mise exec -- pnpm --filter @app/crm build` — compiled the CRM server/client and federated types, then the expected dirty-worktree release-envelope guard rejected `sourceRevision "workspace"`. - `GIT_CEILING_DIRECTORIES=/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-customer-detail-business-fields ULTRAMODERN_SOURCE_REVISION=6cf972b2c3c61c6bb845f4a39980622b76245f23 mise exec -- pnpm --filter @app/crm build` — passed the complete CRM build and packaging pipeline. -- `mise exec -- pnpm check` — initially found `unicorn(prefer-spread)` in the updated component test; - fixed and rerun successfully through every repository gate. +- `mise exec -- pnpm check` — initially found `unicorn(prefer-spread)` in the updated component test; fixed and rerun successfully through every repository gate. - `GIT_CEILING_DIRECTORIES=/Users/jiprochazka/Projects/Programming/TechsioCZ/ontos-customer-detail-business-fields ULTRAMODERN_SOURCE_REVISION=6cf972b2c3c61c6bb845f4a39980622b76245f23 mise exec -- pnpm build` — passed CRM, Shell, Module Federation type assertions, and performance readiness. -- `mise exec -- pnpm --filter @app/crm dev` plus in-app browser review — development build passed; - the standalone route preserved loading/error semantics and had no horizontal page overflow at - 360 px (`scrollWidth = viewportWidth = 360`). +- `mise exec -- pnpm --filter @app/crm dev` plus in-app browser review — development build passed; the standalone route preserved loading/error semantics and had no horizontal page overflow at 360 px (`scrollWidth = viewportWidth = 360`). ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, - `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, - `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, - `docs/architecture/MODULE_MANIFESTS.md`, and `docs/frontend/FRONTEND.md`; reviewed the complete - specification and final diff. The generated Effect client seam, typed UI error mapping, - MicroVertical boundary, page entrypoint, UI-kit/Tailwind rules, and i18n boundary remain intact. -- Fixed the only task-local review finding (the repository's spread-syntax lint rule) and reran the - affected tests plus the complete quality gate. No remaining blocker, skippable, or tech-debt - findings were identified. +- Re-read `../AGENTS.md`, `AGENTS.md`, `docs/architecture/MICROVERTICALS.md`, `docs/architecture/ACTIONS.md`, `docs/architecture/ERRORS.md`, `docs/architecture/ULTRAMODERN.md`, `docs/architecture/MODULE_ENTRYPOINTS.md`, `docs/architecture/MODULE_MANIFESTS.md`, and `docs/frontend/FRONTEND.md`; reviewed the complete specification and final diff. The generated Effect client seam, typed UI error mapping, MicroVertical boundary, page entrypoint, UI-kit/Tailwind rules, and i18n boundary remain intact. +- Fixed the only task-local review finding (the repository's spread-syntax lint rule) and reran the affected tests plus the complete quality gate. No remaining blocker, skippable, or tech-debt findings were identified. - Browser evidence: `.codex/reports/review/feature-crm-customer-detail-business-fields/customer-detail-narrow-error.png`. ### Deviations and Follow-ups -- No implementation deviation. The plain dirty-worktree build cannot emit promotable release - metadata by design; deterministic base-revision builds passed for both CRM and the full workspace. -- The standalone CRM browser session had no authenticated Shell/BFF context, so the ready-state - record could not be exercised there. It correctly rendered the existing retryable error state; - complete, null, archived, contacts, and responsive ready states are covered by the passing - component integration suite. +- No implementation deviation. The plain dirty-worktree build cannot emit promotable release metadata by design; deterministic base-revision builds passed for both CRM and the full workspace. +- The standalone CRM browser session had no authenticated Shell/BFF context, so the ready-state record could not be exercised there. It correctly rendered the existing retryable error state; complete, null, archived, contacts, and responsive ready states are covered by the passing component integration suite. diff --git a/app/specs/feature-crm-customer-detail-page.md b/app/specs/feature-crm-customer-detail-page.md index 798d3aa24..fa4e1bdfd 100644 --- a/app/specs/feature-crm-customer-detail-page.md +++ b/app/specs/feature-crm-customer-detail-page.md @@ -8,58 +8,27 @@ created: 2026-08-14 ## Feature Description -Add the generated CRM MicroVertical page `CustomerDetail` at canonical route -`/crm/customers/:id`, exposed by the locale-aware Shell as `/cs/crm/customers/:id` and -`/en/crm/customers/:id`. The authenticated page presents one Customer using the component -arrangement from Figma page `Pre-Alpha Repo`, frame `Resource Detail — Běžný` (`6:780`): a compact -return link, Customer heading, and responsive overview details inside the existing Shell dashboard -layout. - -The page must obtain its Customer data by executing the CRM contract-derived -`getCustomerDetail` Effect client operation through the CRM BFF. It must not import a backend -handler, read CRM persistence directly, or issue an ad hoc `fetch`. The BFF operation is owned by -the prerequisite plan `specs/feature-crm-customer-contact-actions.md`; this page consumes that -operation and does not create a second Customer-detail contract or endpoint. - -Figma is a wireframe for arrangement only. The implementation uses the installed -`@techsio/ui-kit` components and tokens without copying Figma colors, spacing, typography, borders, -or component styling. +Add the generated CRM MicroVertical page `CustomerDetail` at canonical route `/crm/customers/:id`, exposed by the locale-aware Shell as `/cs/crm/customers/:id` and `/en/crm/customers/:id`. The authenticated page presents one Customer using the component arrangement from Figma page `Pre-Alpha Repo`, frame `Resource Detail — Běžný` (`6:780`): a compact return link, Customer heading, and responsive overview details inside the existing Shell dashboard layout. + +The page must obtain its Customer data by executing the CRM contract-derived `getCustomerDetail` Effect client operation through the CRM BFF. It must not import a backend handler, read CRM persistence directly, or issue an ad hoc `fetch`. The BFF operation is owned by the prerequisite plan `specs/feature-crm-customer-contact-actions.md`; this page consumes that operation and does not create a second Customer-detail contract or endpoint. + +Figma is a wireframe for arrangement only. The implementation uses the installed `@techsio/ui-kit` components and tokens without copying Figma colors, spacing, typography, borders, or component styling. ## User Story -As a signed-in CRM user -I want to open a Customer by its URL and see its current details -So that I can inspect the canonical CRM record without leaving the authenticated dashboard +As a signed-in CRM user I want to open a Customer by its URL and see its current details So that I can inspect the canonical CRM record without leaving the authenticated dashboard ## Problem Statement -CRM persists Customers and has a planned governed Customer-detail read, but it has no Customer -detail page. Users therefore cannot navigate directly to a Customer record through a stable, -localized CRM URL or see typed loading, not-found, forbidden, and unavailable states. +CRM persists Customers and has a planned governed Customer-detail read, but it has no Customer detail page. Users therefore cannot navigate directly to a Customer record through a stable, localized CRM URL or see typed loading, not-found, forbidden, and unavailable states. -The current repository cannot safely generate the requested page yet. The mandatory -`scaffold:microvertical-page` command rejects route parameters, `ShellPageContributionSchema` -accepts only static kebab-case paths, and the generated Shell page connector has no approved typed -route-parameter prop contract for a remote MicroVertical page. Hand-authoring -the dynamic route, manifest registration, Shell connector, or private loader would violate the -Codesmith and module-entrypoint rules. The planned prerequisite -`specs/chore-support-dynamic-microvertical-pages.md` owns that infrastructure change. +The current repository cannot safely generate the requested page yet. The mandatory `scaffold:microvertical-page` command rejects route parameters, `ShellPageContributionSchema` accepts only static kebab-case paths, and the generated Shell page connector has no approved typed route-parameter prop contract for a remote MicroVertical page. Hand-authoring the dynamic route, manifest registration, Shell connector, or private loader would violate the Codesmith and module-entrypoint rules. The planned prerequisite `specs/chore-support-dynamic-microvertical-pages.md` owns that infrastructure change. ## Solution Statement -First implement and validate `specs/chore-support-dynamic-microvertical-pages.md`. After that -prerequisite lands, run the page generator with stable page identity `customer-detail` and canonical URL -`/crm/customers/:id`; do not include the locale in the generator URL. +First implement and validate `specs/chore-support-dynamic-microvertical-pages.md`. After that prerequisite lands, run the page generator with stable page identity `customer-detail` and canonical URL `/crm/customers/:id`; do not include the locale in the generator URL. -Adapt the generated CRM page to receive the bounded `id` route prop only after the Shell gate, -validate it as the Customer UUID, and call `getCustomerDetail` through the CRM Effect BFF client. -Follow the Customers-list feature's page-local TanStack Query pattern to bridge the typed Effect at -the framework edge without ordinary fetching in a React effect. Retain the declared -client error union until it is mapped to a closed presentation model. Use UI-kit `Link`, `Skeleton`, -`StatusText`, and `Button` components for navigation, loading, feedback, and retry. Render the -Customer fields as a semantic description list because the UI kit has no more specific detail-list -component. Do not add inert tabs for Documents, Timeline, or Audit: those peer panels are visible in -the generic Figma wireframe but have no CRM contract in this feature. +Adapt the generated CRM page to receive the bounded `id` route prop only after the Shell gate, validate it as the Customer UUID, and call `getCustomerDetail` through the CRM Effect BFF client. Follow the Customers-list feature's page-local TanStack Query pattern to bridge the typed Effect at the framework edge without ordinary fetching in a React effect. Retain the declared client error union until it is mapped to a closed presentation model. Use UI-kit `Link`, `Skeleton`, `StatusText`, and `Button` components for navigation, loading, feedback, and retry. Render the Customer fields as a semantic description list because the UI kit has no more specific detail-list component. Do not add inert tabs for Documents, Timeline, or Audit: those peer panels are visible in the generic Figma wireframe but have no CRM contract in this feature. ## Relevant Files @@ -118,26 +87,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Implement and validate the planned dynamic-page chore, then run the mandatory page generator. -Complete the Customer operations plan so `getCustomerDetail` and its public DTO/error union are real -contracts rather than page-owned inventions. Complete the Customers-list page so `/crm/customers` -and the CRM-local UI-kit/query dependencies and test infrastructure exist before detail reuses them. +Implement and validate the planned dynamic-page chore, then run the mandatory page generator. Complete the Customer operations plan so `getCustomerDetail` and its public DTO/error union are real contracts rather than page-owned inventions. Complete the Customers-list page so `/crm/customers` and the CRM-local UI-kit/query dependencies and test infrastructure exist before detail reuses them. ### Phase 2: Core Implementation -Adapt the generated remote route-param prop to validate `id`, execute `getCustomerDetail` through the -CRM Effect client inside the CRM query boundary, and map success and the complete client error union -to closed presentation states. -Adapt the generated page presentation to show the Customer name and canonical DTO fields with -localized labels, layout-only Tailwind classes, UI-kit loading/error/retry controls, semantic HTML, -and no mutation affordances. +Adapt the generated remote route-param prop to validate `id`, execute `getCustomerDetail` through the CRM Effect client inside the CRM query boundary, and map success and the complete client error union to closed presentation states. Adapt the generated page presentation to show the Customer name and canonical DTO fields with localized labels, layout-only Tailwind classes, UI-kit loading/error/retry controls, semantic HTML, and no mutation affordances. ### Phase 3: Integration -Verify that Shell authentication, legal-entity selection, module state, and page permission gates -run before any CRM remote or Customer read. Add focused generator, contract, Shell, and browser -coverage for both locales, exact Customer ID propagation, normal/loading/not-found/forbidden/ -unavailable behavior, retry, and mobile layout. Finish with the complete repository quality gate. +Verify that Shell authentication, legal-entity selection, module state, and page permission gates run before any CRM remote or Customer read. Add focused generator, contract, Shell, and browser coverage for both locales, exact Customer ID propagation, normal/loading/not-found/forbidden/ unavailable behavior, retry, and mobile layout. Finish with the complete repository quality gate. ## Step by Step Tasks @@ -202,19 +160,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Rely on the dynamic-page prerequisite's disposable workspaces for filesystem mapping, route schema, -Shell/manifest wiring, collisions, reruns, and atomic failure. Use CRM component tests for route-ID -decoding, exact BFF request construction, query behavior, DTO-to-view mapping, typed failure mapping, -semantic presentation, localization, and source-boundary assertions. Use Shell Rstest coverage for -authorization-before-load ordering and the typed remote parameter contract. +Rely on the dynamic-page prerequisite's disposable workspaces for filesystem mapping, route schema, Shell/manifest wiring, collisions, reruns, and atomic failure. Use CRM component tests for route-ID decoding, exact BFF request construction, query behavior, DTO-to-view mapping, typed failure mapping, semantic presentation, localization, and source-boundary assertions. Use Shell Rstest coverage for authorization-before-load ordering and the typed remote parameter contract. ### Integration Tests -Reuse the prerequisite CRM BFF integration coverage for governed Customer reads, authentication, -tenant isolation, typed Problem Details, and durable Data Access evidence. Add an authenticated -Shell browser flow with a seeded Customer to prove the complete localized URL → Shell gate → -generated remote route-param seam → `getCustomerDetail` BFF → rendered detail path. Browser tests also -cover anonymous non-loading, declared failures, retry, keyboard behavior, and mobile layout. +Reuse the prerequisite CRM BFF integration coverage for governed Customer reads, authentication, tenant isolation, typed Problem Details, and durable Data Access evidence. Add an authenticated Shell browser flow with a seeded Customer to prove the complete localized URL → Shell gate → generated remote route-param seam → `getCustomerDetail` BFF → rendered detail path. Browser tests also cover anonymous non-loading, declared failures, retry, keyboard behavior, and mobile layout. ### Edge Cases diff --git a/app/specs/feature-crm-customer-edit-business-fields.md b/app/specs/feature-crm-customer-edit-business-fields.md index 7542c95ab..129601b6b 100644 --- a/app/specs/feature-crm-customer-edit-business-fields.md +++ b/app/specs/feature-crm-customer-edit-business-fields.md @@ -8,27 +8,19 @@ created: 2026-08-17 ## Feature Description -Update the existing Customer edit page to load, display, validate, and save all canonical Customer -business fields through the controlled Customer form and existing EditCustomerAction. The ARES -loader remains create-only. +Update the existing Customer edit page to load, display, validate, and save all canonical Customer business fields through the controlled Customer form and existing EditCustomerAction. The ARES loader remains create-only. ## User Story -As a CRM user -I want to correct any Customer business field -So that the canonical Customer record remains accurate after creation +As a CRM user I want to correct any Customer business field So that the canonical Customer record remains accurate after creation ## Problem Statement -The edit page currently initializes and submits only `name`. Once the canonical Customer expands, -the page must preserve optional values, allow explicit clearing, classify field/conflict errors, and -include the complete payload in logical idempotency behavior. +The edit page currently initializes and submits only `name`. Once the canonical Customer expands, the page must preserve optional values, allow explicit clearing, classify field/conflict errors, and include the complete payload in logical idempotency behavior. ## Solution Statement -Map the expanded detail response into controlled form strings, submit normalized nullable values -through the generated `editCustomer` Effect client, and update all query-cache, error, idempotency, -and component tests. Do not add ARES lookup controls or a separate ARES section. +Map the expanded detail response into controlled form strings, submit normalized nullable values through the generated `editCustomer` Effect client, and update all query-cache, error, idempotency, and component tests. Do not add ARES lookup controls or a separate ARES section. ## Relevant Files @@ -47,18 +39,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Consume the expanded read/Action contracts and controlled form without changing the generated page, -route, federation, or Shell identity. +Consume the expanded read/Action contracts and controlled form without changing the generated page, route, federation, or Shell identity. ### Phase 2: Core Implementation -Map nullable DTO values to form values, normalize changed values back to the edit payload, and -preserve typed error and idempotency semantics across every field. +Map nullable DTO values to form values, normalize changed values back to the edit payload, and preserve typed error and idempotency semantics across every field. ### Phase 3: Integration -Complete localized field/conflict states, cache updates, responsive/accessibility behavior, and -focused page plus real BFF tests. +Complete localized field/conflict states, cache updates, responsive/accessibility behavior, and focused page plus real BFF tests. ## Step by Step Tasks @@ -98,13 +87,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use Testing Library with mocked generated Effect clients to cover read mapping, controlled edits, -nullable normalization, every explicit state, idempotency, cache, and navigation. +Use Testing Library with mocked generated Effect clients to cover read mapping, controlled edits, nullable normalization, every explicit state, idempotency, cache, and navigation. ### Integration Tests -Use the strict CRM BFF and real EditCustomerAction to prove complete persistence, null clearing, -tenant isolation, and duplicate-IČO conflict. +Use the strict CRM BFF and real EditCustomerAction to prove complete persistence, null clearing, tenant isolation, and duplicate-IČO conflict. ### Edge Cases diff --git a/app/specs/feature-crm-customer-edit-page.md b/app/specs/feature-crm-customer-edit-page.md index 8f2b207e4..a95f42139 100644 --- a/app/specs/feature-crm-customer-edit-page.md +++ b/app/specs/feature-crm-customer-edit-page.md @@ -8,54 +8,25 @@ created: 2026-08-14 ## Feature Description -Add the generated CRM `CustomerEdit` page at localized URL -`/cs/crm/customers/:id/edit` (canonical generator URL `/crm/customers/:id/edit`). The authenticated -Shell continues to own dashboard/sidebar composition and exact page gating. The remote CRM page -uses the route Customer ID to load the current Customer through the contract-derived CRM Effect BFF -client, renders a reusable Customer form, and submits the changed name through the generated -`editCustomer` client method so the final state change executes `EditCustomerAction` through the -CRM BFF. - -Use Figma page `Pre-Alpha Repo`, screen `Resource Detail - Běžný`, only as a wireframe: preserve the -back-link, heading, and primary content arrangement, and replace the read-only detail rows with form -controls. Do not copy Figma styling or add inert resource-detail tabs. Use the installed -`@techsio/ui-kit` `FormInput`, `Button`, `Link`, and `StatusText` components with existing tokens and -Tailwind only for responsive layout composition. - -The form itself must be a separate owner-private `CustomerForm` presentation component so a future -Customer-create page can reuse the same field, validation, pending, form-status, cancel, and submit -contract. It receives plain values/states and semantic callbacks; it does not read route params, -navigate, call the BFF, run Effects, access permissions, or depend on BFF/domain error types. +Add the generated CRM `CustomerEdit` page at localized URL `/cs/crm/customers/:id/edit` (canonical generator URL `/crm/customers/:id/edit`). The authenticated Shell continues to own dashboard/sidebar composition and exact page gating. The remote CRM page uses the route Customer ID to load the current Customer through the contract-derived CRM Effect BFF client, renders a reusable Customer form, and submits the changed name through the generated `editCustomer` client method so the final state change executes `EditCustomerAction` through the CRM BFF. + +Use Figma page `Pre-Alpha Repo`, screen `Resource Detail - Běžný`, only as a wireframe: preserve the back-link, heading, and primary content arrangement, and replace the read-only detail rows with form controls. Do not copy Figma styling or add inert resource-detail tabs. Use the installed `@techsio/ui-kit` `FormInput`, `Button`, `Link`, and `StatusText` components with existing tokens and Tailwind only for responsive layout composition. + +The form itself must be a separate owner-private `CustomerForm` presentation component so a future Customer-create page can reuse the same field, validation, pending, form-status, cancel, and submit contract. It receives plain values/states and semantic callbacks; it does not read route params, navigate, call the BFF, run Effects, access permissions, or depend on BFF/domain error types. ## User Story -As an authenticated CRM user with write access -I want to edit a Customer's name on a dedicated localized page -So that I can correct the canonical Customer record through the governed CRM Action boundary +As an authenticated CRM user with write access I want to edit a Customer's name on a dedicated localized page So that I can correct the canonical Customer record through the governed CRM Action boundary ## Problem Statement -CRM has persistence and a planned typed Customer read/edit BFF, but no user-facing edit route. The -mandatory page generator cannot currently express the requested dynamic URL, and frontend code must -not bypass the generated page entrypoint or call a backend handler/ad hoc fetch. The form also needs -an explicit reusable presentation boundary or a future create flow would duplicate validation and -UI behavior. +CRM has persistence and a planned typed Customer read/edit BFF, but no user-facing edit route. The mandatory page generator cannot currently express the requested dynamic URL, and frontend code must not bypass the generated page entrypoint or call a backend handler/ad hoc fetch. The form also needs an explicit reusable presentation boundary or a future create flow would duplicate validation and UI behavior. ## Solution Statement -After dynamic page support and the existing Customer operations plan are implemented, run the -mandatory page generator with stable identity `customer-edit` and canonical URL -`/crm/customers/:id/edit`. Adapt only the generated owner page and wiring. Pass the declared `id` -route parameter to CRM feature integration, use the generated `getCustomerDetail` Effect client to -obtain initial values, and use a scoped TanStack Query integration (added to the CRM package) for -explicit loading, retry, not-found, forbidden, unavailable, and success states without ordinary -fetching in a React effect. +After dynamic page support and the existing Customer operations plan are implemented, run the mandatory page generator with stable identity `customer-edit` and canonical URL `/crm/customers/:id/edit`. Adapt only the generated owner page and wiring. Pass the declared `id` route parameter to CRM feature integration, use the generated `getCustomerDetail` Effect client to obtain initial values, and use a scoped TanStack Query integration (added to the CRM package) for explicit loading, retry, not-found, forbidden, unavailable, and success states without ordinary fetching in a React effect. -Render the owner-private `CustomerForm` with the one authoritative Customer business field, -`name`. Validate a trimmed non-empty name, preserve accessible field/form errors, and submit through -`editCustomer` with one idempotency key per logical submission. Exhaustively map the typed client -error union before rendering. On success, update/invalidate the cached Customer detail and navigate -to the localized generated Customers list without changing `CustomerForm`. +Render the owner-private `CustomerForm` with the one authoritative Customer business field, `name`. Validate a trimmed non-empty name, preserve accessible field/form errors, and submit through `editCustomer` with one idempotency key per logical submission. Exhaustively map the typed client error union before rendering. On success, update/invalidate the cached Customer detail and navigate to the localized generated Customers list without changing `CustomerForm`. ## Relevant Files @@ -99,27 +70,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Confirm `specs/chore-support-dynamic-microvertical-pages.md`, -`specs/feature-crm-customer-contact-actions.md`, and -`specs/feature-crm-customers-list-page.md` are implemented. Then generate `customer-edit` at the -canonical parameterized URL before adapting any page/wiring file. Reuse the list page's CRM-owned -UI/query/test infrastructure. Create `customer-form.tsx` as the explicitly approved owner-private -ordinary React presentation component; do not publish it as a module entrypoint. +Confirm `specs/chore-support-dynamic-microvertical-pages.md`, `specs/feature-crm-customer-contact-actions.md`, and `specs/feature-crm-customers-list-page.md` are implemented. Then generate `customer-edit` at the canonical parameterized URL before adapting any page/wiring file. Reuse the list page's CRM-owned UI/query/test infrastructure. Create `customer-form.tsx` as the explicitly approved owner-private ordinary React presentation component; do not publish it as a module entrypoint. ### Phase 2: Core Implementation -Implement `CustomerForm` as a controlled presentation contract using `FormInput`, primary/secondary -`Button`s, and `StatusText`, with no routing, BFF, Effect, or permission dependency. Adapt the -generated CustomerEdit page to load current values through `getCustomerDetail`, preserve the typed -client error union at the query edge, gate editing on `target.writable`, and submit through -`editCustomer` with correct idempotency and retry behavior. +Implement `CustomerForm` as a controlled presentation contract using `FormInput`, primary/secondary `Button`s, and `StatusText`, with no routing, BFF, Effect, or permission dependency. Adapt the generated CustomerEdit page to load current values through `getCustomerDetail`, preserve the typed client error union at the query edge, gate editing on `target.writable`, and submit through `editCustomer` with correct idempotency and retry behavior. ### Phase 3: Integration -Complete English/Czech copy, generated manifest/registration/federation/Shell wiring, responsive and -accessible state rendering, post-save navigation, focused component/feature tests, exact contract -checks, and the independent CRM build. Keep the real Action/BFF proof in the prerequisite CRM -integration suite until repository browser orchestration can start both deployments. +Complete English/Czech copy, generated manifest/registration/federation/Shell wiring, responsive and accessible state rendering, post-save navigation, focused component/feature tests, exact contract checks, and the independent CRM build. Keep the real Action/BFF proof in the prerequisite CRM integration suite until repository browser orchestration can start both deployments. ## Step by Step Tasks @@ -175,21 +134,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use focused Rstest/Testing Library coverage for the reusable `CustomerForm` contract and -CustomerEdit integration. Exercise accessible validation/focus/keyboard behavior, initial query -states, `target.writable`, exhaustive typed query/mutation error mapping, exact BFF payload and -idempotency behavior, cache/navigation results, and the form's lack of routing/BFF dependencies. -Retain the existing CRM Node unit suite for schemas/contracts and extend generated page/manifest -assertions only where necessary. +Use focused Rstest/Testing Library coverage for the reusable `CustomerForm` contract and CustomerEdit integration. Exercise accessible validation/focus/keyboard behavior, initial query states, `target.writable`, exhaustive typed query/mutation error mapping, exact BFF payload and idempotency behavior, cache/navigation results, and the form's lack of routing/BFF dependencies. Retain the existing CRM Node unit suite for schemas/contracts and extend generated page/manifest assertions only where necessary. ### Integration Tests -Rely on `specs/feature-crm-customer-contact-actions.md` for real Action/Read runtime and strict BFF -integration. A new Playwright integration test is not required in this increment because the -researched browser configuration starts only the Shell command and does not orchestrate the -independently deployable CRM remote/BFF. Focused UI tests cover page interaction while the -prerequisite real-BFF suite proves durable reads/writes; add a cross-deployment browser proof later -when repository-owned orchestration exists rather than mocking or weakening the seam. +Rely on `specs/feature-crm-customer-contact-actions.md` for real Action/Read runtime and strict BFF integration. A new Playwright integration test is not required in this increment because the researched browser configuration starts only the Shell command and does not orchestrate the independently deployable CRM remote/BFF. Focused UI tests cover page interaction while the prerequisite real-BFF suite proves durable reads/writes; add a cross-deployment browser proof later when repository-owned orchestration exists rather than mocking or weakening the seam. ### Edge Cases @@ -245,47 +194,21 @@ Execute every command to validate the feature with zero regressions. ## Notes -- Implementation order is: dynamic page generator chore; existing Customer Action/Read/BFF feature; - generated Customers list page; then this page feature. -- Stable generator identity is lower-kebab `customer-edit`, producing `CustomerEditPage`; the locale - is router-owned, so the generator receives `/crm/customers/:id/edit`, not `/cs/...`. +- Implementation order is: dynamic page generator chore; existing Customer Action/Read/BFF feature; generated Customers list page; then this page feature. +- Stable generator identity is lower-kebab `customer-edit`, producing `CustomerEditPage`; the locale is router-owned, so the generator receives `/crm/customers/:id/edit`, not `/cs/...`. - Customer has exactly one editable business field, `name`, under the completed persistence contract. -- The concurrently planned generated Customers list page owns `/crm/customers`; this feature depends - on it for a concrete, localized Back/Cancel/success destination and reuses its CRM UI/query/test - setup. -- TanStack Query is selected because the remote page is lazy-loaded inside the authenticated Shell, - the current gateway does not execute an owner route loader, and frontend guidance forbids ordinary - route fetching in a React effect. The query adapter remains the thin Promise edge around typed - CRM client Effects. -- Resolved developer decision (2026-08-15): create `CustomerForm` directly as an ordinary - owner-private React presentation component. It remains private to CRM and must not use the - public-component generator, module registration/manifest/federation, or a Shell entrypoint. -- Follow-up: the current Playwright server starts only the Shell command. Add a cross-deployment - browser proof after repository-owned orchestration supplies the independently deployable CRM - BFF/remote; do not weaken the deployment seam or silently replace that proof with a mocked browser - request. +- The concurrently planned generated Customers list page owns `/crm/customers`; this feature depends on it for a concrete, localized Back/Cancel/success destination and reuses its CRM UI/query/test setup. +- TanStack Query is selected because the remote page is lazy-loaded inside the authenticated Shell, the current gateway does not execute an owner route loader, and frontend guidance forbids ordinary route fetching in a React effect. The query adapter remains the thin Promise edge around typed CRM client Effects. +- Resolved developer decision (2026-08-15): create `CustomerForm` directly as an ordinary owner-private React presentation component. It remains private to CRM and must not use the public-component generator, module registration/manifest/federation, or a Shell entrypoint. +- Follow-up: the current Playwright server starts only the Shell command. Add a cross-deployment browser proof after repository-owned orchestration supplies the independently deployable CRM BFF/remote; do not weaken the deployment seam or silently replace that proof with a mocked browser request. ## Implementation Evidence -- Mandatory Codesmith generation completed from `app/` with - `mise exec -- pnpm scaffold:microvertical-page -- --vertical crm --page customer-edit --url /crm/customers/:id/edit`. -- Focused tests passed: generator 35/35, CRM unit 20/20, CRM component 46/46, CRM real-BFF - integration 3/3, and Shell unit 152/152. -- The exact CRM typecheck passed after building its declared project-reference prerequisites. The - i18n, API, database-access, module-entrypoint, module-contract, full `pnpm check`, exact CRM build, - and aggregate workspace build commands all passed. -- The exact CRM build emitted `dist/@mf-types.zip`. The federation DTS boundary now includes the - generated shared API contract so declaration generation covers its real dependency graph while - retaining the validated narrow boundary. -- Browser review used the built CRM deployment for malformed and valid UUID routes, retry behavior, - localized states, and a 390×844 mobile viewport with no horizontal overflow. The repository's - cross-deployment browser proof remains the explicit follow-up described above; focused component - tests and the real-BFF integration suite are the authoritative automated proof for this increment. -- Integration validation used a disposable PostgreSQL container because environment files were not - read. The container was removed after the suite; no project data was changed. -- Builds used an explicit immutable validation revision because the release-envelope guard rejects - the intentional dirty worktree's default `workspace` revision. -- Final review against `../AGENTS.md`, `AGENTS.md`, the referenced architecture/frontend documents, - and this specification found no unresolved findings after fixing fail-closed standalone write - capability, rejected-submit guard release, live-status placement, generator nested-property - parsing, and federation declaration coverage. +- Mandatory Codesmith generation completed from `app/` with `mise exec -- pnpm scaffold:microvertical-page -- --vertical crm --page customer-edit --url /crm/customers/:id/edit`. +- Focused tests passed: generator 35/35, CRM unit 20/20, CRM component 46/46, CRM real-BFF integration 3/3, and Shell unit 152/152. +- The exact CRM typecheck passed after building its declared project-reference prerequisites. The i18n, API, database-access, module-entrypoint, module-contract, full `pnpm check`, exact CRM build, and aggregate workspace build commands all passed. +- The exact CRM build emitted `dist/@mf-types.zip`. The federation DTS boundary now includes the generated shared API contract so declaration generation covers its real dependency graph while retaining the validated narrow boundary. +- Browser review used the built CRM deployment for malformed and valid UUID routes, retry behavior, localized states, and a 390×844 mobile viewport with no horizontal overflow. The repository's cross-deployment browser proof remains the explicit follow-up described above; focused component tests and the real-BFF integration suite are the authoritative automated proof for this increment. +- Integration validation used a disposable PostgreSQL container because environment files were not read. The container was removed after the suite; no project data was changed. +- Builds used an explicit immutable validation revision because the release-envelope guard rejects the intentional dirty worktree's default `workspace` revision. +- Final review against `../AGENTS.md`, `AGENTS.md`, the referenced architecture/frontend documents, and this specification found no unresolved findings after fixing fail-closed standalone write capability, rejected-submit guard release, live-status placement, generator nested-property parsing, and federation declaration coverage. diff --git a/app/specs/feature-crm-customer-form-business-fields.md b/app/specs/feature-crm-customer-form-business-fields.md index 0bf5b4805..a956efae8 100644 --- a/app/specs/feature-crm-customer-form-business-fields.md +++ b/app/specs/feature-crm-customer-form-business-fields.md @@ -8,28 +8,19 @@ created: 2026-08-17 ## Feature Description -Expand the existing owner-private `CustomerForm` with all approved Customer business fields and -make it explicitly controlled so a parent can apply ARES-prefilled values after mount without a -React effect that copies props into local state. +Expand the existing owner-private `CustomerForm` with all approved Customer business fields and make it explicitly controlled so a parent can apply ARES-prefilled values after mount without a React effect that copies props into local state. ## User Story -As a CRM user -I want one consistent Customer form for manual, prefilled, create, and edit flows -So that I can review and correct every Customer business field before saving +As a CRM user I want one consistent Customer form for manual, prefilled, create, and edit flows So that I can review and correct every Customer business field before saving ## Problem Statement -`CustomerForm` currently owns only a `name` state initialized once from props. Updating its initial -values after an ARES response would not update the mounted form, and duplicating the form for create -or edit would violate the established presentation boundary. +`CustomerForm` currently owns only a `name` state initialized once from props. Updating its initial values after an ARES response would not update the mounted form, and duplicating the form for create or edit would violate the established presentation boundary. ## Solution Statement -Refactor `CustomerForm` to a controlled plain-value contract and compose existing UI-kit -`FormInput`, `Button`, and `StatusText` components for `name`, `ico`, `dic`, `legalFormCode`, -`establishedOn`, and `dissolvedOn`. Keep BFF, Effect, route, permissions, and domain error types in -the owning pages. +Refactor `CustomerForm` to a controlled plain-value contract and compose existing UI-kit `FormInput`, `Button`, and `StatusText` components for `name`, `ico`, `dic`, `legalFormCode`, `establishedOn`, and `dissolvedOn`. Keep BFF, Effect, route, permissions, and domain error types in the owning pages. ## Relevant Files @@ -47,18 +38,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Define a plain controlled `CustomerFormValues`/copy/error contract matching canonical editable -fields, with no backend types or application behavior. +Define a plain controlled `CustomerFormValues`/copy/error contract matching canonical editable fields, with no backend types or application behavior. ### Phase 2: Core Implementation -Compose UI-kit inputs, normalized validation, invalid-field focus, submit guards, and responsive -actions while removing one-time prop-to-state ownership. +Compose UI-kit inputs, normalized validation, invalid-field focus, submit guards, and responsive actions while removing one-time prop-to-state ownership. ### Phase 3: Integration -Temporarily adapt both owning pages to the controlled interface, add matching locale copy, and prove -the form can accept a parent-driven prefill without losing accessibility or edit behavior. +Temporarily adapt both owning pages to the controlled interface, add matching locale copy, and prove the form can accept a parent-driven prefill without losing accessibility or edit behavior. ## Step by Step Tasks @@ -96,14 +84,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use Rstest/Testing Library to render the form with plain props and prove controlled prefill, all -validation, semantic emissions, focus, keyboard, pending, accessible descriptions, and responsive -composition contracts. +Use Rstest/Testing Library to render the form with plain props and prove controlled prefill, all validation, semantic emissions, focus, keyboard, pending, accessible descriptions, and responsive composition contracts. ### Integration Tests -Not required beyond adapting existing create/edit component tests: BFF mutations are handled by the -dedicated create/edit specs. +Not required beyond adapting existing create/edit component tests: BFF mutations are handled by the dedicated create/edit specs. ### Edge Cases diff --git a/app/specs/feature-crm-customers-list-page.md b/app/specs/feature-crm-customers-list-page.md index d533b68aa..33485d298 100644 --- a/app/specs/feature-crm-customers-list-page.md +++ b/app/specs/feature-crm-customers-list-page.md @@ -8,61 +8,27 @@ created: 2026-08-14 ## Feature Description -Add a generated CRM MicroVertical page named `CustomersListPage` at the canonical path -`/crm/customers`, exposed by the localized Shell as `/cs/crm/customers` and -`/en/crm/customers`. The page gives an authenticated, permitted CRM user a compact overview of -Customers using the installed `@techsio/ui-kit@0.25.1` semantic Table and supporting UI-kit -components. - -Use Figma file `ERP`, page `Pre-Alpha Repo`, frame `Audit Log — Naplněný` (`6:1042`) only as a -wireframe for the content arrangement: page heading, compact filter controls, tabular results, and -pagination controls. The authenticated Shell already owns the sidebar, tenant/legal-entity -selectors, global search, header, and account menu, so the CRM remote must render only the page -content and must not duplicate that chrome or copy Figma colors and fixed measurements. - -The populated list is loaded through the CRM MicroVertical's generated Effect BFF client. The -requested `GetCustomersAction` terminology maps to the architecture-compliant governed Customer -list read exposed as `getCustomerList`; it is not implemented as a state-changing OntOS Action. +Add a generated CRM MicroVertical page named `CustomersListPage` at the canonical path `/crm/customers`, exposed by the localized Shell as `/cs/crm/customers` and `/en/crm/customers`. The page gives an authenticated, permitted CRM user a compact overview of Customers using the installed `@techsio/ui-kit@0.25.1` semantic Table and supporting UI-kit components. + +Use Figma file `ERP`, page `Pre-Alpha Repo`, frame `Audit Log — Naplněný` (`6:1042`) only as a wireframe for the content arrangement: page heading, compact filter controls, tabular results, and pagination controls. The authenticated Shell already owns the sidebar, tenant/legal-entity selectors, global search, header, and account menu, so the CRM remote must render only the page content and must not duplicate that chrome or copy Figma colors and fixed measurements. + +The populated list is loaded through the CRM MicroVertical's generated Effect BFF client. The requested `GetCustomersAction` terminology maps to the architecture-compliant governed Customer list read exposed as `getCustomerList`; it is not implemented as a state-changing OntOS Action. ## User Story -As an authenticated CRM user -I want to view and page through Customers in a clear table -So that I can quickly understand which Customer records exist and whether they are active or -archived +As an authenticated CRM user I want to view and page through Customers in a clear table So that I can quickly understand which Customer records exist and whether they are active or archived ## Problem Statement -CRM has persistence for Customers but no dedicated Customers page. The generated `/crm` starter -shows only a placeholder heading, and the current CRM package does not declare or load the UI kit. -The Customer list read and typed BFF client are now implemented, but frontend code still needs an -approved generated page, typed client integration, explicit user-facing states, localized copy, -and semantic table presentation. Direct database access, a backend implementation import, an ad -hoc `fetch`, or a getter modeled as an Action would violate the MicroVertical and governed-read -boundaries. +CRM has persistence for Customers but no dedicated Customers page. The generated `/crm` starter shows only a placeholder heading, and the current CRM package does not declare or load the UI kit. The Customer list read and typed BFF client are now implemented, but frontend code still needs an approved generated page, typed client integration, explicit user-facing states, localized copy, and semantic table presentation. Direct database access, a backend implementation import, an ad hoc `fetch`, or a getter modeled as an Action would violate the MicroVertical and governed-read boundaries. ## Solution Statement -Run the mandatory MicroVertical page generator with stable identity `customers-list` and canonical -URL `/crm/customers`, producing `CustomersListPage` and all CRM/Shell manifest, registration, -Module Federation, route, and locale wiring before adapting the generated page. Add the pinned UI -kit to CRM and load its package token/theme output while preserving CRM-prefixed Tailwind layout -utilities. Reuse `Table`, `Select`, `Badge`, `Skeleton`, `StatusText`, `Button`, and `LinkButton` -from their public package subpaths; do not recreate or restyle their primitives. - -Keep query integration and pure presentation separate inside the generated page module so no -unsupported business file type is hand-authored. Because a Shell-composed federated page loads -after the Shell route loader and the repository forbids ordinary data fetching in a React effect, -use a page-local TanStack Query provider/hook as the framework edge. Its query function bridges the -typed `getCustomerList` Effect to the Promise required by the query library while retaining the -operation-specific error union for exhaustive UI mapping. - -Treat `status=active|archived|all` and a non-negative `offset` as shareable URL state, with active -and zero as safe defaults, and request a fixed page size of 25. Render name, Customer ID, lifecycle -status, creation time, and update time in a semantic UI-kit Table. Use previous/next URL navigation -derived from `offset` and the BFF's nullable `nextOffset`; do not fabricate a total count for the -UI-kit Pagination component. Present loading, populated, empty, forbidden, and unavailable/retry -states explicitly and localize all visible and accessibility copy in English and Czech. +Run the mandatory MicroVertical page generator with stable identity `customers-list` and canonical URL `/crm/customers`, producing `CustomersListPage` and all CRM/Shell manifest, registration, Module Federation, route, and locale wiring before adapting the generated page. Add the pinned UI kit to CRM and load its package token/theme output while preserving CRM-prefixed Tailwind layout utilities. Reuse `Table`, `Select`, `Badge`, `Skeleton`, `StatusText`, `Button`, and `LinkButton` from their public package subpaths; do not recreate or restyle their primitives. + +Keep query integration and pure presentation separate inside the generated page module so no unsupported business file type is hand-authored. Because a Shell-composed federated page loads after the Shell route loader and the repository forbids ordinary data fetching in a React effect, use a page-local TanStack Query provider/hook as the framework edge. Its query function bridges the typed `getCustomerList` Effect to the Promise required by the query library while retaining the operation-specific error union for exhaustive UI mapping. + +Treat `status=active|archived|all` and a non-negative `offset` as shareable URL state, with active and zero as safe defaults, and request a fixed page size of 25. Render name, Customer ID, lifecycle status, creation time, and update time in a semantic UI-kit Table. Use previous/next URL navigation derived from `offset` and the BFF's nullable `nextOffset`; do not fabricate a total count for the UI-kit Pagination component. Present loading, populated, empty, forbidden, and unavailable/retry states explicitly and localize all visible and accessibility copy in English and Czech. ## Relevant Files @@ -115,25 +81,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Generate the exact CRM page and Shell connector first. Then make CRM an independent consumer of the -pinned UI kit, add the client-query runtime needed by a federated list with retry/pagination, and -add focused component-test infrastructure using the same versions and configuration style already -used by the Shell. Preserve generated owner slots and CRM's `crm:` Tailwind namespace. +Generate the exact CRM page and Shell connector first. Then make CRM an independent consumer of the pinned UI kit, add the client-query runtime needed by a federated list with retry/pagination, and add focused component-test infrastructure using the same versions and configuration style already used by the Shell. Preserve generated owner slots and CRM's `crm:` Tailwind namespace. ### Phase 2: Core Implementation -Adapt only the generated page module for business UI. Parse bounded URL query state, call the -generated `getCustomerList` Effect through the BFF facade, exhaustively convert its success/error -channels into a closed presentation model, and render the Figma-inspired arrangement from UI-kit -components. Add tests beside the behavior for loading, populated, active/archived status, empty, -forbidden, unavailable/retry, invalid URL values, pagination, table semantics, and keyboard use. +Adapt only the generated page module for business UI. Parse bounded URL query state, call the generated `getCustomerList` Effect through the BFF facade, exhaustively convert its success/error channels into a closed presentation model, and render the Figma-inspired arrangement from UI-kit components. Add tests beside the behavior for loading, populated, active/archived status, empty, forbidden, unavailable/retry, invalid URL values, pagination, table semantics, and keyboard use. ### Phase 3: Integration -Complete English/Czech catalogs and metadata, prove the authenticated Shell resolves and lazily -loads the exact page at both localized URLs, and verify the browser issues the Customer list -operation through the CRM BFF before rendering rows. Test anonymous guarding, retry, mobile layout, -and the independently deployable CRM build, then run all repository boundaries and the final gate. +Complete English/Czech catalogs and metadata, prove the authenticated Shell resolves and lazily loads the exact page at both localized URLs, and verify the browser issues the Customer list operation through the CRM BFF before rendering rows. Test anonymous guarding, retry, mobile layout, and the independently deployable CRM build, then run all repository boundaries and the final gate. ## Step by Step Tasks @@ -192,21 +148,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Retain the existing Node unit tests for the `getCustomerList` schemas and typed client. Add CRM -RSTest/happy-dom component coverage around the generated page module with the generated BFF client -mocked at its public Effect seam. Test URL parsing, query keys and exact payloads, exhaustive error -mapping, retry behavior, view-model formatting, UI-kit Table semantics, filters, lifecycle badges, -localized copy, focus, and responsive overflow. Test presentation from plain props/query outcomes; -do not mock or import backend services. +Retain the existing Node unit tests for the `getCustomerList` schemas and typed client. Add CRM RSTest/happy-dom component coverage around the generated page module with the generated BFF client mocked at its public Effect seam. Test URL parsing, query keys and exact payloads, exhaustive error mapping, retry behavior, view-model formatting, UI-kit Table semantics, filters, lifecycle badges, localized copy, focus, and responsive overflow. Test presentation from plain props/query outcomes; do not mock or import backend services. ### Integration Tests -Use the existing CRM in-process BFF integration tests to prove the actual governed Customer -list read, assertion, typed errors, tenant isolation, evidence, and client decoding. Use Shell unit -tests for exact entrypoint resolution and lazy loading, then Playwright for the complete localized -Shell → Module Federation page → generated CRM BFF client flow. Browser tests may control BFF -outcomes at the network seam for deterministic empty/error/retry presentation, while at least one -populated path must exercise the real generated endpoint/client contract. +Use the existing CRM in-process BFF integration tests to prove the actual governed Customer list read, assertion, typed errors, tenant isolation, evidence, and client decoding. Use Shell unit tests for exact entrypoint resolution and lazy loading, then Playwright for the complete localized Shell → Module Federation page → generated CRM BFF client flow. Browser tests may control BFF outcomes at the network seam for deterministic empty/error/retry presentation, while at least one populated path must exercise the real generated endpoint/client contract. ### Edge Cases diff --git a/app/specs/feature-deployment-safe-ontos-module-manifest.md b/app/specs/feature-deployment-safe-ontos-module-manifest.md index f9ed2bfdc..f97edc9d5 100644 --- a/app/specs/feature-deployment-safe-ontos-module-manifest.md +++ b/app/specs/feature-deployment-safe-ontos-module-manifest.md @@ -8,95 +8,43 @@ created: 2026-08-06 ## Feature Description -Add the missing OntOS Module Manifest contract without weakening the independently deployable -MicroVertical boundary required by `app/docs/architecture/MICROVERTICALS.md`. - -Each MicroVertical will author one typed manifest as an Effect Schema-validated TypeScript value. -The manifest will describe its OntOS module identity, activation rules, dependencies, public -Actions, public Effect API, Module Federation component surface, resource types, public events, -search descriptors, and report descriptors. The owning deployment will keep executable handlers, -workers, migrations, routes, and implementation bindings in a private owner-local runtime -registration. - -A build command will derive a serializable, deployment-safe contract document from the typed -manifest, generated package metadata, public API contract, Module Federation configuration, and -private registration descriptors. Each MicroVertical deployment will serve this immutable document -at a generated well-known path. The Shell will receive an explicit deployment allowlist containing -only known topology application IDs and contract URLs, fetch and Effect-decode those documents, and -assemble a Shell/Core-owned installed-module catalog without importing another deployment's private -registration or source code. +Add the missing OntOS Module Manifest contract without weakening the independently deployable MicroVertical boundary required by `app/docs/architecture/MICROVERTICALS.md`. + +Each MicroVertical will author one typed manifest as an Effect Schema-validated TypeScript value. The manifest will describe its OntOS module identity, activation rules, dependencies, public Actions, public Effect API, Module Federation component surface, resource types, public events, search descriptors, and report descriptors. The owning deployment will keep executable handlers, workers, migrations, routes, and implementation bindings in a private owner-local runtime registration. + +A build command will derive a serializable, deployment-safe contract document from the typed manifest, generated package metadata, public API contract, Module Federation configuration, and private registration descriptors. Each MicroVertical deployment will serve this immutable document at a generated well-known path. The Shell will receive an explicit deployment allowlist containing only known topology application IDs and contract URLs, fetch and Effect-decode those documents, and assemble a Shell/Core-owned installed-module catalog without importing another deployment's private registration or source code. The feature must keep the two identities distinct: -- the UltraModern topology application ID identifies a deployment and remains the Module - Federation remote and Shell gateway assertion audience, for example `property-registry`; -- the OntOS module ID identifies the business capability and remains the Action owner, tenant - module-state key, resource owner, event producer, Policy owner, and Outbox owner, for example - `property.registry`. +- the UltraModern topology application ID identifies a deployment and remains the Module Federation remote and Shell gateway assertion audience, for example `property-registry`; +- the OntOS module ID identifies the business capability and remains the Action owner, tenant module-state key, resource owner, event producer, Policy owner, and Outbox owner, for example `property.registry`. -All local Codesmith generators must be updated where they currently infer business ownership from -the topology application ID or maintain a source-time cross-deployment registry. +All local Codesmith generators must be updated where they currently infer business ownership from the topology application ID or maintain a source-time cross-deployment registry. ## User Story -As an OntOS platform developer -I want each independently deployed MicroVertical to publish a validated module contract -So that Shell/Core can safely reason about installed modules, capabilities, dependencies, and -tenant activation without linking private MicroVertical implementations into the Shell +As an OntOS platform developer I want each independently deployed MicroVertical to publish a validated module contract So that Shell/Core can safely reason about installed modules, capabilities, dependencies, and tenant activation without linking private MicroVertical implementations into the Shell ## Problem Statement -The `develop` branch has persisted tenant module state and an authenticated Shell operation for -listing active modules, but it has no OntOS Module Manifest schema, authored manifest value, -deployment contract, runtime-registration contract, or installed-module descriptor catalog. -`apps/shell-super-app/api/verticals/installed-verticals.ts` currently derives installed identifiers -directly from `topology/reference-topology.json` and treats topology application IDs as the values -stored in `core.tenant_module_states.module_key`. - -That implementation cannot represent the distinction between deployment ID `property-registry` -and module ID `property.registry`, cannot validate dependencies or public surfaces, and cannot tell -Core which module capabilities are actually present. The current Action, Policy, Outbox Message, -and Outbox Worker generators repeat the same identity error by emitting the target vertical's -`modernjs.appId` as `owningModuleKey`, producer/consumer module key, and Action/worker key prefix. - -The older repository-level manifest design also assumes that the Shell statically imports every -`vertical.registration.ts`. That would place routes, handlers, migrations, workers, and other -private implementation hooks in a jointly linked process. It conflicts with the authoritative -`app/` rule that every MicroVertical must remain independently deployable and that consumers cross -the seam only through generated Effect clients, published Outbox schemas, and Module Federation -exposures. - -The Outbox generator has a related deployment coupling: -`scripts/scaffolding/outbox-worker/scaffold.mts` scans all vertical source trees and rewrites -`packages/core-runtime/src/outbox/subscriptions.generated.ts`. Every independently deployed worker -therefore depends on a complete source-time catalog and must be rebuilt when an unrelated vertical -adds a worker. +The `develop` branch has persisted tenant module state and an authenticated Shell operation for listing active modules, but it has no OntOS Module Manifest schema, authored manifest value, deployment contract, runtime-registration contract, or installed-module descriptor catalog. `apps/shell-super-app/api/verticals/installed-verticals.ts` currently derives installed identifiers directly from `topology/reference-topology.json` and treats topology application IDs as the values stored in `core.tenant_module_states.module_key`. + +That implementation cannot represent the distinction between deployment ID `property-registry` and module ID `property.registry`, cannot validate dependencies or public surfaces, and cannot tell Core which module capabilities are actually present. The current Action, Policy, Outbox Message, and Outbox Worker generators repeat the same identity error by emitting the target vertical's `modernjs.appId` as `owningModuleKey`, producer/consumer module key, and Action/worker key prefix. + +The older repository-level manifest design also assumes that the Shell statically imports every `vertical.registration.ts`. That would place routes, handlers, migrations, workers, and other private implementation hooks in a jointly linked process. It conflicts with the authoritative `app/` rule that every MicroVertical must remain independently deployable and that consumers cross the seam only through generated Effect clients, published Outbox schemas, and Module Federation exposures. + +The Outbox generator has a related deployment coupling: `scripts/scaffolding/outbox-worker/scaffold.mts` scans all vertical source trees and rewrites `packages/core-runtime/src/outbox/subscriptions.generated.ts`. Every independently deployed worker therefore depends on a complete source-time catalog and must be rebuilt when an unrelated vertical adds a worker. ## Solution Statement Implement four explicit layers with separate ownership: -1. **Deployment topology and allowlist:** generated UltraModern metadata identifies independently - deployed application IDs, Module Federation/API locations, and an environment-specific OntOS - contract URL. It authorizes discovery but does not define the business module identity. -2. **Typed OntOS Module Manifest:** an owner-authored TypeScript value validated by Effect Schema - holds real Action, API, component, resource, event, search, and report values. It is the source - contract but is never statically imported by the Shell or another MicroVertical at runtime. -3. **Deployment contract and installed-module catalog:** a build tool derives one serializable - contract document per deployment. Shell/Core fetches only allowlisted documents, validates the - deployment-to-module mapping and complete catalog invariants, and exposes an immutable catalog - keyed separately by deployment ID and module ID. -4. **Private owner-local runtime registration:** executable registrations stay inside the owning - MicroVertical deployment. Actions and workers execute only there; APIs cross through generated - Effect clients; components cross through generated Module Federation wrappers. Deployment - metadata may describe safe runtime identities such as worker subscriptions, but it never contains - handlers, source paths, migrations, route trees, repositories, or arbitrary import strings. - -The Shell active-module operation will intersect tenant state with catalog module IDs rather than -topology application IDs. The topology-derived application-ID inventory remains authoritative for -gateway JWT audiences. The existing tenant-state behavior remains otherwise unchanged: the current -home-page list continues to show only state `active`; expanding navigation semantics for -`read_only` or `deprecated` is outside this feature. +1. **Deployment topology and allowlist:** generated UltraModern metadata identifies independently deployed application IDs, Module Federation/API locations, and an environment-specific OntOS contract URL. It authorizes discovery but does not define the business module identity. +2. **Typed OntOS Module Manifest:** an owner-authored TypeScript value validated by Effect Schema holds real Action, API, component, resource, event, search, and report values. It is the source contract but is never statically imported by the Shell or another MicroVertical at runtime. +3. **Deployment contract and installed-module catalog:** a build tool derives one serializable contract document per deployment. Shell/Core fetches only allowlisted documents, validates the deployment-to-module mapping and complete catalog invariants, and exposes an immutable catalog keyed separately by deployment ID and module ID. +4. **Private owner-local runtime registration:** executable registrations stay inside the owning MicroVertical deployment. Actions and workers execute only there; APIs cross through generated Effect clients; components cross through generated Module Federation wrappers. Deployment metadata may describe safe runtime identities such as worker subscriptions, but it never contains handlers, source paths, migrations, route trees, repositories, or arbitrary import strings. + +The Shell active-module operation will intersect tenant state with catalog module IDs rather than topology application IDs. The topology-derived application-ID inventory remains authoritative for gateway JWT audiences. The existing tenant-state behavior remains otherwise unchanged: the current home-page list continues to show only state `active`; expanding navigation semantics for `read_only` or `deprecated` is outside this feature. ## Relevant Files @@ -170,29 +118,17 @@ Use these files to implement the feature: ### Phase 1: Foundation -Define the app-authoritative terminology and Effect Schema contracts. Separate topology deployment -identity from OntOS business identity, define the authored manifest and generated deployment -document, and establish a private registration that can expose descriptors to owner-local runtime -composition without exposing executable values to Shell/Core. +Define the app-authoritative terminology and Effect Schema contracts. Separate topology deployment identity from OntOS business identity, define the authored manifest and generated deployment document, and establish a private registration that can expose descriptors to owner-local runtime composition without exposing executable values to Shell/Core. -Add and test the module-contract Codesmith generator before any manifest or registration owner file -is created. Because `verticals/*` is empty on `develop`, prove generated output in disposable -UltraModern-shaped fixtures rather than adding a demonstration business MicroVertical. +Add and test the module-contract Codesmith generator before any manifest or registration owner file is created. Because `verticals/*` is empty on `develop`, prove generated output in disposable UltraModern-shaped fixtures rather than adding a demonstration business MicroVertical. ### Phase 2: Core Implementation -Implement deterministic contract emission and an immutable installed-module catalog. Update every -affected generator to consume the manifest's OntOS module ID, patch only explicit generated owner -slots, preserve topology application IDs at deployment/MF/authentication seams, and replace the -source-time Outbox subscription catalog with descriptor metadata derived from deployed contracts. +Implement deterministic contract emission and an immutable installed-module catalog. Update every affected generator to consume the manifest's OntOS module ID, patch only explicit generated owner slots, preserve topology application IDs at deployment/MF/authentication seams, and replace the source-time Outbox subscription catalog with descriptor metadata derived from deployed contracts. ### Phase 3: Integration -Add Shell allowlist loading and fail-closed remote contract validation, then replace only the active -module list's topology-ID intersection with module-ID catalog lookup. Preserve gateway audience -behavior and private runtime execution. Extend repository validators, generated-fixture integration -tests, documentation, and production build checks so no static import of another vertical's manifest -or registration can reappear. +Add Shell allowlist loading and fail-closed remote contract validation, then replace only the active module list's topology-ID intersection with module-ID catalog lookup. Preserve gateway audience behavior and private runtime execution. Extend repository validators, generated-fixture integration tests, documentation, and production build checks so no static import of another vertical's manifest or registration can reappear. ## Step by Step Tasks @@ -266,28 +202,15 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use Effect Schema tests for every authored and serialized manifest surface, stable IDs, activation -rules, dependency modes, cross-references, deployment identity, duplicate rejection, catalog graph -validation, immutability, and private-field exclusion. Test the private registration's handler -opacity and safe descriptor extraction. Extend Action and Outbox tests for catalog-supplied runtime -requirements and removal of the source-generated subscription default. +Use Effect Schema tests for every authored and serialized manifest surface, stable IDs, activation rules, dependency modes, cross-references, deployment identity, duplicate rejection, catalog graph validation, immutability, and private-field exclusion. Test the private registration's handler opacity and safe descriptor extraction. Extend Action and Outbox tests for catalog-supplied runtime requirements and removal of the source-generated subscription default. -Use disposable Codesmith fixtures for the new module-contract command and all existing commands. -Assert exact owner slots, no-partial-write preflight, preservation of developer code and JSON order, -correct module/deployment identity use, deterministic output, formatter stability, and compilation -against the real Core contracts. +Use disposable Codesmith fixtures for the new module-contract command and all existing commands. Assert exact owner slots, no-partial-write preflight, preservation of developer code and JSON order, correct module/deployment identity use, deterministic output, formatter stability, and compilation against the real Core contracts. ### Integration Tests -Run local fixture HTTP servers that act as two separately deployed MicroVerticals. Configure the -Shell with an explicit allowlist, serve valid and invalid well-known contracts, and prove that the -Shell builds its catalog without importing fixture source. Combine the catalog with real Core -tenant-state services to prove module-ID filtering and transition rejection. +Run local fixture HTTP servers that act as two separately deployed MicroVerticals. Configure the Shell with an explicit allowlist, serve valid and invalid well-known contracts, and prove that the Shell builds its catalog without importing fixture source. Combine the catalog with real Core tenant-state services to prove module-ID filtering and transition rejection. -Exercise a generated owner-local Action and worker registration separately from the Shell process. -Verify that only metadata crosses the deployment boundary, that the Core matcher uses the complete -subscription snapshot, that workers claim only already-created owner-local deliveries, and that -handler execution remains local. +Exercise a generated owner-local Action and worker registration separately from the Shell process. Verify that only metadata crosses the deployment boundary, that the Core matcher uses the complete subscription snapshot, that workers claim only already-created owner-local deliveries, and that handler execution remains local. ### Edge Cases @@ -354,45 +277,23 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Implemented the typed manifest, opaque owner-local runtime registration, deterministic deployment - contract generation, immutable dual-key catalog, environment-specific Shell allowlist, bounded - catalog loading, catalog-backed active-module filtering, and deployment-catalog Outbox matching. -- Corrected the final review findings: real branded Action/API/component/Schema values are required; - Effect API operation keys are derived; Cloudflare output maps to `dist-cloudflare`; import checks - permit only same-owner registration access; activation dependency reads share the Action - transaction and record Data Access evidence; and the combined two-deployment proof now covers one - active and one inactive module plus owner-local Action/worker execution boundaries. -- Added the generated workspace skills lock and its required third-party license so repository - contract validation is reproducible in this worktree. Provisioned isolated local PostgreSQL and - SpiceDB validation resources, then fixed a database-test fixture that could generate an invalid - dotted module ID when a UUID segment began with a digit. +- Implemented the typed manifest, opaque owner-local runtime registration, deterministic deployment contract generation, immutable dual-key catalog, environment-specific Shell allowlist, bounded catalog loading, catalog-backed active-module filtering, and deployment-catalog Outbox matching. +- Corrected the final review findings: real branded Action/API/component/Schema values are required; Effect API operation keys are derived; Cloudflare output maps to `dist-cloudflare`; import checks permit only same-owner registration access; activation dependency reads share the Action transaction and record Data Access evidence; and the combined two-deployment proof now covers one active and one inactive module plus owner-local Action/worker execution boundaries. +- Added the generated workspace skills lock and its required third-party license so repository contract validation is reproducible in this worktree. Provisioned isolated local PostgreSQL and SpiceDB validation resources, then fixed a database-test fixture that could generate an invalid dotted module ID when a UUID segment began with a digit. ### Changed Files -- 67 tracked or newly added files across Core runtime contracts, Shell discovery/runtime wiring, - Codesmith generators and validators, architecture guidance, topology configuration, workspace - skill metadata, and tests. +- 67 tracked or newly added files across Core runtime contracts, Shell discovery/runtime wiring, Codesmith generators and validators, architecture guidance, topology configuration, workspace skill metadata, and tests. - The final count includes newly added files that plain `git diff --stat` omits until tracked. ### Tests Written or Updated -- `packages/core-runtime/tests/unit/module-manifest.test.ts` — real typed public values, exact schema - decoding, ownership/reference rejection, immutability, and safe serialization. -- `packages/core-runtime/tests/unit/module-catalog.test.ts` — dual identities, dependency graphs, and - complete deterministic Outbox subscription snapshots. -- `packages/core-runtime/tests/unit/tenant-module-state.test.ts` and - `packages/core-runtime/tests/integration/tenant-module-state.test.ts` — installed membership, - supported-state and active-first checks, transaction serialization, no-write rejection, and - truthful dependency-read evidence; integration fixture module IDs are valid for every UUID. -- `apps/shell-super-app/tests/unit/deployment-allowlist.test.ts`, - `installed-module-catalog.test.ts`, and `installed-outbox-matcher.test.ts` — safe environment URL - derivation, bounded atomic loading/cache revision behavior, and catalog-to-matcher provenance. -- `apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts` — two separately served - contracts, active/inactive tenant state, owner-local Action and worker references, Effect API - client reference, Module Federation descriptor, and metadata-only HTTP discovery. -- `scripts/scaffolding/tests/module-contract-generator.test.mts` and - `scaffold-generators.test.mts` — generator composition, actual Cloudflare output root, derived API - operations, authored/emitted contract validation, and ownership-aware private import enforcement. +- `packages/core-runtime/tests/unit/module-manifest.test.ts` — real typed public values, exact schema decoding, ownership/reference rejection, immutability, and safe serialization. +- `packages/core-runtime/tests/unit/module-catalog.test.ts` — dual identities, dependency graphs, and complete deterministic Outbox subscription snapshots. +- `packages/core-runtime/tests/unit/tenant-module-state.test.ts` and `packages/core-runtime/tests/integration/tenant-module-state.test.ts` — installed membership, supported-state and active-first checks, transaction serialization, no-write rejection, and truthful dependency-read evidence; integration fixture module IDs are valid for every UUID. +- `apps/shell-super-app/tests/unit/deployment-allowlist.test.ts`, `installed-module-catalog.test.ts`, and `installed-outbox-matcher.test.ts` — safe environment URL derivation, bounded atomic loading/cache revision behavior, and catalog-to-matcher provenance. +- `apps/shell-super-app/tests/integration/module-catalog-runtime.test.ts` — two separately served contracts, active/inactive tenant state, owner-local Action and worker references, Effect API client reference, Module Federation descriptor, and metadata-only HTTP discovery. +- `scripts/scaffolding/tests/module-contract-generator.test.mts` and `scaffold-generators.test.mts` — generator composition, actual Cloudflare output root, derived API operations, authored/emitted contract validation, and ownership-aware private import enforcement. ### Validation @@ -402,38 +303,26 @@ Execute every command to validate the feature with zero regressions. - `mise exec -- pnpm scaffold:module-contract -- --help` — passed and wrote no files. - `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — passed, 48 tests. - `mise exec -- pnpm outbox:test` — passed, 19 unit and 8 database integration tests. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — passed, 127 tests against isolated local - PostgreSQL and SpiceDB validation resources. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — passed, 127 tests against isolated local PostgreSQL and SpiceDB validation resources. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed, 71 tests. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — passed, 2 integration tests, - including authenticated Shell/Core behavior and the combined deployment-isolation proof. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — passed, 2 integration tests, including authenticated Shell/Core behavior and the combined deployment-isolation proof. - `mise exec -- pnpm check:module-contracts` — passed. - `mise exec -- pnpm api:check` — passed. -- `mise exec -- pnpm contract:check` — passed after installing and validating the pinned workspace - skills from `.agents/skills-lock.json`. +- `mise exec -- pnpm contract:check` — passed after installing and validating the pinned workspace skills from `.agents/skills-lock.json`. - `mise exec -- pnpm typecheck` — passed; direct strict Core and Shell package typechecks also passed. - `mise exec -- pnpm build` — passed, including MF type and performance-readiness checks. - `mise exec -- pnpm check` — passed as the final repository quality gate. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, all seven relevant app-local architecture guides, and the - referenced repository-level module, MicroVertical, and activation documents. App-local guidance - remains authoritative where the older product documents describe a static joint registry. -- Reviewed `git status --short`, `git diff --check`, the diff/stat, runtime and generator changes, - identity usage, build roots, private imports, serialized fields, remote-fetch limits, and deleted - source-time Outbox catalog. The final review additionally closed a loophole that could have let an - owner registration import another deployment's owner file. -- No UI component or visual behavior was introduced, so screenshot/browser review was not applicable; - authenticated runtime behavior was covered by the passing Shell integration suite. +- Re-read `../AGENTS.md`, `AGENTS.md`, all seven relevant app-local architecture guides, and the referenced repository-level module, MicroVertical, and activation documents. App-local guidance remains authoritative where the older product documents describe a static joint registry. +- Reviewed `git status --short`, `git diff --check`, the diff/stat, runtime and generator changes, identity usage, build roots, private imports, serialized fields, remote-fetch limits, and deleted source-time Outbox catalog. The final review additionally closed a loophole that could have let an owner registration import another deployment's owner file. +- No UI component or visual behavior was introduced, so screenshot/browser review was not applicable; authenticated runtime behavior was covered by the passing Shell integration suite. ### Deviations and Follow-ups -- No implementation or validation blocker remains. Disposable local database/auth configuration and - an isolated SpiceDB instance were used only for the final integration gates; no secret file was - read or modified. -- Repository-level documentation still describing a jointly deployed/static registry remains the - already documented out-of-scope documentation-owner follow-up. +- No implementation or validation blocker remains. Disposable local database/auth configuration and an isolated SpiceDB instance were used only for the final integration gates; no secret file was read or modified. +- Repository-level documentation still describing a jointly deployed/static registry remains the already documented out-of-scope documentation-owner follow-up. ## Notes @@ -445,6 +334,4 @@ Execute every command to validate the feature with zero regressions. - The repository currently contains no production `verticals/*` package. Disposable fixtures must prove the complete design until the first real business MicroVertical is generated. - A new Codesmith generator is required because `vertical.manifest.ts` and `vertical.registration.ts` are new business owner files. The generator must exist and be tested before either file is created in a real vertical; hand-creation remains forbidden. - No unresolved developer decision blocks implementation. -- Final validation completed on 2026-08-07. Every command under `Validation Commands` passed in the - detached worktree, including database/auth integration, workspace contract, production build, and - final repository quality gates. +- Final validation completed on 2026-08-07. Every command under `Validation Commands` passed in the detached worktree, including database/auth integration, workspace contract, production build, and final repository quality gates. diff --git a/app/specs/feature-fail-closed-action-authorization.md b/app/specs/feature-fail-closed-action-authorization.md index 76ec4bc4c..699ac9d9d 100644 --- a/app/specs/feature-fail-closed-action-authorization.md +++ b/app/specs/feature-fail-closed-action-authorization.md @@ -6,87 +6,35 @@ created: 2026-09-02 # Feature: Fail-closed Action authorization with explicit environment grants -> Historical implementation record. The follow-up -> [protected-entrypoint specification](./feature-complete-protected-entrypoint-authorization.md) -> narrows the provisioning rule below: every Action declares `action_execution` with either -> `tenant_membership_default` or `explicit` provisioning. Only the former receives a fixed -> tenant-member grant; the latter must already have its intended policy and never receives a -> blanket membership grant. This is implemented in `provisionActionAuthorization` and covered by -> the restricted-Action regressions in -> `scripts/tests/provision-current-action-authorization.test.mts`. Default Actions verify every -> fixed context and a representative non-member. Explicit Actions instead require a unique recorded -> per-Action assertion set with at least one allowed and one denied Principal; incomplete or unknown -> assertion sets fail before any schema or relationship write. +> Historical implementation record. The follow-up [protected-entrypoint specification](./feature-complete-protected-entrypoint-authorization.md) narrows the provisioning rule below: every Action declares `action_execution` with either `tenant_membership_default` or `explicit` provisioning. Only the former receives a fixed tenant-member grant; the latter must already have its intended policy and never receives a blanket membership grant. This is implemented in `provisionActionAuthorization` and covered by the restricted-Action regressions in `scripts/tests/provision-current-action-authorization.test.mts`. Default Actions verify every fixed context and a representative non-member. Explicit Actions instead require a unique recorded per-Action assertion set with at least one allowed and one denied Principal; incomplete or unknown assertion sets fail before any schema or relationship write. ## Feature Description -Make every OntOS Action require an explicit SpiceDB executor relationship. An Action with no -executor relationship must be rejected before its handler runs, and a user-triggered Contacts Action -denial must appear as a localized `@techsio/ui-kit` error Toast. +Make every OntOS Action require an explicit SpiceDB executor relationship. An Action with no executor relationship must be rejected before its handler runs, and a user-triggered Contacts Action denial must appear as a localized `@techsio/ui-kit` error Toast. -Preserve development convenience through explicit environment data rather than a code bypass. One -operator-invoked, idempotent provisioning command must expand the compatible SpiceDB schema and -grant every current Action to the membership set of the fixed development Tenant. The same command -must support the fixed stage Tenants later, without accepting arbitrary Tenant or Action input and -without running during application startup, sandbox preparation, database migration, or deployment. +Preserve development convenience through explicit environment data rather than a code bypass. One operator-invoked, idempotent provisioning command must expand the compatible SpiceDB schema and grant every current Action to the membership set of the fixed development Tenant. The same command must support the fixed stage Tenants later, without accepting arbitrary Tenant or Action input and without running during application startup, sandbox preparation, database migration, or deployment. -The rollout has two mandatory checkpoints in one Locki sandbox: first prove that an Action without -a relationship is denied and displays the Toast; then run the provisioning command and prove that -the same authenticated Tenant member can execute the Action. Stage provisioning is a later -operator-controlled promotion gate and must happen before the fail-closed runtime is deployed to -stage. +The rollout has two mandatory checkpoints in one Locki sandbox: first prove that an Action without a relationship is denied and displays the Toast; then run the provisioning command and prove that the same authenticated Tenant member can execute the Action. Stage provisioning is a later operator-controlled promotion gate and must happen before the fail-closed runtime is deployed to stage. ## User Story -As an authenticated OntOS user -I want every Action to have an explicit authorization rule and receive clear feedback when it does not -So that missing authorization configuration cannot silently permit a state change +As an authenticated OntOS user I want every Action to have an explicit authorization rule and receive clear feedback when it does not So that missing authorization configuration cannot silently permit a state change ## Problem Statement -`packages/core-runtime/src/permissions/service.ts` currently performs an `action#is_restricted` -self-check before checking `action#execute`. When the restriction marker is absent it returns the -`unconfigured` decision, and `packages/core-runtime/src/actions/runtime.ts` rejects only `denied`. -Therefore, an Action with no SpiceDB relationships is allowed to reach its Policy and handler -boundaries. The current live integration test explicitly protects this compatibility behavior. +`packages/core-runtime/src/permissions/service.ts` currently performs an `action#is_restricted` self-check before checking `action#execute`. When the restriction marker is absent it returns the `unconfigured` decision, and `packages/core-runtime/src/actions/runtime.ts` rejects only `denied`. Therefore, an Action with no SpiceDB relationships is allowed to reach its Policy and handler boundaries. The current live integration test explicitly protects this compatibility behavior. -Existing Contacts BFFs already map `ActionPermissionDenied` to the declared `ContactsForbiddenProblem` 403, -and Contacts features already classify that public error as `forbidden`, but mutation feedback is inline -and no Contacts Toast renderer is mounted for both standalone and federated rendering. Existing local -and stage context bootstraps establish Tenant membership but do not grant the current Action set to -those membership sets. The SpiceDB schema also limits `action#executor` to a direct `principal`, so -it cannot yet express “every authenticated active member of this specific Tenant.” +Existing Contacts BFFs already map `ActionPermissionDenied` to the declared `ContactsForbiddenProblem` 403, and Contacts features already classify that public error as `forbidden`, but mutation feedback is inline and no Contacts Toast renderer is mounted for both standalone and federated rendering. Existing local and stage context bootstraps establish Tenant membership but do not grant the current Action set to those membership sets. The SpiceDB schema also limits `action#executor` to a direct `principal`, so it cannot yet express “every authenticated active member of this specific Tenant.” ## Solution Statement -Change the canonical permission decision to a single fully consistent `action#execute` check: -`HAS_PERMISSION` is allowed, `NO_PERMISSION` is a definite denial, and conditional, malformed, or -unavailable results remain the existing retryable `ActionPermissionCheckError`. Remove -`unconfigured` from the decision vocabulary and let the existing Action runtime denial finalizer -produce `ActionPermissionDenied`, one terminal `action.rejected` audit record, and no handler or -business writes. - -Compatibly expand `action#executor` to accept `principal | tenant#member`. Keep the legacy -`restriction` relation during this rollout so old application versions and existing direct -Principal tuples remain schema-compatible, but stop consulting it in the new runtime. Add a -generated Core Action catalog and combine it with action descriptors from each topology-owned -public module deployment contract so the provisioning command covers all eight current Core Actions -and all eight current Contacts Actions without importing a MicroVertical's private runtime into another -deployment. - -Create one parameterless `authorization:provision-current-actions` command. It must derive the -current Action set, select only the source-controlled development or stage Tenant set from the -validated deployment environment, apply the compatible SpiceDB schema, `TOUCH` each -`action:#executor@tenant:#member` relationship, and verify representative -allowed and denied checks. It must reject production, arbitrary identifiers, incompatible -endpoints, missing Tenant membership, and incomplete Action discovery. It is authorization -environment provisioning—not a PostgreSQL migration—and must be safe to rerun. - -For Contacts, mount `Toaster` once in the standalone layout and once per loaded federated page root, then -use `useToast()` in the six existing mutation feature surfaces. On the closed `forbidden` Action -state, create an error Toast using the existing localized action-specific forbidden copy. Keep -validation, conflict, authentication, unavailable/retry, loading, empty, responsive, and -accessibility behavior unchanged; do not turn indeterminate 503 failures into permission denials. +Change the canonical permission decision to a single fully consistent `action#execute` check: `HAS_PERMISSION` is allowed, `NO_PERMISSION` is a definite denial, and conditional, malformed, or unavailable results remain the existing retryable `ActionPermissionCheckError`. Remove `unconfigured` from the decision vocabulary and let the existing Action runtime denial finalizer produce `ActionPermissionDenied`, one terminal `action.rejected` audit record, and no handler or business writes. + +Compatibly expand `action#executor` to accept `principal | tenant#member`. Keep the legacy `restriction` relation during this rollout so old application versions and existing direct Principal tuples remain schema-compatible, but stop consulting it in the new runtime. Add a generated Core Action catalog and combine it with action descriptors from each topology-owned public module deployment contract so the provisioning command covers all eight current Core Actions and all eight current Contacts Actions without importing a MicroVertical's private runtime into another deployment. + +Create one parameterless `authorization:provision-current-actions` command. It must derive the current Action set, select only the source-controlled development or stage Tenant set from the validated deployment environment, apply the compatible SpiceDB schema, `TOUCH` each `action:#executor@tenant:#member` relationship, and verify representative allowed and denied checks. It must reject production, arbitrary identifiers, incompatible endpoints, missing Tenant membership, and incomplete Action discovery. It is authorization environment provisioning—not a PostgreSQL migration—and must be safe to rerun. + +For Contacts, mount `Toaster` once in the standalone layout and once per loaded federated page root, then use `useToast()` in the six existing mutation feature surfaces. On the closed `forbidden` Action state, create an error Toast using the existing localized action-specific forbidden copy. Keep validation, conflict, authentication, unavailable/retry, loading, empty, responsive, and accessibility behavior unchanged; do not turn indeterminate 503 failures into permission denials. ## Relevant Files @@ -146,24 +94,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Accept the proposed authorization decision, create one authoritative Core Action catalog maintained -by Codesmith, derive MicroVertical Actions from public deployment contracts, and define a canonical -compatible SpiceDB schema whose executor relation accepts a direct Principal or one fixed Tenant's -member set. Protect the current 16-Action baseline and prevent automatic provisioning. +Accept the proposed authorization decision, create one authoritative Core Action catalog maintained by Codesmith, derive MicroVertical Actions from public deployment contracts, and define a canonical compatible SpiceDB schema whose executor relation accepts a direct Principal or one fixed Tenant's member set. Protect the current 16-Action baseline and prevent automatic provisioning. ### Phase 2: Core Implementation -Remove the `unconfigured` allow path, reuse the existing durable Action denial finalizer, and add the -explicit environment-gated provisioning command. Prove fail-closed, direct-grant compatibility, -Tenant membership grants, cross-Tenant denial, indeterminate failures, idempotence, and complete -current Action coverage with unit and live integration tests. +Remove the `unconfigured` allow path, reuse the existing durable Action denial finalizer, and add the explicit environment-gated provisioning command. Prove fail-closed, direct-grant compatibility, Tenant membership grants, cross-Tenant denial, indeterminate failures, idempotence, and complete current Action coverage with unit and live integration tests. ### Phase 3: Integration -Render the UI-kit Toast portal in standalone and federated Contacts surfaces, map only definite Action -forbidden states to localized error Toasts, execute the two human sandbox checkpoints in order, and -document the later stage expand/provision/verify/deploy sequence. The sandbox must never mutate -stage. +Render the UI-kit Toast portal in standalone and federated Contacts surfaces, map only definite Action forbidden states to localized error Toasts, execute the two human sandbox checkpoints in order, and document the later stage expand/provision/verify/deploy sequence. The sandbox must never mutate stage. ## Step by Step Tasks @@ -237,19 +176,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test the single-check permission classifier, the reduced decision union, sanitized indeterminate -failures, Action runtime stage ordering, durable denial branch, canonical schema alignment, fixed -environment guards, complete Action discovery, relationship construction, idempotence, and -Codesmith catalog maintenance. Component tests cover all six current Contacts mutation surfaces and both -standalone/federated Toast portals without weakening their existing exhaustive UI-state tests. +Test the single-check permission classifier, the reduced decision union, sanitized indeterminate failures, Action runtime stage ordering, durable denial branch, canonical schema alignment, fixed environment guards, complete Action discovery, relationship construction, idempotence, and Codesmith catalog maintenance. Component tests cover all six current Contacts mutation surfaces and both standalone/federated Toast portals without weakening their existing exhaustive UI-state tests. ### Integration Tests -Use the existing live Core Action permission suite against PostgreSQL and SpiceDB to cover missing, -direct Principal, Tenant membership-set, cross-Tenant, concurrent, and unavailable outcomes through -the real Action repository. Retain the Contacts BFF integration proof for typed internal denial to 403 -Problem Details to generated-client error. Perform the two ordered manual Locki checks in one -sandbox, followed later by the operator-controlled stage pre-deploy grant and smoke gate. +Use the existing live Core Action permission suite against PostgreSQL and SpiceDB to cover missing, direct Principal, Tenant membership-set, cross-Tenant, concurrent, and unavailable outcomes through the real Action repository. Retain the Contacts BFF integration proof for typed internal denial to 403 Problem Details to generated-client error. Perform the two ordered manual Locki checks in one sandbox, followed later by the operator-controlled stage pre-deploy grant and smoke gate. ### Edge Cases diff --git a/app/specs/feature-generic-microvertical-action-identity.md b/app/specs/feature-generic-microvertical-action-identity.md index 862ef936a..b94b70745 100644 --- a/app/specs/feature-generic-microvertical-action-identity.md +++ b/app/specs/feature-generic-microvertical-action-identity.md @@ -8,82 +8,33 @@ created: 2026-08-03 ## Feature Description -Add one reusable, Shell-owned mechanism for propagating the authenticated OntOS principal from -`shell-super-app` to an independently deployed MicroVertical BFF, plus one repository-owned -Codesmith generator that prepares an existing MicroVertical to consume that identity for Action -endpoints. The mechanism must remove per-Action Shell endpoints and loader-held tokens, preserve the -strict MicroVertical deployment seam, and deliver a verified `TrustedPrincipalContext` to the -existing Core Action runtime without moving credentials, sessions, or authorization into the -MicroVertical. - -The Shell will validate the Better Auth session and issue a short-lived, audience-scoped, -asymmetrically signed assertion. The private signing key remains Shell-only. A generated -MicroVertical BFF adapter will verify the assertion with public key material, validate all claims, -and expose the trusted principal through an Effect interface. The token proves authentication and -context only: it must contain no Action permission grant, and Core must continue to enforce -SpiceDB permissions and executable Policies for each Action. +Add one reusable, Shell-owned mechanism for propagating the authenticated OntOS principal from `shell-super-app` to an independently deployed MicroVertical BFF, plus one repository-owned Codesmith generator that prepares an existing MicroVertical to consume that identity for Action endpoints. The mechanism must remove per-Action Shell endpoints and loader-held tokens, preserve the strict MicroVertical deployment seam, and deliver a verified `TrustedPrincipalContext` to the existing Core Action runtime without moving credentials, sessions, or authorization into the MicroVertical. + +The Shell will validate the Better Auth session and issue a short-lived, audience-scoped, asymmetrically signed assertion. The private signing key remains Shell-only. A generated MicroVertical BFF adapter will verify the assertion with public key material, validate all claims, and expose the trusted principal through an Effect interface. The token proves authentication and context only: it must contain no Action permission grant, and Core must continue to enforce SpiceDB permissions and executable Policies for each Action. ## User Story -As an OntOS developer -I want an existing MicroVertical to gain the standard Shell-user Action identity boundary through one generator command -So that I can expose Action BFF operations without copying authentication, token, configuration, and client-refresh code or modifying Shell for every Action +As an OntOS developer I want an existing MicroVertical to gain the standard Shell-user Action identity boundary through one generator command So that I can expose Action BFF operations without copying authentication, token, configuration, and client-refresh code or modifying Shell for every Action ## Problem Statement -The Core Action runtime already requires a trusted principal separately from the business payload, -but OntOS has no reusable production seam that supplies that context to an independently deployed -MicroVertical BFF. The disposable Testing spike proved the runtime path with a five-minute HMAC -token, but hardcoded `testing.testing` into the Shell endpoint, Shell contract, Shell client, home -loader, Testing BFF, and Testing client. Repeating that shape would cause every new Action or -MicroVertical to require coordinated Shell edits. - -The prototype also places a shared signing secret and issuer/verifier implementation in Core, -contrary to the rule that Core owns only non-secret principal bindings and context while Shell owns -credentials and authentication mechanics. Its globally constructed `Layer.orDie` makes unrelated -login/session operations depend on gateway configuration, and the page loader obtains a token only -once, so a retry cannot combine a refreshed token with the original idempotency key. The HMAC design -also gives every verifier the ability to mint assertions. - -Without a generic identity module and a generator, developers must reproduce security-sensitive -claims, Bearer parsing, signature checks, audience validation, error mapping, configuration, and -client token acquisition. That is repetitive, easy to get wrong, and incompatible with independently -deployable MicroVerticals authenticating each request themselves. +The Core Action runtime already requires a trusted principal separately from the business payload, but OntOS has no reusable production seam that supplies that context to an independently deployed MicroVertical BFF. The disposable Testing spike proved the runtime path with a five-minute HMAC token, but hardcoded `testing.testing` into the Shell endpoint, Shell contract, Shell client, home loader, Testing BFF, and Testing client. Repeating that shape would cause every new Action or MicroVertical to require coordinated Shell edits. + +The prototype also places a shared signing secret and issuer/verifier implementation in Core, contrary to the rule that Core owns only non-secret principal bindings and context while Shell owns credentials and authentication mechanics. Its globally constructed `Layer.orDie` makes unrelated login/session operations depend on gateway configuration, and the page loader obtains a token only once, so a retry cannot combine a refreshed token with the original idempotency key. The HMAC design also gives every verifier the ability to mint assertions. + +Without a generic identity module and a generator, developers must reproduce security-sensitive claims, Bearer parsing, signature checks, audience validation, error mapping, configuration, and client token acquisition. That is repetitive, easy to get wrong, and incompatible with independently deployable MicroVerticals authenticating each request themselves. ## Solution Statement -Keep the existing `shell-super-app` as the only deployed authentication authority. Add one generic -strict Effect BFF operation, `issueGatewayContext`, which accepts a MicroVertical audience, validates -the current Better Auth session, verifies that the audience is an existing vertical ID in the -authoritative topology, and returns a five-minute EdDSA JWT plus its expiry. Do not add an Auth -MicroVertical, another app, a package, a delivery unit, or a Module Federation remote. - -Publish the non-secret assertion schemas and the contract-derived Effect client through the existing -`@app/shared-contracts` package. The protected header will contain algorithm, type, and key ID; the -signed claims will contain issuer, audience, subject, issued-at, expiry, unique token ID, assertion -version, and the safe `TrustedPrincipalContext` fields. They will -contain no email, display name, credential, cookie, session token, Action key, permission, Policy -decision, or business payload. Require the standard subject claim to equal the nested principal ID. - -Use a Shell-private Ed25519 JWK with a required `kid` to sign. Give MicroVerticals only a JWKS of -public verification keys. Permit current and retiring public keys so rotation can overlap for at -least token TTL plus clock skew; reject unknown keys, algorithms other than EdDSA, invalid issuer or -audience, malformed claims, future issue times outside the allowed skew, and expired assertions. - -Add `mise exec -- pnpm scaffold:microvertical-action-boundary -- --vertical `. Run it once -after the UltraModern CLI creates a vertical and before that vertical exposes Shell-user Action BFF -operations. The generator will discover the vertical from package metadata and topology, add only -the required direct dependencies, and emit a server-side Effect verifier adapter plus a client-side -Effect token-acquisition adapter with the vertical app ID embedded as its audience. Endpoint authors -will call the generated server adapter and exhaustively map its typed authentication/unavailability -errors in the endpoint-specific Problem Details contract. Client Action methods will compose through -the generated client adapter so every new attempt obtains a fresh assertion while the feature keeps -its existing idempotency key. - -The generator must not create an Action, generic Action endpoint, permission, Policy, Outbox Message, -UI, or business vertical; those remain owned by their existing generators and feature code. The Core -Action runtime, permission service, Policy evaluator, transaction, Domain Event, and Outbox logic -remain unchanged. +Keep the existing `shell-super-app` as the only deployed authentication authority. Add one generic strict Effect BFF operation, `issueGatewayContext`, which accepts a MicroVertical audience, validates the current Better Auth session, verifies that the audience is an existing vertical ID in the authoritative topology, and returns a five-minute EdDSA JWT plus its expiry. Do not add an Auth MicroVertical, another app, a package, a delivery unit, or a Module Federation remote. + +Publish the non-secret assertion schemas and the contract-derived Effect client through the existing `@app/shared-contracts` package. The protected header will contain algorithm, type, and key ID; the signed claims will contain issuer, audience, subject, issued-at, expiry, unique token ID, assertion version, and the safe `TrustedPrincipalContext` fields. They will contain no email, display name, credential, cookie, session token, Action key, permission, Policy decision, or business payload. Require the standard subject claim to equal the nested principal ID. + +Use a Shell-private Ed25519 JWK with a required `kid` to sign. Give MicroVerticals only a JWKS of public verification keys. Permit current and retiring public keys so rotation can overlap for at least token TTL plus clock skew; reject unknown keys, algorithms other than EdDSA, invalid issuer or audience, malformed claims, future issue times outside the allowed skew, and expired assertions. + +Add `mise exec -- pnpm scaffold:microvertical-action-boundary -- --vertical `. Run it once after the UltraModern CLI creates a vertical and before that vertical exposes Shell-user Action BFF operations. The generator will discover the vertical from package metadata and topology, add only the required direct dependencies, and emit a server-side Effect verifier adapter plus a client-side Effect token-acquisition adapter with the vertical app ID embedded as its audience. Endpoint authors will call the generated server adapter and exhaustively map its typed authentication/unavailability errors in the endpoint-specific Problem Details contract. Client Action methods will compose through the generated client adapter so every new attempt obtains a fresh assertion while the feature keeps its existing idempotency key. + +The generator must not create an Action, generic Action endpoint, permission, Policy, Outbox Message, UI, or business vertical; those remain owned by their existing generators and feature code. The Core Action runtime, permission service, Policy evaluator, transaction, Domain Event, and Outbox logic remain unchanged. ## Relevant Files @@ -136,40 +87,19 @@ Use these files to implement the feature: ### Phase 1: Foundation -Document the exact authentication seam and assertion security contract before changing runtime code. -Move the non-secret gateway wire contract into the existing shared-contract package, select EdDSA -with explicit key IDs and rotation overlap, and keep Core's trusted principal type as the canonical -decoded context. Remove the disposable HMAC prototype from Core if it is still present. Add focused -schema tests for required claims, safe fields, subject/principal equality, and rejection of identity -or authorization data outside the approved contract. +Document the exact authentication seam and assertion security contract before changing runtime code. Move the non-secret gateway wire contract into the existing shared-contract package, select EdDSA with explicit key IDs and rotation overlap, and keep Core's trusted principal type as the canonical decoded context. Remove the disposable HMAC prototype from Core if it is still present. Add focused schema tests for required claims, safe fields, subject/principal equality, and rejection of identity or authorization data outside the approved contract. ### Phase 2: Core Implementation -Implement one Shell-private lazy issuer and one generic strict Effect BFF operation. Resolve the -current Better Auth session for every issuance, derive the audience allowlist from authoritative -topology, sign only safe claims, and map missing sessions, unknown audiences, invalid configuration, -and signing failures to declared typed Problems. Keep ordinary sign-in/session/sign-out layers -independent so missing gateway keys affect only the gateway operation. +Implement one Shell-private lazy issuer and one generic strict Effect BFF operation. Resolve the current Better Auth session for every issuance, derive the audience allowlist from authoritative topology, sign only safe claims, and map missing sessions, unknown audiences, invalid configuration, and signing failures to declared typed Problems. Keep ordinary sign-in/session/sign-out layers independent so missing gateway keys affect only the gateway operation. -Extend Codesmith with the MicroVertical Action-boundary command. Generate one edge-safe public-key -verifier adapter and one Effect client acquisition adapter for the target app ID. Preflight every -mutation, preserve package formatting and developer code, reject incompatible dependencies or -existing outputs, and prove exact generated output in disposable fixtures. The verifier must return -typed missing/invalid/expired/scope/configuration errors and never construct a principal from unsigned -payload or headers. +Extend Codesmith with the MicroVertical Action-boundary command. Generate one edge-safe public-key verifier adapter and one Effect client acquisition adapter for the target app ID. Preflight every mutation, preserve package formatting and developer code, reject incompatible dependencies or existing outputs, and prove exact generated output in disposable fixtures. The verifier must return typed missing/invalid/expired/scope/configuration errors and never construct a principal from unsigned payload or headers. ### Phase 3: Integration -Prove the complete seam with an ephemeral generated vertical fixture: an authenticated Shell session -issues an assertion, the generated adapter verifies it for the matching audience, and the resulting -context is accepted by the existing Action trusted-context schema. Prove another audience, expired or -tampered assertions, unknown `kid`, missing configuration, and anonymous sessions fail closed. +Prove the complete seam with an ephemeral generated vertical fixture: an authenticated Shell session issues an assertion, the generated adapter verifies it for the matching audience, and the resulting context is accepted by the existing Action trusted-context schema. Prove another audience, expired or tampered assertions, unknown `kid`, missing configuration, and anonymous sessions fail closed. -Demonstrate the generated client adapter acquiring a new assertion for each attempt while a caller -retains one idempotency key across retry. Ensure an Action BFF endpoint can map authentication failure -to `401` with a Bearer challenge and verification/configuration unavailability to `503`, without -changing the Action runtime or inventing a universal Action HTTP error contract. Remove all -Testing-specific Shell identity fields if the disposable spike has not already been reverted. +Demonstrate the generated client adapter acquiring a new assertion for each attempt while a caller retains one idempotency key across retry. Ensure an Action BFF endpoint can map authentication failure to `401` with a Bearer challenge and verification/configuration unavailability to `503`, without changing the Action runtime or inventing a universal Action HTTP error contract. Remove all Testing-specific Shell identity fields if the disposable spike has not already been reverted. ## Step by Step Tasks @@ -225,25 +155,13 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use Effect-controlled clocks and ephemeral Ed25519 keypairs to test claim construction, safe-field -selection, topology audience validation, configuration isolation, signature verification, token -times, subject consistency, key IDs, algorithm allowlisting, and typed errors. Use Node disposable -fixtures for generator arguments, path containment, dependency patches, exact templates, formatting, -typechecking, and no-partial-write behavior. Keep private keys and complete assertions out of test -failure output. +Use Effect-controlled clocks and ephemeral Ed25519 keypairs to test claim construction, safe-field selection, topology audience validation, configuration isolation, signature verification, token times, subject consistency, key IDs, algorithm allowlisting, and typed errors. Use Node disposable fixtures for generator arguments, path containment, dependency patches, exact templates, formatting, typechecking, and no-partial-write behavior. Keep private keys and complete assertions out of test failure output. ### Integration Tests -Use the real Shell authentication service with isolated Better Auth/Core identity fixtures to prove -only authenticated active identities can obtain an assertion. Render a disposable generated -MicroVertical adapter, verify a real Shell-issued assertion through it, and pass the result through -the existing Action trusted-context schema. Add a strict Effect BFF fixture to prove declared `401` -and `503` transport behavior and a client retry test that refreshes authentication independently of -the Action idempotency key. +Use the real Shell authentication service with isolated Better Auth/Core identity fixtures to prove only authenticated active identities can obtain an assertion. Render a disposable generated MicroVertical adapter, verify a real Shell-issued assertion through it, and pass the result through the existing Action trusted-context schema. Add a strict Effect BFF fixture to prove declared `401` and `503` transport behavior and a client retry test that refreshes authentication independently of the Action idempotency key. -Do not add or retain a demonstration business MicroVertical. The generator/runtime seam can be -proved in OS-temporary fixtures, and the existing Core tests remain authoritative for permission, -Policy, transaction, Domain Event, and Outbox behavior after trusted context is supplied. +Do not add or retain a demonstration business MicroVertical. The generator/runtime seam can be proved in OS-temporary fixtures, and the existing Core tests remain authoritative for permission, Policy, transaction, Domain Event, and Outbox behavior after trusted context is supplied. ### Edge Cases diff --git a/app/specs/feature-login-page.md b/app/specs/feature-login-page.md index 1b2e3fcb7..7e0bd5610 100644 --- a/app/specs/feature-login-page.md +++ b/app/specs/feature-login-page.md @@ -8,124 +8,71 @@ created: 2026-07-29 ## Feature Description -Add a shell-owned login page at `/login` with Login and Password fields and one -primary button whose English text is `Login`. Submitting the form validates on -the client that both fields are filled. If either field is missing, the page -marks the relevant UI-kit field as invalid and shows one UI-kit error Toast. +Add a shell-owned login page at `/login` with Login and Password fields and one primary button whose English text is `Login`. Submitting the form validates on the client that both fields are filled. If either field is missing, the page marks the relevant UI-kit field as invalid and shows one UI-kit error Toast. -This feature establishes the pre-authentication user interface only. It does -not authenticate credentials, create a session, resolve an OntOS principal, or -navigate after a valid submission. +This feature establishes the pre-authentication user interface only. It does not authenticate credentials, create a session, resolve an OntOS principal, or navigate after a valid submission. ## User Story -As a user entering OntOS -I want to provide my login and password and receive clear validation feedback -So that I know when the required credentials are missing before authentication +As a user entering OntOS I want to provide my login and password and receive clear validation feedback So that I know when the required credentials are missing before authentication ## Problem Statement -The shell currently has only its localized home route and provides no login -form. OntOS product architecture assigns authentication and identity to -Shell/Core, but there is not yet an application route where a user can enter -credentials. The requested scope defines only client validation and error -feedback; the BetterAuth submission and authenticated-principal session flow -are not yet implemented. +The shell currently has only its localized home route and provides no login form. OntOS product architecture assigns authentication and identity to Shell/Core, but there is not yet an application route where a user can enter credentials. The requested scope defines only client validation and error feedback; the BetterAuth submission and authenticated-principal session flow are not yet implemented. ## Solution Statement -Create a private, non-indexable localized shell route backed by one page-owned -React component. Compose the form from the installed -`@techsio/ui-kit@0.25.1` `FormInput`, `Button`, `Toaster`, and `useToast` APIs. -Use one semantic form-submit handler for both button activation and Enter-key -submission. The handler recomputes missing fields, exposes field-level error -state, focuses the first invalid field, and creates one transient error Toast. +Create a private, non-indexable localized shell route backed by one page-owned React component. Compose the form from the installed `@techsio/ui-kit@0.25.1` `FormInput`, `Button`, `Toaster`, and `useToast` APIs. Use one semantic form-submit handler for both button activation and Enter-key submission. The handler recomputes missing fields, exposes field-level error state, focuses the first invalid field, and creates one transient error Toast. -Keep all app-authored text in the existing English and Czech shell locale -resources. Mount the Toast portal once in the shell layout. Do not add a BFF -client, Action, authentication request, loading state, or success transition -until the real BetterAuth contract is separately specified. +Keep all app-authored text in the existing English and Czech shell locale resources. Mount the Toast portal once in the shell layout. Do not add a BFF client, Action, authentication request, loading state, or success transition until the real BetterAuth contract is separately specified. ## Relevant Files Use these files to implement the feature: - `../AGENTS.md` — repository scope and mandatory Codesmith generator rules. -- `AGENTS.md` — authoritative application architecture and managed command - convention. -- `README.md` — shell ownership, private-first route metadata, localization, - and generated route behavior. -- `docs/architecture/ULTRAMODERN.md` — generator and direct-file-creation - constraints. -- `docs/frontend/FRONTEND.md` — UI-kit, component, state, accessibility, and - frontend integration rules. -- `../docs/09_AUTHN_AUTHZ_MODEL.md` — BetterAuth and Core identity ownership; - prevents this UI-only plan from inventing authentication behavior. -- `../docs/adr/0014-authenticated-principal-session.md` — defines the - authenticated OntOS state that remains outside this feature. -- `apps/shell-super-app/src/routes/layout.tsx` — shell-global location for one - UI-kit `Toaster`. -- `apps/shell-super-app/src/routes/[lang]/page.tsx` — closest localized page - implementation pattern. -- `apps/shell-super-app/src/routes/[lang]/route.meta.ts` — closest private, - non-indexable route metadata pattern. -- `apps/shell-super-app/src/routes/ultramodern-route-metadata.ts` — generated - route metadata manifest; regenerate rather than edit it manually. -- `apps/shell-super-app/src/modern-tanstack/index/router.gen.ts` — generated - TanStack route tree; regenerate rather than edit it manually. -- `apps/shell-super-app/src/modern.runtime.ts` — existing shell i18n namespace - and English/Czech resource registration. -- `apps/shell-super-app/locales/en/shell.json` — English login labels, - validation feedback, Toast content, and route metadata text. +- `AGENTS.md` — authoritative application architecture and managed command convention. +- `README.md` — shell ownership, private-first route metadata, localization, and generated route behavior. +- `docs/architecture/ULTRAMODERN.md` — generator and direct-file-creation constraints. +- `docs/frontend/FRONTEND.md` — UI-kit, component, state, accessibility, and frontend integration rules. +- `../docs/09_AUTHN_AUTHZ_MODEL.md` — BetterAuth and Core identity ownership; prevents this UI-only plan from inventing authentication behavior. +- `../docs/adr/0014-authenticated-principal-session.md` — defines the authenticated OntOS state that remains outside this feature. +- `apps/shell-super-app/src/routes/layout.tsx` — shell-global location for one UI-kit `Toaster`. +- `apps/shell-super-app/src/routes/[lang]/page.tsx` — closest localized page implementation pattern. +- `apps/shell-super-app/src/routes/[lang]/route.meta.ts` — closest private, non-indexable route metadata pattern. +- `apps/shell-super-app/src/routes/ultramodern-route-metadata.ts` — generated route metadata manifest; regenerate rather than edit it manually. +- `apps/shell-super-app/src/modern-tanstack/index/router.gen.ts` — generated TanStack route tree; regenerate rather than edit it manually. +- `apps/shell-super-app/src/modern.runtime.ts` — existing shell i18n namespace and English/Czech resource registration. +- `apps/shell-super-app/locales/en/shell.json` — English login labels, validation feedback, Toast content, and route metadata text. - `apps/shell-super-app/locales/cs/shell.json` — matching Czech translations. -- `apps/shell-super-app/package.json` — installed UI-kit version and focused - shell typecheck command. +- `apps/shell-super-app/package.json` — installed UI-kit version and focused shell typecheck command. - `scripts/generate-tanstack-routes.mts` — repository-managed route generator. - `package.json` — supported validation scripts. ### New Files -- `apps/shell-super-app/src/routes/[lang]/login/page.tsx` — page-owned UI-kit - login form and client validation integration. -- `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` — private, - non-indexable login route metadata. -- `apps/shell-super-app/rstest.config.ts` — Modern.js-aware Rstest - configuration for unit and component tests using `happy-dom`. -- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — focused - component tests for login validation and interaction behavior. -- `apps/shell-super-app/tests/unit/routes/login/locales.test.ts` — English and - Czech login translation parity coverage. -- `apps/shell-super-app/tests/unit/layout.test.tsx` — shell-global Toast - renderer coverage. -- `apps/shell-super-app/playwright.config.ts` — Playwright configuration for - shell end-to-end tests against the built application. -- `apps/shell-super-app/tests/e2e/login.spec.ts` — localized login-route and - browser-interaction coverage. +- `apps/shell-super-app/src/routes/[lang]/login/page.tsx` — page-owned UI-kit login form and client validation integration. +- `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` — private, non-indexable login route metadata. +- `apps/shell-super-app/rstest.config.ts` — Modern.js-aware Rstest configuration for unit and component tests using `happy-dom`. +- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — focused component tests for login validation and interaction behavior. +- `apps/shell-super-app/tests/unit/routes/login/locales.test.ts` — English and Czech login translation parity coverage. +- `apps/shell-super-app/tests/unit/layout.test.tsx` — shell-global Toast renderer coverage. +- `apps/shell-super-app/playwright.config.ts` — Playwright configuration for shell end-to-end tests against the built application. +- `apps/shell-super-app/tests/e2e/login.spec.ts` — localized login-route and browser-interaction coverage. ## Implementation Plan ### Phase 1: Foundation -Confirm the shell owns this system-level authentication entry point and record -that the MicroVertical page generator does not apply. Establish the approved -Modern.js testing baseline: Rstest for unit/component tests and Playwright for -end-to-end tests. Define private route metadata and translated content using -the existing shell patterns. +Confirm the shell owns this system-level authentication entry point and record that the MicroVertical page generator does not apply. Establish the approved Modern.js testing baseline: Rstest for unit/component tests and Playwright for end-to-end tests. Define private route metadata and translated content using the existing shell patterns. ### Phase 2: Core Implementation -Mount the global UI-kit Toast renderer and compose the login form from -`FormInput` and `Button`. Add page-local validation state and one submit path -that handles mouse and keyboard submission, field errors, focus, and Toast -feedback. Add automated validation and interaction tests using the approved -test harness under the shell package's `tests/` directory. +Mount the global UI-kit Toast renderer and compose the login form from `FormInput` and `Button`. Add page-local validation state and one submit path that handles mouse and keyboard submission, field errors, focus, and Toast feedback. Add automated validation and interaction tests using the approved test harness under the shell package's `tests/` directory. ### Phase 3: Integration -Regenerate the framework-owned route files, verify localized route behavior -and accessibility in the browser, and run the shell and repository validation -commands. Confirm the implementation stays client-only and does not cross the -Action, BFF, Effect error, session, or principal boundaries. +Regenerate the framework-owned route files, verify localized route behavior and accessibility in the browser, and run the shell and repository validation commands. Confirm the implementation stays client-only and does not cross the Action, BFF, Effect error, session, or principal boundaries. ## Step by Step Tasks @@ -133,144 +80,62 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Resolve ownership, scaffolding, and test prerequisites -- [x] Confirm the login page remains a Shell/Core system capability rather - than a MicroVertical business page. Do not run - `scaffold:microvertical-page` or invent a placeholder vertical. -- [x] Treat approval of this plan as approval for one page-owned route - component composed from existing UI-kit components; do not introduce a - reusable application component. -- [x] Treat approval of this plan as explicit developer authorization to - create `apps/shell-super-app/src/routes/[lang]/login/page.tsx` and - `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` directly. - These are shell-owned business files for which no applicable Codesmith - generator exists. -- [x] Add the approved light testing baseline to - `apps/shell-super-app/package.json`: Rstest with the Modern.js adapter, - `happy-dom`, and Testing Library for unit/component tests, plus Playwright - for end-to-end tests. The expected development dependencies are - `@rstest/core`, `@modern-js/adapter-rstest`, `happy-dom`, - `@testing-library/react`, `@testing-library/dom`, - `@testing-library/user-event`, and `@playwright/test`. Select versions - compatible with the installed Modern.js package cohort and update the - pnpm lockfile. -- [x] Add `"test:unit": "rstest"` and - `"test:e2e": "playwright test"` scripts to the shell package. Configure - Rstest in `apps/shell-super-app/rstest.config.ts` with - `withModernConfig()` and `testEnvironment: "happy-dom"`. Configure - Playwright in `apps/shell-super-app/playwright.config.ts` to run the - shell's built application and use Chromium as the minimum browser target. - Install the Playwright Chromium binary through the repository-managed - toolchain before executing the E2E suite. +- [x] Confirm the login page remains a Shell/Core system capability rather than a MicroVertical business page. Do not run `scaffold:microvertical-page` or invent a placeholder vertical. +- [x] Treat approval of this plan as approval for one page-owned route component composed from existing UI-kit components; do not introduce a reusable application component. +- [x] Treat approval of this plan as explicit developer authorization to create `apps/shell-super-app/src/routes/[lang]/login/page.tsx` and `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` directly. These are shell-owned business files for which no applicable Codesmith generator exists. +- [x] Add the approved light testing baseline to `apps/shell-super-app/package.json`: Rstest with the Modern.js adapter, `happy-dom`, and Testing Library for unit/component tests, plus Playwright for end-to-end tests. The expected development dependencies are `@rstest/core`, `@modern-js/adapter-rstest`, `happy-dom`, `@testing-library/react`, `@testing-library/dom`, `@testing-library/user-event`, and `@playwright/test`. Select versions compatible with the installed Modern.js package cohort and update the pnpm lockfile. +- [x] Add `"test:unit": "rstest"` and `"test:e2e": "playwright test"` scripts to the shell package. Configure Rstest in `apps/shell-super-app/rstest.config.ts` with `withModernConfig()` and `testEnvironment: "happy-dom"`. Configure Playwright in `apps/shell-super-app/playwright.config.ts` to run the shell's built application and use Chromium as the minimum browser target. Install the Playwright Chromium binary through the repository-managed toolchain before executing the E2E suite. ### 2. Define the private localized login route -- [x] Add - `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` following - the existing route metadata shape with id `shell-login`, canonical path - `/login`, owner `shell-super-app`, namespace `shell`, - `public: false`, `indexable: false`, and - `publicSurface: "private-app-screen"`. -- [x] Use localized title and description keys and the same `/login` localized - path for English and Czech. Keep the route reachable before - authentication even though it is private and non-indexable for public - discovery. -- [x] Add router/metadata generation assertions in the approved test harness - when that harness supports generated-route checks; otherwise cover route - registration in the runtime test for this step. +- [x] Add `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` following the existing route metadata shape with id `shell-login`, canonical path `/login`, owner `shell-super-app`, namespace `shell`, `public: false`, `indexable: false`, and `publicSurface: "private-app-screen"`. +- [x] Use localized title and description keys and the same `/login` localized path for English and Czech. Keep the route reachable before authentication even though it is private and non-indexable for public discovery. +- [x] Add router/metadata generation assertions in the approved test harness when that harness supports generated-route checks; otherwise cover route registration in the runtime test for this step. ### 3. Add localized login content -- [x] Add structurally matching `shell.login.*` keys to - `apps/shell-super-app/locales/en/shell.json` and - `apps/shell-super-app/locales/cs/shell.json` for the page title, Login - and Password labels, submit text, required-field messages, Toast title, - Toast description, and route description. -- [x] Set the English submit translation to exactly `Login`; use the approved - Czech equivalent for the Czech route. -- [x] Add or update locale-contract tests using the approved harness so - missing or mismatched login keys fail deterministically. +- [x] Add structurally matching `shell.login.*` keys to `apps/shell-super-app/locales/en/shell.json` and `apps/shell-super-app/locales/cs/shell.json` for the page title, Login and Password labels, submit text, required-field messages, Toast title, Toast description, and route description. +- [x] Set the English submit translation to exactly `Login`; use the approved Czech equivalent for the Czech route. +- [x] Add or update locale-contract tests using the approved harness so missing or mismatched login keys fail deterministically. ### 4. Mount the global UI-kit Toast renderer -- [x] Update `apps/shell-super-app/src/routes/layout.tsx` to render exactly one - `Toaster` from `@techsio/ui-kit/molecules/toast` alongside the route - outlet. -- [x] Add a layout/component test in the approved harness proving the Toast - portal is available once and is not mounted inside the login form or - submit button. +- [x] Update `apps/shell-super-app/src/routes/layout.tsx` to render exactly one `Toaster` from `@techsio/ui-kit/molecules/toast` alongside the route outlet. +- [x] Add a layout/component test in the approved harness proving the Toast portal is available once and is not mounted inside the login form or submit button. ### 5. Implement the UI-kit login form and client validation -- [x] Add - `apps/shell-super-app/src/routes/[lang]/login/page.tsx` as the single - page-owned component and obtain strings through the existing - `useModernI18n` integration. -- [x] Render a semantic `
` containing: - a required `FormInput` with label `Login`, stable id/name, - `type="text"`, and `autoComplete="username"`; - a required `FormInput` with label `Password`, stable id/name, - `type="password"`, and `autoComplete="current-password"`; and - one `Button` with `type="submit"`, `variant="primary"`, - `theme="solid"`, and translated text. -- [x] Use UI-kit component props and tokens for control visuals. Use Tailwind - only for responsive page layout; add no plain CSS, native input/button - replacement, custom Toast, or duplicated UI-kit control styling. -- [x] In the single form-submit handler, prevent default submission and - recompute validity from current values. Treat Login as missing when it - is empty after trimming; treat Password as missing only when its value - length is zero so non-empty password whitespace is not altered. -- [x] Map each missing field to `validateStatus="error"` and localized - `helpText`, return corrected fields to the default state on the next - submission, and focus the first missing field. -- [x] When one or both fields are missing, call `useToast().create(...)` once - with `type: "error"` and short localized title/description content. -- [x] When both fields are filled, clear stale validation and perform no - request, navigation, success Toast, loading state, session change, or - other side effect. -- [x] Add focused tests in the approved harness for both fields missing, each - individual field missing, both fields present, correction after a prior - error, one Toast per invalid submit, Enter-key submission, and first - invalid-field focus. The valid-submission test must explicitly assert - that no validation Toast, network request, or navigation occurs. +- [x] Add `apps/shell-super-app/src/routes/[lang]/login/page.tsx` as the single page-owned component and obtain strings through the existing `useModernI18n` integration. +- [x] Render a semantic `` containing: - a required `FormInput` with label `Login`, stable id/name, `type="text"`, and `autoComplete="username"`; - a required `FormInput` with label `Password`, stable id/name, `type="password"`, and `autoComplete="current-password"`; and - one `Button` with `type="submit"`, `variant="primary"`, `theme="solid"`, and translated text. +- [x] Use UI-kit component props and tokens for control visuals. Use Tailwind only for responsive page layout; add no plain CSS, native input/button replacement, custom Toast, or duplicated UI-kit control styling. +- [x] In the single form-submit handler, prevent default submission and recompute validity from current values. Treat Login as missing when it is empty after trimming; treat Password as missing only when its value length is zero so non-empty password whitespace is not altered. +- [x] Map each missing field to `validateStatus="error"` and localized `helpText`, return corrected fields to the default state on the next submission, and focus the first missing field. +- [x] When one or both fields are missing, call `useToast().create(...)` once with `type: "error"` and short localized title/description content. +- [x] When both fields are filled, clear stale validation and perform no request, navigation, success Toast, loading state, session change, or other side effect. +- [x] Add focused tests in the approved harness for both fields missing, each individual field missing, both fields present, correction after a prior error, one Toast per invalid submit, Enter-key submission, and first invalid-field focus. The valid-submission test must explicitly assert that no validation Toast, network request, or navigation occurs. ### 6. Regenerate and verify route integration -- [x] Run the repository route generator and review the generated changes in - `ultramodern-route-metadata.ts` and `router.gen.ts`; do not hand-edit - either file. -- [x] Verify `/login` follows the existing locale redirect behavior and that - `/en/login` and `/cs/login` render their corresponding translations. -- [x] Verify the page at mobile and desktop widths, keyboard-only submission, - visible field validation, focus placement, and Toast feedback. -- [x] Verify a valid submission produces no browser network request and no - navigation. +- [x] Run the repository route generator and review the generated changes in `ultramodern-route-metadata.ts` and `router.gen.ts`; do not hand-edit either file. +- [x] Verify `/login` follows the existing locale redirect behavior and that `/en/login` and `/cs/login` render their corresponding translations. +- [x] Verify the page at mobile and desktop widths, keyboard-only submission, visible field validation, focus placement, and Toast feedback. +- [x] Verify a valid submission produces no browser network request and no navigation. ### 7. Run all validation commands -- [x] Execute every command listed under Validation Commands in order and fix - all failures without weakening repository gates or expanding feature - scope. +- [x] Execute every command listed under Validation Commands in order and fix all failures without weakening repository gates or expanding feature scope. ## Testing Strategy ### Unit Tests -Use Rstest with `@modern-js/adapter-rstest`, `happy-dom`, and Testing Library. -Add focused tests for the required-field decision table and for the component's -field status, help text, Toast creation count, keyboard submission, -stale-error clearing, focus behavior, and valid-submission absence of Toast, -network, and navigation side effects. Keep shell-owned tests under -`apps/shell-super-app/tests/unit/`; do not add a general validation abstraction -solely to make the tests easier. +Use Rstest with `@modern-js/adapter-rstest`, `happy-dom`, and Testing Library. Add focused tests for the required-field decision table and for the component's field status, help text, Toast creation count, keyboard submission, stale-error clearing, focus behavior, and valid-submission absence of Toast, network, and navigation side effects. Keep shell-owned tests under `apps/shell-super-app/tests/unit/`; do not add a general validation abstraction solely to make the tests easier. ### Integration Tests -Use Playwright for automated end-to-end coverage of `/login`, `/en/login`, and -`/cs/login`. Confirm the generated router recognizes the page, locale content -is correct, the form is usable with keyboard and mobile viewport widths, -invalid submissions show field feedback plus one Toast, and valid submissions -cause no request or redirect. Retain implementation-time browser review as -additional evidence rather than a substitute for the Playwright suite. +Use Playwright for automated end-to-end coverage of `/login`, `/en/login`, and `/cs/login`. Confirm the generated router recognizes the page, locale content is correct, the form is usable with keyboard and mobile viewport widths, invalid submissions show field feedback plus one Toast, and valid submissions cause no request or redirect. Retain implementation-time browser review as additional evidence rather than a substitute for the Playwright suite. -Loading, empty, forbidden, conflict, and retry states are not required because -this feature performs no asynchronous or backend operation. +Loading, empty, forbidden, conflict, and retry states are not required because this feature performs no asynchronous or backend operation. ### Edge Cases @@ -280,66 +145,44 @@ this feature performs no asynchronous or backend operation. - Only Password is empty. - Password contains non-empty whitespace that must not be trimmed or changed. - A corrected field clears its previous error on the next submit. -- Repeated invalid submissions create one Toast per submission, not one per - invalid field. +- Repeated invalid submissions create one Toast per submission, not one per invalid field. - Enter-key submission follows the same path as activating the button. -- Locale changes do not leave stale English validation content on the Czech - route. +- Locale changes do not leave stale English validation content on the Czech route. ## Acceptance Criteria -- [x] `/login` resolves through the shell's existing locale behavior; - `/en/login` renders English and `/cs/login` renders Czech. -- [x] The English page displays UI-kit fields labeled `Login` and `Password` - and exactly one UI-kit button with the text `Login`. +- [x] `/login` resolves through the shell's existing locale behavior; `/en/login` renders English and `/cs/login` renders Czech. +- [x] The English page displays UI-kit fields labeled `Login` and `Password` and exactly one UI-kit button with the text `Login`. - [x] The Czech page displays the corresponding Czech translations. -- [x] Submitting with both fields empty marks both fields invalid, focuses - Login, and shows exactly one UI-kit error Toast. -- [x] Submitting with only one field empty marks and focuses only that field - and shows exactly one UI-kit error Toast. +- [x] Submitting with both fields empty marks both fields invalid, focuses Login, and shows exactly one UI-kit error Toast. +- [x] Submitting with only one field empty marks and focuses only that field and shows exactly one UI-kit error Toast. - [x] Submitting after correcting a field clears that field's stale error. -- [x] Submitting with both fields filled clears validation, shows no error - Toast, sends no network request, and does not navigate. +- [x] Submitting with both fields filled clears validation, shows no error Toast, sends no network request, and does not navigate. - [x] Activating the Login button and pressing Enter run the same validation. -- [x] The page uses `FormInput`, `Button`, `Toaster`, and `useToast` from the - installed UI kit without recreating those components or styles. -- [x] The login route is private and non-indexable but remains accessible - before authentication. -- [x] Automated tests cover the validation decision table, Toast behavior, - keyboard submission, stale-error clearing, and focus behavior using the - approved frontend test harness. -- [x] The Rstest unit/component suite and Playwright E2E suite both pass, and - the Playwright suite covers the localized routes, invalid interaction, - and valid submission without a request or navigation. +- [x] The page uses `FormInput`, `Button`, `Toaster`, and `useToast` from the installed UI kit without recreating those components or styles. +- [x] The login route is private and non-indexable but remains accessible before authentication. +- [x] Automated tests cover the validation decision table, Toast behavior, keyboard submission, stale-error clearing, and focus behavior using the approved frontend test harness. +- [x] The Rstest unit/component suite and Playwright E2E suite both pass, and the Playwright suite covers the localized routes, invalid interaction, and valid submission without a request or navigation. - [x] The page remains usable and legible at mobile and desktop widths. ## Validation Commands Execute every command to validate the feature with zero regressions. -- `mise exec -- pnpm exec node ./scripts/generate-tanstack-routes.mts` — - regenerate framework-owned route files. -- `mise exec -- pnpm --filter @app/shell-super-app typecheck` — typecheck the - affected shell application. -- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — run Rstest - unit/component coverage for login validation and shell Toast integration. -- `mise exec -- pnpm i18n:boundaries` — validate localization boundaries and - shell locale integration. -- `mise exec -- pnpm contract:check` — validate route metadata, topology, and - generated workspace contracts. +- `mise exec -- pnpm exec node ./scripts/generate-tanstack-routes.mts` — regenerate framework-owned route files. +- `mise exec -- pnpm --filter @app/shell-super-app typecheck` — typecheck the affected shell application. +- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — run Rstest unit/component coverage for login validation and shell Toast integration. +- `mise exec -- pnpm i18n:boundaries` — validate localization boundaries and shell locale integration. +- `mise exec -- pnpm contract:check` — validate route metadata, topology, and generated workspace contracts. - `mise exec -- pnpm check` — Run the final repository quality gate. -- `mise exec -- pnpm build` — compile the shell and verify generated Module - Federation and performance-readiness outputs. -- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — run the - Playwright login suite against the built shell application. +- `mise exec -- pnpm build` — compile the shell and verify generated Module Federation and performance-readiness outputs. +- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — run the Playwright login suite against the built shell application. ## Review Checklist - [x] Every acceptance criterion is satisfied. -- [x] The diff complies with `../AGENTS.md`, `AGENTS.md`, and all relevant - referenced guidance. -- [x] MicroVertical, Action, generated BFF client, and typed Effect error - boundaries are preserved. +- [x] The diff complies with `../AGENTS.md`, `AGENTS.md`, and all relevant referenced guidance. +- [x] MicroVertical, Action, generated BFF client, and typed Effect error boundaries are preserved. - [x] Tests cover every changed behavior and important failure path. - [x] No unrelated changes, dead code, or accidental API expansion remain. @@ -347,103 +190,53 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Implemented the localized shell login route, UI-kit form controls, field - validation, global Toast host, generated route metadata, and focused test - harnesses. -- Moved shell-owned tests into `apps/shell-super-app/tests/unit/` and - `apps/shell-super-app/tests/e2e/`. -- Updated the OntOS implementation skill to require package-owned `tests/` - directories for shells, MicroVerticals, and shared packages. +- Implemented the localized shell login route, UI-kit form controls, field validation, global Toast host, generated route metadata, and focused test harnesses. +- Moved shell-owned tests into `apps/shell-super-app/tests/unit/` and `apps/shell-super-app/tests/e2e/`. +- Updated the OntOS implementation skill to require package-owned `tests/` directories for shells, MicroVerticals, and shared packages. ### Changed Files -- 22 files changed, approximately 1,559 insertions and 20 deletions, including - the untracked implementation, test, configuration, and specification files. +- 22 files changed, approximately 1,559 insertions and 20 deletions, including the untracked implementation, test, configuration, and specification files. ### Tests Written or Updated -- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — required-field - decision table, repeated Toasts, keyboard submission, focus, stale-error - clearing, and valid-submit side effects. -- `apps/shell-super-app/tests/unit/routes/login/locales.test.ts` — locale - contract parity and generated login metadata. -- `apps/shell-super-app/tests/unit/layout.test.tsx` — one shell-global Toast - host. -- `apps/shell-super-app/tests/e2e/login.spec.ts` — English/Czech routes and - metadata, localized invalid states, redirect behavior, valid/invalid - interactions, and mobile usability. +- `apps/shell-super-app/tests/unit/routes/login/page.test.tsx` — required-field decision table, repeated Toasts, keyboard submission, focus, stale-error clearing, and valid-submit side effects. +- `apps/shell-super-app/tests/unit/routes/login/locales.test.ts` — locale contract parity and generated login metadata. +- `apps/shell-super-app/tests/unit/layout.test.tsx` — one shell-global Toast host. +- `apps/shell-super-app/tests/e2e/login.spec.ts` — English/Czech routes and metadata, localized invalid states, redirect behavior, valid/invalid interactions, and mobile usability. ### Validation - `mise exec -- pnpm exec node ./scripts/generate-tanstack-routes.mts` — passed. -- `mise exec -- pnpm --filter @app/shell-super-app typecheck` — passed after - removing the redundant explicit i18next instance. -- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed: 3 files, - 13 tests. +- `mise exec -- pnpm --filter @app/shell-super-app typecheck` — passed after removing the redundant explicit i18next instance. +- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed: 3 files, 13 tests. - `mise exec -- pnpm i18n:boundaries` — passed. -- `mise exec -- pnpm contract:check` — passed after registering the Rstest - adapter in package-source cohort metadata. -- `mise exec -- pnpm check` — passed after formatting the two architecture - tables and resolving the feature-file lint findings it exposed. +- `mise exec -- pnpm contract:check` — passed after registering the Rstest adapter in package-source cohort metadata. +- `mise exec -- pnpm check` — passed after formatting the two architecture tables and resolving the feature-file lint findings it exposed. - `mise exec -- pnpm build` — passed. -- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed: 7 - Chromium tests. +- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed: 7 Chromium tests. - `git diff --check` — passed. ### Review -- Reviewed both applicable `AGENTS.md` files plus the MicroVertical, Action, - Effect error, UltraModern, frontend, authentication, and principal-session - guidance. -- Independent Standards and Spec reviews found metadata integration, raw - visual tokens, repeated-submit coverage, and Czech invalid-state coverage; - all four feature findings were fixed. -- UI-kit audit found no native control replacements or recreated UI-kit - components. Production browser review verified field focus and one localized - error Toast. -- Screenshot: - `.codex/reports/review/feature-login-page/login-en-validation-final.png`. +- Reviewed both applicable `AGENTS.md` files plus the MicroVertical, Action, Effect error, UltraModern, frontend, authentication, and principal-session guidance. +- Independent Standards and Spec reviews found metadata integration, raw visual tokens, repeated-submit coverage, and Czech invalid-state coverage; all four feature findings were fixed. +- UI-kit audit found no native control replacements or recreated UI-kit components. Production browser review verified field focus and one localized error Toast. +- Screenshot: `.codex/reports/review/feature-login-page/login-en-validation-final.png`. ### Deviations and Follow-ups - The standalone typecheck and final repository quality gate now pass. -- Shell Tailwind utilities remain unprefixed because the current UI-kit token - import pipeline fails when Tailwind's `prefix(shell)` is enabled. This is - CSS-federation technical debt. -- The route generator now covers the generated login manifest through the - shell unit test, but its new generic discovery/filtering/error paths do not - yet have isolated tooling tests. +- Shell Tailwind utilities remain unprefixed because the current UI-kit token import pipeline fails when Tailwind's `prefix(shell)` is enabled. This is CSS-federation technical debt. +- The route generator now covers the generated login manifest through the shell unit test, but its new generic discovery/filtering/error paths do not yet have isolated tooling tests. - Rstest passes with a non-blocking `MODULE_TYPELESS_PACKAGE_JSON` warning. ## Notes -- Approval of this plan selects and authorizes the shell testing baseline: - Rstest with the official Modern.js adapter, `happy-dom`, and Testing Library - for unit/component tests, and Playwright for end-to-end tests. The - implementation may add the compatible development dependencies, - configuration files, package scripts, test files, Chromium browser - installation, and pnpm lockfile changes required to run both suites. -- Approving this plan also approves the component strategy: one page-owned - route component composed from existing UI-kit components, with no new - reusable component and no UI-kit library changes. -- Approval also explicitly authorizes direct creation of - `apps/shell-super-app/src/routes/[lang]/login/page.tsx` and - `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` because these - shell-owned business files have no applicable Codesmith generator. -- No Codesmith generator applies because this is a Shell/Core system route, - not a MicroVertical page, Action, Outbox Message, or Policy. If ownership - changes to a MicroVertical, implementation must stop, identify the approved - owning vertical, and run the mandatory `scaffold:microvertical-page` - generator for that vertical and the `login` page before adapting generated - output. -- Product architecture proposes BetterAuth for login/session mechanics and - requires a BetterAuth session to resolve through an active principal binding, - principal, and tenant before OntOS considers the user logged in. That - workflow requires a separate specification. -- The installed Toast component owns a hard-coded English accessible label for - its close control and exposes no localization prop. App-authored strings are - localized here; translating that library-owned label requires a separate - UI-kit API change. -- The UI-kit usage skills describe an older library version, so implementation - must continue to treat the installed `@techsio/ui-kit@0.25.1` declarations as - the API source of truth. +- Approval of this plan selects and authorizes the shell testing baseline: Rstest with the official Modern.js adapter, `happy-dom`, and Testing Library for unit/component tests, and Playwright for end-to-end tests. The implementation may add the compatible development dependencies, configuration files, package scripts, test files, Chromium browser installation, and pnpm lockfile changes required to run both suites. +- Approving this plan also approves the component strategy: one page-owned route component composed from existing UI-kit components, with no new reusable component and no UI-kit library changes. +- Approval also explicitly authorizes direct creation of `apps/shell-super-app/src/routes/[lang]/login/page.tsx` and `apps/shell-super-app/src/routes/[lang]/login/route.meta.ts` because these shell-owned business files have no applicable Codesmith generator. +- No Codesmith generator applies because this is a Shell/Core system route, not a MicroVertical page, Action, Outbox Message, or Policy. If ownership changes to a MicroVertical, implementation must stop, identify the approved owning vertical, and run the mandatory `scaffold:microvertical-page` generator for that vertical and the `login` page before adapting generated output. +- Product architecture proposes BetterAuth for login/session mechanics and requires a BetterAuth session to resolve through an active principal binding, principal, and tenant before OntOS considers the user logged in. That workflow requires a separate specification. +- The installed Toast component owns a hard-coded English accessible label for its close control and exposes no localization prop. App-authored strings are localized here; translating that library-owned label requires a separate UI-kit API change. +- The UI-kit usage skills describe an older library version, so implementation must continue to treat the installed `@techsio/ui-kit@0.25.1` declarations as the API source of truth. diff --git a/app/specs/feature-module-aware-shell-composition.md b/app/specs/feature-module-aware-shell-composition.md index c7962b36a..079fe7a82 100644 --- a/app/specs/feature-module-aware-shell-composition.md +++ b/app/specs/feature-module-aware-shell-composition.md @@ -16,9 +16,7 @@ The Shell remains responsible for URLs, layout, tenant context, navigation, load ## User Story -As an authenticated OntOS user -I want the Shell to show and open only the module capabilities that are valid for my tenant, module state, and permissions -So that navigation and every cross-module entrypoint behave consistently and safely as independently deployed modules are installed or change state +As an authenticated OntOS user I want the Shell to show and open only the module capabilities that are valid for my tenant, module state, and permissions So that navigation and every cross-module entrypoint behave consistently and safely as independently deployed modules are installed or change state ## Problem Statement diff --git a/app/specs/feature-shell-core-action-runtime.md b/app/specs/feature-shell-core-action-runtime.md index 5e41a3867..4e95f4d05 100644 --- a/app/specs/feature-shell-core-action-runtime.md +++ b/app/specs/feature-shell-core-action-runtime.md @@ -8,93 +8,40 @@ created: 2026-07-29 ## Feature Description -Add the first server-side Action execution runtime to Shell/Core. An Action is -a typed command or intent, such as creating, changing, or deleting one or more -business entities. A Domain Event is a business fact that occurred as a result -of a successful Action. - -The runtime accepts a typed Action registration, a payload, and a trusted -principal context supplied separately from that payload. Shell/Core owns the -Action Invocation lifecycle and the database transaction; the owning Action -handler receives the trusted principal, typed payload, a transaction-scoped -database executor, and controlled methods for recording Data Access Events, -adding Domain Events, and adding Outbox Messages. - -Every structurally valid Action creates an Action Invocation Log before the -business transaction. The handler's business changes, successful execution -audit record, Data Access Events, Domain Events, Outbox Messages, and successful -Action Invocation update commit atomically. If handler execution or the -transaction fails, all transactional records roll back, the invocation remains -open, no read data reaches the client, and the caller receives a typed Effect -error. Open failed invocations are intentionally not finalized in this -increment. - -An Outbox Message can be added only with a Domain Event registered by the same -Action execution. A repeated idempotency key may retry an open invocation when -the request hash matches. Once the earlier transaction has committed, the same -idempotency key fails with a typed already-committed error; the runtime does not -store or replay the original response. +Add the first server-side Action execution runtime to Shell/Core. An Action is a typed command or intent, such as creating, changing, or deleting one or more business entities. A Domain Event is a business fact that occurred as a result of a successful Action. + +The runtime accepts a typed Action registration, a payload, and a trusted principal context supplied separately from that payload. Shell/Core owns the Action Invocation lifecycle and the database transaction; the owning Action handler receives the trusted principal, typed payload, a transaction-scoped database executor, and controlled methods for recording Data Access Events, adding Domain Events, and adding Outbox Messages. + +Every structurally valid Action creates an Action Invocation Log before the business transaction. The handler's business changes, successful execution audit record, Data Access Events, Domain Events, Outbox Messages, and successful Action Invocation update commit atomically. If handler execution or the transaction fails, all transactional records roll back, the invocation remains open, no read data reaches the client, and the caller receives a typed Effect error. Open failed invocations are intentionally not finalized in this increment. + +An Outbox Message can be added only with a Domain Event registered by the same Action execution. A repeated idempotency key may retry an open invocation when the request hash matches. Once the earlier transaction has committed, the same idempotency key fails with a typed already-committed error; the runtime does not store or replay the original response. ## User Story -As an OntOS module developer -I want Shell/Core to execute typed Actions through one transaction and evidence lifecycle -So that business writes, access records, Domain Events, and Outbox Messages remain consistent and auditable +As an OntOS module developer I want Shell/Core to execute typed Actions through one transaction and evidence lifecycle So that business writes, access records, Domain Events, and Outbox Messages remain consistent and auditable ## Problem Statement -OntOS has authoritative Action lifecycle and database schemas but no executable -Shell/Core Action runtime. Shell and future MicroVertical BFF handlers therefore -cannot yet submit typed commands to one shared runtime that separates trusted -identity from user payload, owns transactions, records successful reads and -results, binds Outbox Messages to Domain Events, preserves typed failures, and -enforces idempotency. +OntOS has authoritative Action lifecycle and database schemas but no executable Shell/Core Action runtime. Shell and future MicroVertical BFF handlers therefore cannot yet submit typed commands to one shared runtime that separates trusted identity from user payload, owns transactions, records successful reads and results, binds Outbox Messages to Domain Events, preserves typed failures, and enforces idempotency. -The Core database foundation exposed raw typed Drizzle -execution but not Action descriptors, private handlers, execution context, -evidence collectors, idempotency coordination, or transaction orchestration. -The documented lifecycle also needs to reflect the agreed behavior that a -definitely failed transaction leaves its Action Invocation open and persists no -terminal failure or Data Access Event. +The Core database foundation exposed raw typed Drizzle execution but not Action descriptors, private handlers, execution context, evidence collectors, idempotency coordination, or transaction orchestration. The documented lifecycle also needs to reflect the agreed behavior that a definitely failed transaction leaves its Action Invocation open and persists no terminal failure or Data Access Event. ## Solution Statement -Implement an Effect-based Action runtime in `@app/core-runtime` using typed -object/interface composition rather than an inheritance hierarchy: +Implement an Effect-based Action runtime in `@app/core-runtime` using typed object/interface composition rather than an inheritance hierarchy: -- An Action descriptor owns its stable key and Effect Schema payload/result - contracts, declared domain-error schema, and permitted Domain Event payload - schemas. +- An Action descriptor owns its stable key and Effect Schema payload/result contracts, declared domain-error schema, and permitted Domain Event payload schemas. - A private handler is paired with the descriptor in an Action registration. -- `runAction` receives the registration, unknown payload, trusted principal - context, and transport/idempotency metadata as separate values. -- Core decodes the payload before entering the lifecycle, inserts or resolves - the Action Invocation, serializes concurrent use of its idempotency key, and - opens the Drizzle transaction. -- The handler receives the decoded payload and a restricted execution context - containing the principal, transaction executor, and append-only collector - methods. It cannot commit or roll back the transaction. -- `addDomainEvent` returns an execution-local typed reference. - `addOutboxMessage` requires that reference and rejects foreign or missing - Domain Events before persistence. -- On handler success, Core persists the result audit record, recorded Data - Access Events, Domain Events, Outbox Messages, and the `succeeded` invocation - update in the same transaction as the business writes. -- On a typed handler rejection, defect, persistence failure, or definite - rollback, Core returns a transport-neutral typed Effect error and leaves the - invocation open. -- If commit acknowledgement is lost, Core returns a typed indeterminate result - until its explicit commit-resolution operation can query and lock the - invocation. A committed `succeeded` update proves the transaction committed; - an open invocation permits a same-hash retry after the original database - lock is released. -- Upper BFF layers remain responsible for exhaustively mapping Core and domain - errors to declared HTTP error schemas and statuses. - -The runtime is packaged infrastructure. This feature does not create a -production business Action or generic untyped `/actions` endpoint. Tests use -test-local Action registrations, so the currently unavailable Codesmith Action -generator is intentionally not invoked. +- `runAction` receives the registration, unknown payload, trusted principal context, and transport/idempotency metadata as separate values. +- Core decodes the payload before entering the lifecycle, inserts or resolves the Action Invocation, serializes concurrent use of its idempotency key, and opens the Drizzle transaction. +- The handler receives the decoded payload and a restricted execution context containing the principal, transaction executor, and append-only collector methods. It cannot commit or roll back the transaction. +- `addDomainEvent` returns an execution-local typed reference. `addOutboxMessage` requires that reference and rejects foreign or missing Domain Events before persistence. +- On handler success, Core persists the result audit record, recorded Data Access Events, Domain Events, Outbox Messages, and the `succeeded` invocation update in the same transaction as the business writes. +- On a typed handler rejection, defect, persistence failure, or definite rollback, Core returns a transport-neutral typed Effect error and leaves the invocation open. +- If commit acknowledgement is lost, Core returns a typed indeterminate result until its explicit commit-resolution operation can query and lock the invocation. A committed `succeeded` update proves the transaction committed; an open invocation permits a same-hash retry after the original database lock is released. +- Upper BFF layers remain responsible for exhaustively mapping Core and domain errors to declared HTTP error schemas and statuses. + +The runtime is packaged infrastructure. This feature does not create a production business Action or generic untyped `/actions` endpoint. Tests use test-local Action registrations, so the currently unavailable Codesmith Action generator is intentionally not invoked. ## Relevant Files @@ -141,47 +88,21 @@ Use these files to implement the feature: ### Phase 1: Foundation -Finish and validate the Core PostgreSQL/Drizzle foundation, then align -`ACTIONS.md` and the Core schema with the agreed lifecycle. Define the Action -descriptor, registration, trusted principal context, transport metadata, -transport-neutral errors, and append-only event collector contracts. Keep -payload identity-free and use `Schema.Void` for Actions without a business -payload. - -Ensure the database can allocate ordered Domain Event sequence values safely -under concurrent transactions without application-side `max + 1` logic. -Serialize allocation and commit order for each tenant through the existing -tenant row. -Generated migrations remain Core-only. Preserve the existing invocation row as -the idempotency anchor: create it before the business transaction, allow -controlled lifecycle updates, and leave it open after a definite failure. +Finish and validate the Core PostgreSQL/Drizzle foundation, then align `ACTIONS.md` and the Core schema with the agreed lifecycle. Define the Action descriptor, registration, trusted principal context, transport metadata, transport-neutral errors, and append-only event collector contracts. Keep payload identity-free and use `Schema.Void` for Actions without a business payload. + +Ensure the database can allocate ordered Domain Event sequence values safely under concurrent transactions without application-side `max + 1` logic. Serialize allocation and commit order for each tenant through the existing tenant row. Generated migrations remain Core-only. Preserve the existing invocation row as the idempotency anchor: create it before the business transaction, allow controlled lifecycle updates, and leave it open after a definite failure. ### Phase 2: Core Implementation -Implement the controlled collectors, typed Drizzle repositories, and Effect -Action runtime. The runtime validates payloads, creates or finds invocations, -checks request hashes, runs the deferred gate boundaries before the business -transaction, transitions an accepted invocation to `running`, and serializes -private handler execution inside a Core-owned transaction. +Implement the controlled collectors, typed Drizzle repositories, and Effect Action runtime. The runtime validates payloads, creates or finds invocations, checks request hashes, runs the deferred gate boundaries before the business transaction, transitions an accepted invocation to `running`, and serializes private handler execution inside a Core-owned transaction. -Persist only successful execution evidence. Flush all recorded Data Access -Events, Domain Events, and Domain Event-linked Outbox Messages before updating -the invocation to `succeeded`; any failure rolls the whole transaction back. -Preserve declared domain errors in the Effect error channel and map database or -runtime failures to safe Core errors without attaching HTTP statuses. +Persist only successful execution evidence. Flush all recorded Data Access Events, Domain Events, and Domain Event-linked Outbox Messages before updating the invocation to `succeeded`; any failure rolls the whole transaction back. Preserve declared domain errors in the Effect error channel and map database or runtime failures to safe Core errors without attaching HTTP statuses. ### Phase 3: Integration -Export the Action registration and runtime surface narrowly from -`@app/core-runtime` so Shell BFFs and server-side MicroVertical adapters can -submit registrations without exposing private handlers to browsers or other -verticals. Do not add a generic action-key/unknown-payload HTTP endpoint. -Generated per-Action BFF endpoints will reuse each descriptor's schemas and -perform HTTP mapping in later feature work. +Export the Action registration and runtime surface narrowly from `@app/core-runtime` so Shell BFFs and server-side MicroVertical adapters can submit registrations without exposing private handlers to browsers or other verticals. Do not add a generic action-key/unknown-payload HTTP endpoint. Generated per-Action BFF endpoints will reuse each descriptor's schemas and perform HTTP mapping in later feature work. -Prove the complete behavior with test-local Shell/Core and MicroVertical-shaped -registrations. The same Core runtime contract must execute both without the -Shell importing a deployed MicroVertical implementation across a network seam. +Prove the complete behavior with test-local Shell/Core and MicroVertical-shaped registrations. The same Core runtime contract must execute both without the Shell importing a deployed MicroVertical implementation across a network seam. ## Step by Step Tasks @@ -291,21 +212,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Use Effect-aware tests or the existing Node test runner to verify descriptors, -Schema decoding, trusted-context separation, error unions, request hashing, -collector invariants, lifecycle ordering, transaction ownership, and every -typed failure branch. Runtime tests must use controlled collaborators so they -can assert exactly which persistence operations occur before, during, and after -the transaction. +Use Effect-aware tests or the existing Node test runner to verify descriptors, Schema decoding, trusted-context separation, error unions, request hashing, collector invariants, lifecycle ordering, transaction ownership, and every typed failure branch. Runtime tests must use controlled collaborators so they can assert exactly which persistence operations occur before, during, and after the transaction. ### Integration Tests -Run the Core Action runtime against local PostgreSQL to prove actual transaction -atomicity, row locking, invocation persistence outside the transaction, -successful invocation update inside the transaction, Domain Event/Outbox -foreign keys, concurrent idempotency behavior, rollback behavior, and -commit-acknowledgement recovery. Use only Core-owned test records; no -MicroVertical schema or production business Action is required. +Run the Core Action runtime against local PostgreSQL to prove actual transaction atomicity, row locking, invocation persistence outside the transaction, successful invocation update inside the transaction, Domain Event/Outbox foreign keys, concurrent idempotency behavior, rollback behavior, and commit-acknowledgement recovery. Use only Core-owned test records; no MicroVertical schema or production business Action is required. ### Edge Cases diff --git a/app/specs/feature-tenant-microvertical-state-list.md b/app/specs/feature-tenant-microvertical-state-list.md index 95e800c4f..ce397cd5e 100644 --- a/app/specs/feature-tenant-microvertical-state-list.md +++ b/app/specs/feature-tenant-microvertical-state-list.md @@ -8,66 +8,32 @@ created: 2026-08-03 ## Feature Description -Show the signed-in user's active MicroVerticals on the existing localized Shell home page. The -authenticated page must retain its current identity and logout UI and add only one semantic -`
    ` whose items identify installed MicroVerticals with persisted state exactly `active` for -the tenant resolved from the authenticated principal. - -Add a Core-owned, Effect-based read capability over `core.tenant_module_states` and expose that -read through the existing Shell strict Effect BFF and client. The browser or route loader must -never supply a tenant id, query Core tables directly, call a MicroVertical BFF for this list, or -model the read as an Action. The Shell must derive the tenant from the current Better Auth/Core -identity and intersect persisted active module keys with the authoritative generated topology so -stale or non-installed keys are not rendered. - -Also make tenant MicroVertical state transitions safe and auditable. A Core-owned typed Action -must perform each transition inside the existing Action transaction and atomically update -`core.tenant_module_states` and insert the corresponding -`core.tenant_module_state_changes` history row. No state-changing UI is in scope. +Show the signed-in user's active MicroVerticals on the existing localized Shell home page. The authenticated page must retain its current identity and logout UI and add only one semantic `
      ` whose items identify installed MicroVerticals with persisted state exactly `active` for the tenant resolved from the authenticated principal. + +Add a Core-owned, Effect-based read capability over `core.tenant_module_states` and expose that read through the existing Shell strict Effect BFF and client. The browser or route loader must never supply a tenant id, query Core tables directly, call a MicroVertical BFF for this list, or model the read as an Action. The Shell must derive the tenant from the current Better Auth/Core identity and intersect persisted active module keys with the authoritative generated topology so stale or non-installed keys are not rendered. + +Also make tenant MicroVertical state transitions safe and auditable. A Core-owned typed Action must perform each transition inside the existing Action transaction and atomically update `core.tenant_module_states` and insert the corresponding `core.tenant_module_state_changes` history row. No state-changing UI is in scope. ## User Story -As a signed-in OntOS user -I want to see the MicroVerticals that are active for my tenant -So that the Shell reflects the modules currently available in my tenant context +As a signed-in OntOS user I want to see the MicroVerticals that are active for my tenant So that the Shell reflects the modules currently available in my tenant context ## Problem Statement -The Shell currently resolves and displays a safe authenticated identity but does not load tenant -MicroVertical state. Although Core already owns the `tenant_module_states` current-state table and -the `tenant_module_state_changes` history table, it has no Effect service for listing active rows -or changing a state while enforcing the history invariant. The current Shell also knows that -`testing1` is installed through generated topology and Module Federation wiring, but its home -route does not combine that installed inventory with persisted tenant state. +The Shell currently resolves and displays a safe authenticated identity but does not load tenant MicroVertical state. Although Core already owns the `tenant_module_states` current-state table and the `tenant_module_state_changes` history table, it has no Effect service for listing active rows or changing a state while enforcing the history invariant. The current Shell also knows that `testing1` is installed through generated topology and Module Federation wiring, but its home route does not combine that installed inventory with persisted tenant state. -Directly querying the database from the route, putting the read behind an Action, trusting a -client-supplied tenant id, or hardcoding `testing1` would break the Shell/Core boundary and would -not extend safely to later generated MicroVerticals. Updating only the current-state table would -also lose the required change history and actor/invocation evidence. +Directly querying the database from the route, putting the read behind an Action, trusting a client-supplied tenant id, or hardcoding `testing1` would break the Shell/Core boundary and would not extend safely to later generated MicroVerticals. Updating only the current-state table would also lose the required change history and actor/invocation evidence. ## Solution Statement Introduce a narrow Core module-state capability with two separate paths: -- a read service lists rows whose `tenant_id` is the trusted tenant and whose state is exactly - `active`, sorted deterministically by module key, without creating an Action invocation or a - state-change history row; -- a generated Core-owned `core.modules.change-tenant-module-state` Action serializes transitions - for one tenant, detects no-op transitions, and atomically writes both the current row and one - history row carrying the previous state, new state, effective principal, Action invocation, - source, reason, and timestamp. - -Extend the existing Shell Effect API with an authenticated active-module read. Its handler -revalidates the current session, obtains the trusted tenant id, calls the Core read service, and -filters the result against installed vertical ids derived from -`topology/reference-topology.json`. Extend the existing generated-style Shell client and home -loader to preserve typed failures until they become an explicit page model. - -For an authenticated page, render the resulting module key and active state in the new `
        `. -An empty result still renders an empty `
          `. A typed availability failure keeps the identity and -logout UI, renders an empty `
            `, and shows localized unavailable feedback associated with the -list. Anonymous users continue to see only the existing login link. Do not render remote widgets, -navigation, links, state controls, promotional content, or any other new UI. +- a read service lists rows whose `tenant_id` is the trusted tenant and whose state is exactly `active`, sorted deterministically by module key, without creating an Action invocation or a state-change history row; +- a generated Core-owned `core.modules.change-tenant-module-state` Action serializes transitions for one tenant, detects no-op transitions, and atomically writes both the current row and one history row carrying the previous state, new state, effective principal, Action invocation, source, reason, and timestamp. + +Extend the existing Shell Effect API with an authenticated active-module read. Its handler revalidates the current session, obtains the trusted tenant id, calls the Core read service, and filters the result against installed vertical ids derived from `topology/reference-topology.json`. Extend the existing generated-style Shell client and home loader to preserve typed failures until they become an explicit page model. + +For an authenticated page, render the resulting module key and active state in the new `
              `. An empty result still renders an empty `
                `. A typed availability failure keeps the identity and logout UI, renders an empty `
                  `, and shows localized unavailable feedback associated with the list. Anonymous users continue to see only the existing login link. Do not render remote widgets, navigation, links, state controls, promotional content, or any other new UI. ## Relevant Files @@ -132,26 +98,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Extend the existing Codesmith Action command with the approved Core-owned form before creating -the production Action. Preserve its current MicroVertical form, add strict Core owner/path -validation, and use the new form to create the initial -`core.modules.change-tenant-module-state` Action file. Establish shared module-state schemas, -typed failures, the invocation-id handler context, and the Core Effect service. +Extend the existing Codesmith Action command with the approved Core-owned form before creating the production Action. Preserve its current MicroVertical form, add strict Core owner/path validation, and use the new form to create the initial `core.modules.change-tenant-module-state` Action file. Establish shared module-state schemas, typed failures, the invocation-id handler context, and the Core Effect service. ### Phase 2: Core Implementation -Implement the active-state query and the generated Core Action handler. Serialize state changes -per tenant, make first-time activation explicit with `previous_state = null`, reject no-op -transitions, and atomically insert history plus insert/update current state and `last_change_id` -inside the Action transaction. Add unit and PostgreSQL tests beside each behavior. +Implement the active-state query and the generated Core Action handler. Serialize state changes per tenant, make first-time activation explicit with `previous_state = null`, reject no-op transitions, and atomically insert history plus insert/update current state and `last_change_id` inside the Action transaction. Add unit and PostgreSQL tests beside each behavior. ### Phase 3: Integration -Generalize the existing topology-derived installed vertical inventory, add the authenticated -Shell read contract/handler/client, and compose it in the home loader. Render only the requested -semantic list on the authenticated page, preserve every existing visible state outside that list, -and prove authentication, tenant isolation, installed-module filtering, typed failures, i18n, -accessibility, generator behavior, and full production build compatibility. +Generalize the existing topology-derived installed vertical inventory, add the authenticated Shell read contract/handler/client, and compose it in the home loader. Render only the requested semantic list on the authenticated page, preserve every existing visible state outside that list, and prove authentication, tenant isolation, installed-module filtering, typed failures, i18n, accessibility, generator behavior, and full production build compatibility. ## Step by Step Tasks @@ -218,20 +173,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Test the canonical state vocabulary, typed state/source errors, invocation-id handler context, -active-only Core query classification, generated Core Action descriptor, same-state rejection, -topology decoding, Shell API schemas and Problem Details, generated-style client error unions, -home loader mapping, semantic `
                    ` output, localization, exact active item order, empty/unavailable -states, and unchanged anonymous/logout behavior. +Test the canonical state vocabulary, typed state/source errors, invocation-id handler context, active-only Core query classification, generated Core Action descriptor, same-state rejection, topology decoding, Shell API schemas and Problem Details, generated-style client error unions, home loader mapping, semantic `
                      ` output, localization, exact active item order, empty/unavailable states, and unchanged anonymous/logout behavior. ### Integration Tests -Run PostgreSQL-backed Core tests for initial state creation, serialized transitions, atomic current -state plus history, Action audit/evidence, idempotency, rollback, and tenant isolation. Run the real -Shell Effect BFF with Better Auth/Core fixtures to prove the session-derived tenant, active-only -query, installed-topology intersection, cookie propagation, declared failures, and response -redaction. Build the Shell and `testing1` to prove Core state infrastructure remains server-only -and does not cross the MicroVertical deployment seam. +Run PostgreSQL-backed Core tests for initial state creation, serialized transitions, atomic current state plus history, Action audit/evidence, idempotency, rollback, and tenant isolation. Run the real Shell Effect BFF with Better Auth/Core fixtures to prove the session-derived tenant, active-only query, installed-topology intersection, cookie propagation, declared failures, and response redaction. Build the Shell and `testing1` to prove Core state infrastructure remains server-only and does not cross the MicroVertical deployment seam. ### Edge Cases @@ -293,51 +239,29 @@ Execute every command to validate the feature with zero regressions. ## Notes -- `tenant_module_state_changesonly` in the request is treated as a typographical joining of - `tenant_module_state_changes` and “only.” The existing canonical table is - `core.tenant_module_state_changes`; this feature does not add or rename a table. -- “load the list of active tenants” is interpreted as “load active MicroVerticals for the tenant - resolved from the signed-in user.” The request's preceding and following bullets consistently - describe MicroVertical state, and the product model gives one tenant to the current authenticated - principal. -- The list is intentionally stricter than the older normal-navigation rule in - `../docs/14_ONTOS_MODULE_MANIFEST.md`: this requested home-page list includes only exact `active` - rows, not `read_only` or `deprecated`. It is not a navigation implementation. -- The generated topology app id `testing1` is the persisted module key for this proof. Display - names and an OntOS Module Manifest are not yet implemented, so the list renders the stable key - rather than inventing metadata. -- No seed data is added. The page reflects persisted Core state; integration tests create isolated - fixtures and clean them up in foreign-key order. -- The state-changing Action is server-side only in this scope. No generic Action endpoint or - administrator UI is added. -- The current Codesmith Action command discovers only `verticals/*`. The developer approved - extending it with `scaffold:action -- --scope core --module core.modules --action ...` on - 2026-08-03. The implementation must add and test that generator form before it creates the Core - Action; creating the Action manually remains forbidden. +- `tenant_module_state_changesonly` in the request is treated as a typographical joining of `tenant_module_state_changes` and “only.” The existing canonical table is `core.tenant_module_state_changes`; this feature does not add or rename a table. +- “load the list of active tenants” is interpreted as “load active MicroVerticals for the tenant resolved from the signed-in user.” The request's preceding and following bullets consistently describe MicroVertical state, and the product model gives one tenant to the current authenticated principal. +- The list is intentionally stricter than the older normal-navigation rule in `../docs/14_ONTOS_MODULE_MANIFEST.md`: this requested home-page list includes only exact `active` rows, not `read_only` or `deprecated`. It is not a navigation implementation. +- The generated topology app id `testing1` is the persisted module key for this proof. Display names and an OntOS Module Manifest are not yet implemented, so the list renders the stable key rather than inventing metadata. +- No seed data is added. The page reflects persisted Core state; integration tests create isolated fixtures and clean them up in foreign-key order. +- The state-changing Action is server-side only in this scope. No generic Action endpoint or administrator UI is added. +- The current Codesmith Action command discovers only `verticals/*`. The developer approved extending it with `scaffold:action -- --scope core --module core.modules --action ...` on 2026-08-03. The implementation must add and test that generator form before it creates the Core Action; creating the Action manually remains forbidden. - No unresolved developer decision blocks implementation. ## Implementation Evidence ### Summary -- Extended the mandatory Codesmith Action generator with the mutually exclusive Core ownership - form and used it to generate `core.modules.change-tenant-module-state` before adapting the - generated Action. -- Added the Core active-module read service and the transaction-only, idempotent, auditable state - transition path without changing the existing schema or migrations. -- Added the authenticated Shell Effect BFF operation, topology intersection, contract-derived - client, serializable loader model, and the single localized semantic list requested by this - feature. +- Extended the mandatory Codesmith Action generator with the mutually exclusive Core ownership form and used it to generate `core.modules.change-tenant-module-state` before adapting the generated Action. +- Added the Core active-module read service and the transaction-only, idempotent, auditable state transition path without changing the existing schema or migrations. +- Added the authenticated Shell Effect BFF operation, topology intersection, contract-derived client, serializable loader model, and the single localized semantic list requested by this feature. ### Changed Areas - `scripts/scaffolding/` and `AGENTS.md` for the tested Core Action generator contract. -- `packages/core-runtime/src/modules/` and focused Core unit/integration tests for state reads and - transitions. -- `apps/shell-super-app/` for topology inventory, the strict Effect BFF/client/loader path, - localized presentation, and focused unit/integration coverage. -- No files under `verticals/testing1`, `packages/core-runtime/src/db`, `mvp/`, or `mvp2/` were - changed. No database schema or migration was added. +- `packages/core-runtime/src/modules/` and focused Core unit/integration tests for state reads and transitions. +- `apps/shell-super-app/` for topology inventory, the strict Effect BFF/client/loader path, localized presentation, and focused unit/integration coverage. +- No files under `verticals/testing1`, `packages/core-runtime/src/db`, `mvp/`, or `mvp2/` were changed. No database schema or migration was added. ### Validation Results @@ -345,40 +269,24 @@ Execute every command to validate the feature with zero regressions. - `mise exec -- pnpm exec oxlint scripts/scaffolding` — passed. - `mise exec -- node --test scripts/scaffolding/tests/*.test.mts` — 20/20 passed. - `mise exec -- pnpm scaffold:action -- --help` — passed and documents both ownership forms. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — 89/89 passed with the repository's - PostgreSQL fixture and a disposable SpiceDB instance using the tracked development key. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — 89/89 passed with the repository's PostgreSQL fixture and a disposable SpiceDB instance using the tracked development key. - `mise exec -- pnpm --filter @app/core-runtime typecheck` — passed. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — 44/44 passed. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — 1/1 passed with the real - Better Auth session, Core PostgreSQL state, and Shell BFF flow. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — 1/1 passed with the real Better Auth session, Core PostgreSQL state, and Shell BFF flow. - `mise exec -- pnpm api:check` — passed. - `mise exec -- pnpm contract:check` — passed. -- `mise exec -- pnpm build` — the complete testing1 and Shell client/server production build, - type generation, deployment output, and performance checks passed. +- `mise exec -- pnpm build` — the complete testing1 and Shell client/server production build, type generation, deployment output, and performance checks passed. - `git diff --check` — passed. -- `mise exec -- pnpm check` — passed, including formatting, lint, 48 Action tests, root typecheck, - skills, i18n, API, contract, and performance checks. +- `mise exec -- pnpm check` — passed, including formatting, lint, 48 Action tests, root typecheck, skills, i18n, API, contract, and performance checks. ### Review Results -- Final review found no unresolved correctness, boundary, accessibility, localization, security, - or generated-code issues. -- The generated Action header identifies owner `core.modules`, and its registration is wired only - through the explicit generated Core Action export slot. -- Final scope and status inspection confirmed the requested branch remains on its original single - commit; implementation changes are intentionally uncommitted. +- Final review found no unresolved correctness, boundary, accessibility, localization, security, or generated-code issues. +- The generated Action header identifies owner `core.modules`, and its registration is wired only through the explicit generated Core Action export slot. +- Final scope and status inspection confirmed the requested branch remains on its original single commit; implementation changes are intentionally uncommitted. ### Validation Deviations -- An already-running local SpiceDB container used credentials that did not match the repository's - tracked test configuration. Final Core database validation used a disposable repository-configured - SpiceDB container on an alternate port; it was stopped and removed afterward. -- The release-envelope build correctly refuses a dirty Git worktree. To validate the exact current - uncommitted content without changing the requested branch or history, the final production build - used disposable Git metadata in `/tmp`; that metadata was deleted after the successful build. -- Browser review confirmed the anonymous page still renders only the login link and no list. An - authenticated follow-up navigation was blocked by the local-browser URL policy; the attempted - review nevertheless exposed and led to a fix for strict Effect runtime topology injection. - Authenticated, empty, unavailable, ordering, logout, and redaction behavior is covered by the - passing component, loader, BFF unit, and real Shell integration tests. The anonymous proof is - retained at `.codex/reports/review/feature-tenant-microvertical-state-list/anonymous-home.png`. +- An already-running local SpiceDB container used credentials that did not match the repository's tracked test configuration. Final Core database validation used a disposable repository-configured SpiceDB container on an alternate port; it was stopped and removed afterward. +- The release-envelope build correctly refuses a dirty Git worktree. To validate the exact current uncommitted content without changing the requested branch or history, the final production build used disposable Git metadata in `/tmp`; that metadata was deleted after the successful build. +- Browser review confirmed the anonymous page still renders only the login link and no list. An authenticated follow-up navigation was blocked by the local-browser URL policy; the attempted review nevertheless exposed and led to a fix for strict Effect runtime topology injection. Authenticated, empty, unavailable, ordering, logout, and redaction behavior is covered by the passing component, loader, BFF unit, and real Shell integration tests. The anonymous proof is retained at `.codex/reports/review/feature-tenant-microvertical-state-list/anonymous-home.png`. diff --git a/app/specs/feature-tenant-switcher.md b/app/specs/feature-tenant-switcher.md index 4f0a2ab85..04aea9ba7 100644 --- a/app/specs/feature-tenant-switcher.md +++ b/app/specs/feature-tenant-switcher.md @@ -8,73 +8,31 @@ created: 2026-08-06 ## Feature Description -Implement the authenticated Shell tenant switcher requested by GitHub issue #78. The dashboard -sidebar's current disabled empty Select will show every active OntOS tenant available to the signed -Better Auth user, identify the tenant currently selected by that browser session, and let the user -change the session's trusted tenant context. - -Core remains the authority for tenant access: an available tenant is one reached through an active -`core.principal_auth_bindings` row for the Better Auth user, an active tenant-scoped Principal, and -an active Tenant. Better Auth stores only the active tenant ID on its private session row. It does -not become a second tenant, membership, Principal, role, or authorization model, and the Better -Auth Organization plugin is not introduced. - -After a successful switch, reload the current localized page as a new document so every route -loader, active-module read, future MicroVertical client, and issued gateway assertion is rebuilt -from the newly selected trusted tenant context. A failed switch must leave the prior session -context and rendered data intact and expose a localized retry path. +Implement the authenticated Shell tenant switcher requested by GitHub issue #78. The dashboard sidebar's current disabled empty Select will show every active OntOS tenant available to the signed Better Auth user, identify the tenant currently selected by that browser session, and let the user change the session's trusted tenant context. + +Core remains the authority for tenant access: an available tenant is one reached through an active `core.principal_auth_bindings` row for the Better Auth user, an active tenant-scoped Principal, and an active Tenant. Better Auth stores only the active tenant ID on its private session row. It does not become a second tenant, membership, Principal, role, or authorization model, and the Better Auth Organization plugin is not introduced. + +After a successful switch, reload the current localized page as a new document so every route loader, active-module read, future MicroVertical client, and issued gateway assertion is rebuilt from the newly selected trusted tenant context. A failed switch must leave the prior session context and rendered data intact and expose a localized retry path. ## User Story -As a signed-in user with access to more than one tenant -I want to see and select my current tenant in the dashboard sidebar -So that every OntOS page and MicroVertical operation runs in the tenant context I chose +As a signed-in user with access to more than one tenant I want to see and select my current tenant in the dashboard sidebar So that every OntOS page and MicroVertical operation runs in the tenant context I chose ## Problem Statement -`AuthenticatedDashboardLayout` currently renders an intentionally empty, disabled UI-kit Select. -The current Core principal resolver deliberately treats more than one active binding for one Better -Auth user as ambiguous, while the Better Auth session has no selected tenant field. Consequently, -the Shell cannot list multiple tenant choices, cannot resolve one tenant-scoped Principal for a -multi-tenant user, and cannot persist a choice across a reload. +`AuthenticatedDashboardLayout` currently renders an intentionally empty, disabled UI-kit Select. The current Core principal resolver deliberately treats more than one active binding for one Better Auth user as ambiguous, while the Better Auth session has no selected tenant field. Consequently, the Shell cannot list multiple tenant choices, cannot resolve one tenant-scoped Principal for a multi-tenant user, and cannot persist a choice across a reload. -This is an explicit product-model change from the accepted repository-level guidance in -`../docs/CONTEXT.md` and `../docs/20_DAY_3_GRILL_RESULTS_FOR_ARCHITECT.md`, which says one Better Auth -account belongs to exactly one tenant and forbids a selector. Issue #78 supersedes that constraint -for this feature, but it does not make a Principal global: one Better Auth user may have multiple -active bindings, and each binding still selects a distinct tenant-scoped Principal. +This is an explicit product-model change from the accepted repository-level guidance in `../docs/CONTEXT.md` and `../docs/20_DAY_3_GRILL_RESULTS_FOR_ARCHITECT.md`, which says one Better Auth account belongs to exactly one tenant and forbids a selector. Issue #78 supersedes that constraint for this feature, but it does not make a Principal global: one Better Auth user may have multiple active bindings, and each binding still selects a distinct tenant-scoped Principal. ## Solution Statement -Extend the Core `PrincipalResolver` with two explicit capabilities: list the safe active tenant -choices for a Better Auth user and resolve that user for one exact selected tenant. Preserve -fail-closed behavior for missing, inactive, revoked, disabled, or unavailable records. Order the -visible choices by tenant name and tenant ID; for a newly created or legacy session without a -selection, choose the oldest eligible binding by `createdAt`, breaking ties by tenant ID. This -preserves the existing tenant as the default when additional bindings are added later. Never -silently fall back when an already selected tenant becomes invalid. - -Add nullable `active_tenant_id` to the Shell-owned `auth.session` Drizzle schema and configure it as -a Better Auth session additional field. The session-creation hook writes the deterministic default, -and a legacy session with no value is upgraded lazily through Better Auth's own `updateSession` API. -The field is accepted only through the server-side Better Auth instance behind the strict Effect -BFF; no raw Better Auth route is exposed. The selected value is revalidated against Core on every -session resolution, so the Auth row is context state, never authorization evidence. - -Publish contract-derived Effect operations to list available tenants and switch the current -session. The switch operation validates the target against Core before calling Better Auth -`updateSession`, returns only a safe selected tenant ID, and maps anonymous, forbidden, -unavailable, and unexpected failures to declared RFC 9457 Problem Details. Selecting the already -active tenant is idempotent. Authentication/session creation, revocation, and tenant selection are -Shell-owned Better Auth session mechanics, following the existing sign-in/sign-out boundary; they -do not mutate canonical Core business state and do not run through the Action runtime. - -Replace the placeholder with a controlled `@techsio/ui-kit@0.25.1` Select using its complete -compound anatomy and array value. Keep Effects and reload behavior in `HomeView`; the reusable -layout receives tenant view models, explicit availability/pending/failure state, and a semantic -`onTenantChange` callback. Disable the Select when choices are unavailable, while a switch is -pending, or when no alternative tenant exists. On success, perform a full document reload. On -failure, keep the previous controlled value and show localized `Select.StatusText` feedback. +Extend the Core `PrincipalResolver` with two explicit capabilities: list the safe active tenant choices for a Better Auth user and resolve that user for one exact selected tenant. Preserve fail-closed behavior for missing, inactive, revoked, disabled, or unavailable records. Order the visible choices by tenant name and tenant ID; for a newly created or legacy session without a selection, choose the oldest eligible binding by `createdAt`, breaking ties by tenant ID. This preserves the existing tenant as the default when additional bindings are added later. Never silently fall back when an already selected tenant becomes invalid. + +Add nullable `active_tenant_id` to the Shell-owned `auth.session` Drizzle schema and configure it as a Better Auth session additional field. The session-creation hook writes the deterministic default, and a legacy session with no value is upgraded lazily through Better Auth's own `updateSession` API. The field is accepted only through the server-side Better Auth instance behind the strict Effect BFF; no raw Better Auth route is exposed. The selected value is revalidated against Core on every session resolution, so the Auth row is context state, never authorization evidence. + +Publish contract-derived Effect operations to list available tenants and switch the current session. The switch operation validates the target against Core before calling Better Auth `updateSession`, returns only a safe selected tenant ID, and maps anonymous, forbidden, unavailable, and unexpected failures to declared RFC 9457 Problem Details. Selecting the already active tenant is idempotent. Authentication/session creation, revocation, and tenant selection are Shell-owned Better Auth session mechanics, following the existing sign-in/sign-out boundary; they do not mutate canonical Core business state and do not run through the Action runtime. + +Replace the placeholder with a controlled `@techsio/ui-kit@0.25.1` Select using its complete compound anatomy and array value. Keep Effects and reload behavior in `HomeView`; the reusable layout receives tenant view models, explicit availability/pending/failure state, and a semantic `onTenantChange` callback. Disable the Select when choices are unavailable, while a switch is pending, or when no alternative tenant exists. On success, perform a full document reload. On failure, keep the previous controlled value and show localized `Select.StatusText` feedback. ## Relevant Files @@ -82,95 +40,57 @@ Use these files to implement the feature: - `../AGENTS.md` — app-only scope and mandatory Codesmith generator rules. - `AGENTS.md` — authoritative Shell/Core, Effect, database, Action, and frontend boundaries. -- `docs/architecture/MICROVERTICALS.md` — Shell-owned authentication boundary and propagation of - trusted tenant context to independently deployed MicroVerticals. -- `docs/architecture/ACTIONS.md` — clarify why Better Auth session-context lifecycle is not a - canonical business-state Action while keeping every business state change Action-driven. -- `docs/architecture/ERRORS.md` — declared typed BFF errors, Problem Details, and generated client - error channels. -- `docs/architecture/DATABASE.md` — typed Drizzle schema, generated migration, and Effect service - database rules. -- `docs/frontend/FRONTEND.md` — route/feature ownership, generated Effect client use, UI states, - localization, accessibility, and responsive behavior. -- `packages/core-runtime/src/auth/principal-resolver.ts` — current single-binding resolver to split - into safe tenant listing and exact selected-tenant resolution. +- `docs/architecture/MICROVERTICALS.md` — Shell-owned authentication boundary and propagation of trusted tenant context to independently deployed MicroVerticals. +- `docs/architecture/ACTIONS.md` — clarify why Better Auth session-context lifecycle is not a canonical business-state Action while keeping every business state change Action-driven. +- `docs/architecture/ERRORS.md` — declared typed BFF errors, Problem Details, and generated client error channels. +- `docs/architecture/DATABASE.md` — typed Drizzle schema, generated migration, and Effect service database rules. +- `docs/frontend/FRONTEND.md` — route/feature ownership, generated Effect client use, UI states, localization, accessibility, and responsive behavior. +- `packages/core-runtime/src/auth/principal-resolver.ts` — current single-binding resolver to split into safe tenant listing and exact selected-tenant resolution. - `packages/core-runtime/src/auth/principal-resolver-errors.ts` — closed typed resolution failures. - `packages/core-runtime/src/index.ts` — narrow public Core resolver types and service surface. -- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — pure classification, default - choice, ordering, and fail-closed coverage. -- `packages/core-runtime/tests/integration/principal-resolver.test.ts` — real cross-tenant binding, - Principal, and Tenant query behavior. -- `apps/shell-super-app/api/auth/db/schema.ts` — private Better Auth session schema and - `activeTenantId` column. -- `apps/shell-super-app/drizzle.auth.config.ts` — authoritative Shell Auth migration generator - configuration. -- `apps/shell-super-app/api/auth/service.ts` — Better Auth creation hook, current-session - resolution, safe tenant list, switch, and legacy-session upgrade. -- `apps/shell-super-app/api/auth/errors.ts` — typed runtime failures needed to distinguish an - unauthenticated session, forbidden target tenant, unavailable dependency, and defect. -- `apps/shell-super-app/shared/api.ts` — safe tenant schemas, list/switch endpoints, Problem Details, - and stable paths. -- `apps/shell-super-app/api/index.ts` — strict Effect handlers, exhaustive runtime-error mapping, - cookie forwarding, and unexpected-defect containment. -- `apps/shell-super-app/src/api/auth-client.ts` — contract-derived list and switch Effects with - operation-specific typed error unions. -- `apps/shell-super-app/src/routes/[lang]/page.data.ts` — serializable tenant-list UI state beside - the existing session and active-module loader state. -- `apps/shell-super-app/src/routes/[lang]/page.tsx` — guarded switch Effect, failure state, and full - document reload at the framework edge. -- `apps/shell-super-app/src/routes/shell-frame.tsx` — controlled UI-kit Select presentation and - semantic tenant-selection callback. -- `apps/shell-super-app/locales/en/shell.json` — English tenant loading, selected, pending, - unavailable, and failure copy. +- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — pure classification, default choice, ordering, and fail-closed coverage. +- `packages/core-runtime/tests/integration/principal-resolver.test.ts` — real cross-tenant binding, Principal, and Tenant query behavior. +- `apps/shell-super-app/api/auth/db/schema.ts` — private Better Auth session schema and `activeTenantId` column. +- `apps/shell-super-app/drizzle.auth.config.ts` — authoritative Shell Auth migration generator configuration. +- `apps/shell-super-app/api/auth/service.ts` — Better Auth creation hook, current-session resolution, safe tenant list, switch, and legacy-session upgrade. +- `apps/shell-super-app/api/auth/errors.ts` — typed runtime failures needed to distinguish an unauthenticated session, forbidden target tenant, unavailable dependency, and defect. +- `apps/shell-super-app/shared/api.ts` — safe tenant schemas, list/switch endpoints, Problem Details, and stable paths. +- `apps/shell-super-app/api/index.ts` — strict Effect handlers, exhaustive runtime-error mapping, cookie forwarding, and unexpected-defect containment. +- `apps/shell-super-app/src/api/auth-client.ts` — contract-derived list and switch Effects with operation-specific typed error unions. +- `apps/shell-super-app/src/routes/[lang]/page.data.ts` — serializable tenant-list UI state beside the existing session and active-module loader state. +- `apps/shell-super-app/src/routes/[lang]/page.tsx` — guarded switch Effect, failure state, and full document reload at the framework edge. +- `apps/shell-super-app/src/routes/shell-frame.tsx` — controlled UI-kit Select presentation and semantic tenant-selection callback. +- `apps/shell-super-app/locales/en/shell.json` — English tenant loading, selected, pending, unavailable, and failure copy. - `apps/shell-super-app/locales/cs/shell.json` — structurally matching Czech tenant copy. - `apps/shell-super-app/tests/unit/auth-schema.test.ts` — typed Auth session-column contract. -- `apps/shell-super-app/tests/unit/auth-contract.test.ts` — endpoint, schema, path, and redaction - contracts. -- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — real Better Auth/Core session, - multi-tenant list/switch persistence, authorization, module, and gateway context behavior. -- `apps/shell-super-app/tests/unit/routes/home/loader.test.ts` — independent tenant-list loading and - failure mapping without discarding a valid identity. -- `apps/shell-super-app/tests/unit/layout.test.tsx` — Select anatomy, values, accessibility, - availability, pending, failure, and callback behavior. -- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — guarded switch invocation, prior - context retention on failure, duplicate prevention, and reload on success. +- `apps/shell-super-app/tests/unit/auth-contract.test.ts` — endpoint, schema, path, and redaction contracts. +- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — real Better Auth/Core session, multi-tenant list/switch persistence, authorization, module, and gateway context behavior. +- `apps/shell-super-app/tests/unit/routes/home/loader.test.ts` — independent tenant-list loading and failure mapping without discarding a valid identity. +- `apps/shell-super-app/tests/unit/layout.test.tsx` — Select anatomy, values, accessibility, availability, pending, failure, and callback behavior. +- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — guarded switch invocation, prior context retention on failure, duplicate prevention, and reload on success. - `apps/shell-super-app/tests/unit/routes/login/locales.test.ts` — exact English/Czech key parity. -- `apps/shell-super-app/tests/e2e/auth-fixture.ts` — one Better Auth user with two deterministic - tenant-scoped Principal bindings. -- `apps/shell-super-app/tests/e2e/login.spec.ts` — browser selection, full reload, persistence, - failure/retry, keyboard, and narrow-viewport behavior. -- `../docs/CONTEXT.md`, `../docs/20_DAY_3_GRILL_RESULTS_FOR_ARCHITECT.md`, and - `../docs/adr/0014-authenticated-principal-session.md` — read-only product context whose previous - single-tenant decision is superseded by approval of this plan. +- `apps/shell-super-app/tests/e2e/auth-fixture.ts` — one Better Auth user with two deterministic tenant-scoped Principal bindings. +- `apps/shell-super-app/tests/e2e/login.spec.ts` — browser selection, full reload, persistence, failure/retry, keyboard, and narrow-viewport behavior. +- `../docs/CONTEXT.md`, `../docs/20_DAY_3_GRILL_RESULTS_FOR_ARCHITECT.md`, and `../docs/adr/0014-authenticated-principal-session.md` — read-only product context whose previous single-tenant decision is superseded by approval of this plan. ### New Files -- `apps/shell-super-app/drizzle-auth/0001_*.sql` — generated addition of nullable - `auth.session.active_tenant_id`; retain the actual suffix emitted by the repository Drizzle script. -- `apps/shell-super-app/drizzle-auth/meta/0001_snapshot.json` — generated schema snapshot paired with - the migration; update the journal only through Drizzle generation. +- `apps/shell-super-app/drizzle-auth/0001_*.sql` — generated addition of nullable `auth.session.active_tenant_id`; retain the actual suffix emitted by the repository Drizzle script. +- `apps/shell-super-app/drizzle-auth/meta/0001_snapshot.json` — generated schema snapshot paired with the migration; update the journal only through Drizzle generation. ## Implementation Plan ### Phase 1: Foundation -Document the new multi-tenant session interpretation, then replace ambiguous user-only resolution -with explicit Core queries for available tenant-scoped identities and one selected identity. Extend -the private Better Auth session schema and generate its migration. Establish deterministic initial -and legacy-session selection while keeping invalid existing selections fail-closed. +Document the new multi-tenant session interpretation, then replace ambiguous user-only resolution with explicit Core queries for available tenant-scoped identities and one selected identity. Extend the private Better Auth session schema and generate its migration. Establish deterministic initial and legacy-session selection while keeping invalid existing selections fail-closed. ### Phase 2: Core Implementation -Publish strict Effect list/switch contracts and handlers, derive client Effects from those -contracts, and wire the controlled UI-kit Select into the existing authenticated dashboard. Keep -the previous tenant rendered until the backend confirms the switch; reload the whole localized -document only after success. +Publish strict Effect list/switch contracts and handlers, derive client Effects from those contracts, and wire the controlled UI-kit Select into the existing authenticated dashboard. Keep the previous tenant rendered until the backend confirms the switch; reload the whole localized document only after success. ### Phase 3: Integration -Exercise one Better Auth user bound to two tenant-scoped Principals through Core resolution, Auth -session persistence, active-module reads, gateway assertions, localized UI, keyboard interaction, -failure/retry, and a narrow viewport. Run database, focused, contract, build, and repository gates. +Exercise one Better Auth user bound to two tenant-scoped Principals through Core resolution, Auth session persistence, active-module reads, gateway assertions, localized UI, keyboard interaction, failure/retry, and a narrow viewport. Run database, focused, contract, build, and repository gates. ## Step by Step Tasks @@ -178,197 +98,81 @@ IMPORTANT: Execute every step in order, top to bottom. ### 1. Record the approved authentication-context model -- [x] Update the Authentication Boundary in `docs/architecture/MICROVERTICALS.md` to state that one - Better Auth user may resolve to multiple tenant-scoped Principals, while exactly one active tenant - ID from the current Better Auth session selects the trusted Principal and tenant context used for - reads, gateway assertions, and Actions. -- [x] Update `docs/architecture/ACTIONS.md` narrowly: Better Auth credential and session lifecycle - operations—sign-in, sign-out/revocation, refresh, and active tenant selection—are Shell-owned - authentication mechanics rather than canonical business-state mutations. They use the strict - typed Auth BFF and must not update Core business tables, emit Domain Events, or bypass Action rules - for any later business change. -- [x] State in both changes that Core Principal Auth Bindings remain the tenant-access authority and - that the selected Auth session field grants no permission. Do not introduce a global Principal, - Better Auth Organization/member tables, an Auth MicroVertical, or a generic context store. +- [x] Update the Authentication Boundary in `docs/architecture/MICROVERTICALS.md` to state that one Better Auth user may resolve to multiple tenant-scoped Principals, while exactly one active tenant ID from the current Better Auth session selects the trusted Principal and tenant context used for reads, gateway assertions, and Actions. +- [x] Update `docs/architecture/ACTIONS.md` narrowly: Better Auth credential and session lifecycle operations—sign-in, sign-out/revocation, refresh, and active tenant selection—are Shell-owned authentication mechanics rather than canonical business-state mutations. They use the strict typed Auth BFF and must not update Core business tables, emit Domain Events, or bypass Action rules for any later business change. +- [x] State in both changes that Core Principal Auth Bindings remain the tenant-access authority and that the selected Auth session field grants no permission. Do not introduce a global Principal, Better Auth Organization/member tables, an Auth MicroVertical, or a generic context store. ### 2. Make Core principal resolution tenant-aware -- [x] Refactor `packages/core-runtime/src/auth/principal-resolver.ts` so the service can list safe - active tenant choices for a Better Auth user and resolve the same user for one exact tenant ID. - Include only active, non-revoked bindings joined to active Principals and active Tenants; return - the tenant name for presentation and the tenant-scoped `principalId`/`displayName` only for the - selected identity. -- [x] Keep tenant selection out of payload-derived trusted context: the requested tenant ID is a - candidate that the resolver must match to the authenticated Better Auth user. A missing, foreign, - revoked, disabled, suspended, or archived target fails closed without revealing whether another - account can access it. -- [x] Define deterministic helpers: UI choices sort by tenant name and then tenant ID; initial or - legacy selection uses the oldest eligible binding `createdAt` and then tenant ID. Once a session - has a selected ID, never silently fall back to another tenant when that selection becomes invalid. -- [x] Adapt the typed errors and narrow Core exports in - `principal-resolver-errors.ts`/`src/index.ts`; do not export repositories, Drizzle tables, or a - generic tenant-membership service. -- [x] Update `packages/core-runtime/tests/unit/principal-resolver.test.ts` beside the refactor for - zero, one, and multiple tenant bindings; ordering and default choice; exact selected resolution; - duplicate/invalid record defense; inactive binding/Principal/Tenant filtering; foreign target - denial; and database-unavailable typing. -- [x] Update `packages/core-runtime/tests/integration/principal-resolver.test.ts` with two active - tenant-scoped Principals for one Better Auth subject. Prove both appear safely, either can be - selected explicitly, cross-user/foreign IDs fail closed, and revocation or tenant suspension - immediately removes access without tenant leakage. +- [x] Refactor `packages/core-runtime/src/auth/principal-resolver.ts` so the service can list safe active tenant choices for a Better Auth user and resolve the same user for one exact tenant ID. Include only active, non-revoked bindings joined to active Principals and active Tenants; return the tenant name for presentation and the tenant-scoped `principalId`/`displayName` only for the selected identity. +- [x] Keep tenant selection out of payload-derived trusted context: the requested tenant ID is a candidate that the resolver must match to the authenticated Better Auth user. A missing, foreign, revoked, disabled, suspended, or archived target fails closed without revealing whether another account can access it. +- [x] Define deterministic helpers: UI choices sort by tenant name and then tenant ID; initial or legacy selection uses the oldest eligible binding `createdAt` and then tenant ID. Once a session has a selected ID, never silently fall back to another tenant when that selection becomes invalid. +- [x] Adapt the typed errors and narrow Core exports in `principal-resolver-errors.ts`/`src/index.ts`; do not export repositories, Drizzle tables, or a generic tenant-membership service. +- [x] Update `packages/core-runtime/tests/unit/principal-resolver.test.ts` beside the refactor for zero, one, and multiple tenant bindings; ordering and default choice; exact selected resolution; duplicate/invalid record defense; inactive binding/Principal/Tenant filtering; foreign target denial; and database-unavailable typing. +- [x] Update `packages/core-runtime/tests/integration/principal-resolver.test.ts` with two active tenant-scoped Principals for one Better Auth subject. Prove both appear safely, either can be selected explicitly, cross-user/foreign IDs fail closed, and revocation or tenant suspension immediately removes access without tenant leakage. ### 3. Persist the selected tenant on the private Better Auth session -- [x] Add nullable `activeTenantId`/`active_tenant_id` to the `auth.session` Drizzle schema and to - Better Auth's `session.additionalFields` configuration in `api/auth/service.ts`. Permit Better - Auth's server-side `updateSession` operation to write it, while retaining the current architecture - in which no raw Better Auth router or browser client is exposed. -- [x] Run `mise exec -- pnpm --filter @app/shell-super-app db:generate` from `app/` immediately after - the schema change and commit only the generated Auth migration, snapshot, and journal updates. - Do not hand-author or rename generated migration metadata. -- [x] Change the session-create hook to resolve eligible Core bindings and write the deterministic - default tenant ID. Preserve sign-in failure semantics: no eligible tenant remains forbidden and a - resolver/database outage remains retryable/unavailable. -- [x] For pre-migration sessions whose selected value is null, resolve the same deterministic - default and persist it through Better Auth `updateSession` before returning an authenticated - OntOS identity. Combine and forward all Better Auth `Set-Cookie` headers. Do not automatically - replace a non-null selection that has become invalid. -- [x] Extend `AuthenticationServiceShape` with safe available-tenant and switch operations. Listing - must return only `{ tenantId, name }`; switching must first resolve the exact target through Core, - treat the current target idempotently, update only the current session, and return the selected - tenant ID. Never return the Better Auth user ID, session ID/token, binding ID, credentials, or - another tenant's Principal ID. -- [x] Add a typed forbidden-target error distinct from dependency unavailability and internal - defects, and map Better Auth errors without leaking adapter, SQL, binding, or session details. -- [x] Extend `apps/shell-super-app/tests/unit/auth-schema.test.ts` to assert the exact typed nullable - session column and retain the four-table Auth ownership contract. +- [x] Add nullable `activeTenantId`/`active_tenant_id` to the `auth.session` Drizzle schema and to Better Auth's `session.additionalFields` configuration in `api/auth/service.ts`. Permit Better Auth's server-side `updateSession` operation to write it, while retaining the current architecture in which no raw Better Auth router or browser client is exposed. +- [x] Run `mise exec -- pnpm --filter @app/shell-super-app db:generate` from `app/` immediately after the schema change and commit only the generated Auth migration, snapshot, and journal updates. Do not hand-author or rename generated migration metadata. +- [x] Change the session-create hook to resolve eligible Core bindings and write the deterministic default tenant ID. Preserve sign-in failure semantics: no eligible tenant remains forbidden and a resolver/database outage remains retryable/unavailable. +- [x] For pre-migration sessions whose selected value is null, resolve the same deterministic default and persist it through Better Auth `updateSession` before returning an authenticated OntOS identity. Combine and forward all Better Auth `Set-Cookie` headers. Do not automatically replace a non-null selection that has become invalid. +- [x] Extend `AuthenticationServiceShape` with safe available-tenant and switch operations. Listing must return only `{ tenantId, name }`; switching must first resolve the exact target through Core, treat the current target idempotently, update only the current session, and return the selected tenant ID. Never return the Better Auth user ID, session ID/token, binding ID, credentials, or another tenant's Principal ID. +- [x] Add a typed forbidden-target error distinct from dependency unavailability and internal defects, and map Better Auth errors without leaking adapter, SQL, binding, or session details. +- [x] Extend `apps/shell-super-app/tests/unit/auth-schema.test.ts` to assert the exact typed nullable session column and retain the four-table Auth ownership contract. ### 4. Publish strict Effect tenant list and switch operations -- [x] Add `AvailableTenant`, available-tenant response, switch payload, and switch response Effect - Schemas in `shared/api.ts`. Validate target tenant IDs with the repository UUID pattern - (`Schema.String.check(Schema.isUUID())`), strip unknown response fields, and add stable contract - paths for `GET /auth/tenants` and `POST /auth/tenant/switch`. -- [x] Declare operation-specific Problem Details: `401` plus `WWW-Authenticate` for no usable - session, `403` for a target outside the authenticated user's active bindings, retryable `503` for - required Auth/Core capability unavailability, and safe `500` for caught unexpected defects. - Structural payload decoding remains the framework's `400` path. -- [x] Implement both handlers in `api/index.ts` as Effects over `AuthenticationService`. Exhaustively - map every typed runtime error, forward refreshed/updated cookies, attach the authentication - challenge only to `401`, and catch/log defects with correlation context before returning the - declared safe `500`. -- [x] Add `availableTenants` and `switchTenant` to the generated client wrapper in - `src/api/auth-client.ts`, retaining declared backend, HTTP transport, and Schema decode errors in - precise operation-specific Effect unions. Do not add ad hoc `fetch` or a raw Better Auth client. -- [x] Extend `tests/unit/auth-contract.test.ts` to prove endpoint names, methods, exact paths, - request/response decoding, UUID rejection, safe field stripping, declared statuses, and absence - of passwords, session identifiers, tokens, binding IDs, and foreign Principal IDs. +- [x] Add `AvailableTenant`, available-tenant response, switch payload, and switch response Effect Schemas in `shared/api.ts`. Validate target tenant IDs with the repository UUID pattern (`Schema.String.check(Schema.isUUID())`), strip unknown response fields, and add stable contract paths for `GET /auth/tenants` and `POST /auth/tenant/switch`. +- [x] Declare operation-specific Problem Details: `401` plus `WWW-Authenticate` for no usable session, `403` for a target outside the authenticated user's active bindings, retryable `503` for required Auth/Core capability unavailability, and safe `500` for caught unexpected defects. Structural payload decoding remains the framework's `400` path. +- [x] Implement both handlers in `api/index.ts` as Effects over `AuthenticationService`. Exhaustively map every typed runtime error, forward refreshed/updated cookies, attach the authentication challenge only to `401`, and catch/log defects with correlation context before returning the declared safe `500`. +- [x] Add `availableTenants` and `switchTenant` to the generated client wrapper in `src/api/auth-client.ts`, retaining declared backend, HTTP transport, and Schema decode errors in precise operation-specific Effect unions. Do not add ad hoc `fetch` or a raw Better Auth client. +- [x] Extend `tests/unit/auth-contract.test.ts` to prove endpoint names, methods, exact paths, request/response decoding, UUID rejection, safe field stripping, declared statuses, and absence of passwords, session identifiers, tokens, binding IDs, and foreign Principal IDs. ### 5. Integrate independent tenant-list loading into authenticated Home -- [x] Extend `AuthenticatedHomePageModel` in `page.data.ts` with an independently recoverable tenant - list state. Resolve the current session first; only for an authenticated identity, load active - modules and available tenants through their generated client Effects without allowing one - recoverable read failure to erase the other successful result. -- [x] Treat a tenant-list authentication failure after session resolution as anonymous/stale - session. Map declared unavailable/internal/transport/decode failures to a tenant-list unavailable - state that retains the trusted current identity and module result. Always keep a safe fallback - item for the current tenant ID so the disabled Select still shows the context being retained. -- [x] Extend `tests/unit/routes/home/loader.test.ts` for anonymous isolation, two successful choices, - deterministic order, independent module/tenant failures, current-tenant fallback, stale-session - teardown, safe serialization, and forwarding the request cookie/base URL only through the - generated client options. +- [x] Extend `AuthenticatedHomePageModel` in `page.data.ts` with an independently recoverable tenant list state. Resolve the current session first; only for an authenticated identity, load active modules and available tenants through their generated client Effects without allowing one recoverable read failure to erase the other successful result. +- [x] Treat a tenant-list authentication failure after session resolution as anonymous/stale session. Map declared unavailable/internal/transport/decode failures to a tenant-list unavailable state that retains the trusted current identity and module result. Always keep a safe fallback item for the current tenant ID so the disabled Select still shows the context being retained. +- [x] Extend `tests/unit/routes/home/loader.test.ts` for anonymous isolation, two successful choices, deterministic order, independent module/tenant failures, current-tenant fallback, stale-session teardown, safe serialization, and forwarding the request cookie/base URL only through the generated client options. ### 6. Replace the empty placeholder with the controlled UI-kit Select -- [x] Refactor the existing Select in `shell-frame.tsx`; do not create a new component file. Extend - the presentation contract with tenant choice view models, `currentTenantId`, availability, - switch-pending/failure state, and semantic `onTenantChange(tenantId)` while keeping Effects, - loaders, and page reload outside the layout. -- [x] Use the pinned `@techsio/ui-kit@0.25.1` Select with `items` containing `{ label, displayValue, -value }`, controlled array `value={[currentTenantId]}`, and the complete `Label`, `Control`, - `Trigger`, `ValueText`, `Positioner`, `Content`, `Item`, `ItemText`, `ItemIndicator`, and - `StatusText` anatomy. Use Select/Zag state and ARIA; do not add a native/custom select, manual - keyboard handlers, invented props, or component-appearance `className` overrides. -- [x] Keep the prior tenant selected until a successful backend result. Dispatch only one new, - non-empty value different from `currentTenantId`; disable selection while unavailable or pending - and when no alternative tenant exists. Show localized pending/unavailable/error feedback through - `Select.StatusText` with the correct `validateStatus` and live-region behavior. -- [x] Extend `tests/unit/layout.test.tsx` for current display name, ordered option labels/values, - controlled array selection, complete item anatomy, disabled zero/one/unavailable states, enabled - multi-tenant state, keyboard selection, ignored current/empty values, pending disablement, - accessible error association, and unchanged navigation/account/page-child behavior. +- [x] Refactor the existing Select in `shell-frame.tsx`; do not create a new component file. Extend the presentation contract with tenant choice view models, `currentTenantId`, availability, switch-pending/failure state, and semantic `onTenantChange(tenantId)` while keeping Effects, loaders, and page reload outside the layout. +- [x] Use the pinned `@techsio/ui-kit@0.25.1` Select with `items` containing `{ label, displayValue, value }`, controlled array `value={[currentTenantId]}`, and the complete `Label`, `Control`, `Trigger`, `ValueText`, `Positioner`, `Content`, `Item`, `ItemText`, `ItemIndicator`, and `StatusText` anatomy. Use Select/Zag state and ARIA; do not add a native/custom select, manual keyboard handlers, invented props, or component-appearance `className` overrides. +- [x] Keep the prior tenant selected until a successful backend result. Dispatch only one new, non-empty value different from `currentTenantId`; disable selection while unavailable or pending and when no alternative tenant exists. Show localized pending/unavailable/error feedback through `Select.StatusText` with the correct `validateStatus` and live-region behavior. +- [x] Extend `tests/unit/layout.test.tsx` for current display name, ordered option labels/values, controlled array selection, complete item anatomy, disabled zero/one/unavailable states, enabled multi-tenant state, keyboard selection, ignored current/empty values, pending disablement, accessible error association, and unchanged navigation/account/page-child behavior. ### 7. Switch the session and reload all page data -- [x] In `page.tsx`, add one guarded tenant-switch handler beside logout. Run the contract-derived - `switchTenant` Effect at the framework edge, clear the previous switch error, prevent duplicate - invocation while pending, and pass only presentation state/callbacks into - `AuthenticatedDashboardLayout`. -- [x] After success, call the browser's full document reload for the current localized URL. Do not - patch only identity or active-module React state, invalidate only one query, navigate to a fixed - route, or reuse data loaded under the old tenant. -- [x] On any typed backend, transport, or decode failure, clear pending state, retain the old - identity/current tenant/modules/navigation, keep the Select operable, and show localized retryable - feedback. If the session became anonymous, reload to the anonymous route state rather than retain - stale authenticated chrome. -- [x] Extend `tests/unit/routes/home/page.test.tsx` to prove exact target dispatch, duplicate and - same-value suppression, pending UI, no premature state replacement, full reload only after - success, retained old context on failure, retry success, anonymous teardown, and no credential or - session data exposure. +- [x] In `page.tsx`, add one guarded tenant-switch handler beside logout. Run the contract-derived `switchTenant` Effect at the framework edge, clear the previous switch error, prevent duplicate invocation while pending, and pass only presentation state/callbacks into `AuthenticatedDashboardLayout`. +- [x] After success, call the browser's full document reload for the current localized URL. Do not patch only identity or active-module React state, invalidate only one query, navigate to a fixed route, or reuse data loaded under the old tenant. +- [x] On any typed backend, transport, or decode failure, clear pending state, retain the old identity/current tenant/modules/navigation, keep the Select operable, and show localized retryable feedback. If the session became anonymous, reload to the anonymous route state rather than retain stale authenticated chrome. +- [x] Extend `tests/unit/routes/home/page.test.tsx` to prove exact target dispatch, duplicate and same-value suppression, pending UI, no premature state replacement, full reload only after success, retained old context on failure, retry success, anonymous teardown, and no credential or session data exposure. ### 8. Localize and prove multi-tenant runtime behavior -- [x] Replace the placeholder-only `shell.dashboard.tenant` copy in both Shell locale files with - aligned current selection, pending, unavailable, failure/retry, and accessible-label text. Reuse - existing general dashboard/auth strings where their meaning is exact. -- [x] Extend `tests/unit/routes/login/locales.test.ts` for exact English/Czech tenant namespace - parity and retain login, module, and dashboard locale contracts. -- [x] Expand `tests/integration/auth-runtime.test.ts` to create one Better Auth user with two active - tenant-scoped Principals and distinct tenant module state. Prove deterministic initial selection, - list redaction/order, successful and idempotent switch, persisted selection on a later session - read, changed active-module scope, and a newly issued gateway assertion containing only the new - tenant/principal context. -- [x] In the same integration suite, prove anonymous `401`, foreign/inactive target `403`, resolver - and Auth persistence `503`, safe unexpected `500`, no session mutation on every failure, legacy - null-session upgrade, and fail-closed behavior when the selected binding is revoked after switch. -- [x] Expand the E2E fixture to create two named tenants and two tenant-scoped Principals for one - user with deterministic binding creation order. Clean up both tenants, bindings, Principals, - module states, and Auth sessions without affecting unrelated data. -- [x] Extend `tests/e2e/login.spec.ts` to prove the initial tenant, keyboard and pointer selection, - switch request success, full document navigation, changed rendered Principal/tenant context, - persistence after another reload, and unchanged anonymous/login isolation in English and Czech. -- [x] Add browser failure/retry coverage that aborts one switch request and proves the old context - and selected value remain visible and usable before retry. At 375px, prove the Select menu, - feedback, Header, navigation, and page body remain reachable without horizontal overflow. -- [x] Run the UI-kit app adoption audit over the changed Shell files: verify the existing Select is - used, every prop exists in `0.25.1`, no native/custom primitive or unnecessary wrapper was added, - appearance remains token-first, and no UI-kit library or app-token override is required. +- [x] Replace the placeholder-only `shell.dashboard.tenant` copy in both Shell locale files with aligned current selection, pending, unavailable, failure/retry, and accessible-label text. Reuse existing general dashboard/auth strings where their meaning is exact. +- [x] Extend `tests/unit/routes/login/locales.test.ts` for exact English/Czech tenant namespace parity and retain login, module, and dashboard locale contracts. +- [x] Expand `tests/integration/auth-runtime.test.ts` to create one Better Auth user with two active tenant-scoped Principals and distinct tenant module state. Prove deterministic initial selection, list redaction/order, successful and idempotent switch, persisted selection on a later session read, changed active-module scope, and a newly issued gateway assertion containing only the new tenant/principal context. +- [x] In the same integration suite, prove anonymous `401`, foreign/inactive target `403`, resolver and Auth persistence `503`, safe unexpected `500`, no session mutation on every failure, legacy null-session upgrade, and fail-closed behavior when the selected binding is revoked after switch. +- [x] Expand the E2E fixture to create two named tenants and two tenant-scoped Principals for one user with deterministic binding creation order. Clean up both tenants, bindings, Principals, module states, and Auth sessions without affecting unrelated data. +- [x] Extend `tests/e2e/login.spec.ts` to prove the initial tenant, keyboard and pointer selection, switch request success, full document navigation, changed rendered Principal/tenant context, persistence after another reload, and unchanged anonymous/login isolation in English and Czech. +- [x] Add browser failure/retry coverage that aborts one switch request and proves the old context and selected value remain visible and usable before retry. At 375px, prove the Select menu, feedback, Header, navigation, and page body remain reachable without horizontal overflow. +- [x] Run the UI-kit app adoption audit over the changed Shell files: verify the existing Select is used, every prop exists in `0.25.1`, no native/custom primitive or unnecessary wrapper was added, appearance remains token-first, and no UI-kit library or app-token override is required. ### 9. Run all validation commands -- [x] From `app/`, execute every command under Validation Commands in order, resolve every - implementation-caused failure, then inspect `git diff --check`, the complete relevant diff, and - final `git status --short`. Record any unrelated baseline failure accurately rather than claiming - it passed. +- [x] From `app/`, execute every command under Validation Commands in order, resolve every implementation-caused failure, then inspect `git diff --check`, the complete relevant diff, and final `git status --short`. Record any unrelated baseline failure accurately rather than claiming it passed. ## Testing Strategy ### Unit Tests -Use Core resolver tests for tenant-choice classification, deterministic defaulting, exact selected -resolution, and fail-closed invalid states. Use Shell contract/schema/loader/component tests for the -Auth session field, declared list/switch schemas and errors, independent UI data states, valid -UI-kit Select anatomy, controlled values, semantic callbacks, duplicate guards, reload behavior, -failure retention, and English/Czech parity. +Use Core resolver tests for tenant-choice classification, deterministic defaulting, exact selected resolution, and fail-closed invalid states. Use Shell contract/schema/loader/component tests for the Auth session field, declared list/switch schemas and errors, independent UI data states, valid UI-kit Select anatomy, controlled values, semantic callbacks, duplicate guards, reload behavior, failure retention, and English/Czech parity. ### Integration Tests -Use the existing PostgreSQL-backed Core resolver and Shell Auth runtime suites with one Better Auth -user bound to two tenant-scoped Principals. Prove the selected ID is persisted on the Better Auth -session but always reauthorized through Core, and that active-module reads and gateway assertions -change tenant/principal only after a successful switch. Use Playwright for real cookie persistence, -full document reload, failure/retry, keyboard, localization, and responsive behavior. +Use the existing PostgreSQL-backed Core resolver and Shell Auth runtime suites with one Better Auth user bound to two tenant-scoped Principals. Prove the selected ID is persisted on the Better Auth session but always reauthorized through Core, and that active-module reads and gateway assertions change tenant/principal only after a successful switch. Use Playwright for real cookie persistence, full document reload, failure/retry, keyboard, localization, and responsive behavior. ### Edge Cases @@ -380,71 +184,42 @@ full document reload, failure/retry, keyboard, localization, and responsive beha - The current tenant is selected again or repeated selection occurs while a switch is pending. - Core tenant listing succeeds while active-module loading fails, or vice versa. - Tenant listing, Better Auth session update, transport, response decoding, or document reload fails. -- Two browser tabs share one session and the latest completed session update becomes authoritative; - either tab revalidates from the server on its next request/reload. +- Two browser tabs share one session and the latest completed session update becomes authoritative; either tab revalidates from the server on its next request/reload. - A tenant name is long, the viewport is 375px wide, or the user operates the Select by keyboard. -- A response attempts to include credentials, session data, binding IDs, or another tenant's - Principal details. +- A response attempts to include credentials, session data, binding IDs, or another tenant's Principal details. ## Acceptance Criteria -- [x] One Better Auth user can have multiple active Core Principal Auth Bindings, one per tenant, - without creating a global Principal or weakening tenant-scoped Principal identity. -- [x] A Better Auth session stores exactly one nullable active tenant ID; Core bindings remain the - authority and every session resolution revalidates the selected binding, Principal, and Tenant. -- [x] New and legacy sessions choose the oldest eligible binding deterministically, while an - invalid non-null selection fails closed rather than silently changing tenants. -- [x] The strict Shell Effect BFF exposes a safe available-tenant list and a typed switch operation; - no raw Better Auth route/client, ad hoc fetch, Organization plugin, Auth vertical, or duplicated - membership table is introduced. -- [x] Anonymous, forbidden target, unavailable dependency, malformed payload, and unexpected defect - paths use correct declared HTTP semantics and safe Problem Details; `401` includes - `WWW-Authenticate` and no error leaks tenant existence, SQL, bindings, sessions, or credentials. -- [x] The sidebar Select shows the current tenant and every active available tenant in deterministic - order, uses tenant IDs as values and tenant names as display text, and never includes an inactive, - revoked, disabled, suspended, archived, or foreign tenant. -- [x] The Select uses the pinned UI-kit compound API with an array value, complete item anatomy, - keyboard/ARIA behavior, and `Select.StatusText`; no custom/native replacement, plain CSS, - component appearance override, new UI component, or UI-kit library change is added. -- [x] The Select is disabled when choices are unavailable, a switch is pending, or no alternative - exists; multi-tenant users can select by keyboard or pointer and duplicate invocation is guarded. -- [x] Successful switching updates only the current Better Auth session and triggers a full reload - of the current localized page; the resulting identity, modules, navigation data, and future - gateway assertions use the new tenant-scoped Principal context. -- [x] Failed switching never changes the persisted or displayed tenant context, retains all old - page data, presents localized accessible feedback, and allows retry. -- [x] English and Czech copy remain structurally aligned; anonymous and login pages never expose the - tenant switcher or authenticated dashboard chrome. -- [x] Unit, PostgreSQL integration, and browser tests cover initial selection, listing, success, - persistence, full reload, idempotence, failure/retry, revocation, redaction, keyboard, and narrow - viewport behavior. -- [x] The final implementation documents the approved exception for Better Auth session mechanics - without weakening the Action requirement for canonical Core or MicroVertical business state. +- [x] One Better Auth user can have multiple active Core Principal Auth Bindings, one per tenant, without creating a global Principal or weakening tenant-scoped Principal identity. +- [x] A Better Auth session stores exactly one nullable active tenant ID; Core bindings remain the authority and every session resolution revalidates the selected binding, Principal, and Tenant. +- [x] New and legacy sessions choose the oldest eligible binding deterministically, while an invalid non-null selection fails closed rather than silently changing tenants. +- [x] The strict Shell Effect BFF exposes a safe available-tenant list and a typed switch operation; no raw Better Auth route/client, ad hoc fetch, Organization plugin, Auth vertical, or duplicated membership table is introduced. +- [x] Anonymous, forbidden target, unavailable dependency, malformed payload, and unexpected defect paths use correct declared HTTP semantics and safe Problem Details; `401` includes `WWW-Authenticate` and no error leaks tenant existence, SQL, bindings, sessions, or credentials. +- [x] The sidebar Select shows the current tenant and every active available tenant in deterministic order, uses tenant IDs as values and tenant names as display text, and never includes an inactive, revoked, disabled, suspended, archived, or foreign tenant. +- [x] The Select uses the pinned UI-kit compound API with an array value, complete item anatomy, keyboard/ARIA behavior, and `Select.StatusText`; no custom/native replacement, plain CSS, component appearance override, new UI component, or UI-kit library change is added. +- [x] The Select is disabled when choices are unavailable, a switch is pending, or no alternative exists; multi-tenant users can select by keyboard or pointer and duplicate invocation is guarded. +- [x] Successful switching updates only the current Better Auth session and triggers a full reload of the current localized page; the resulting identity, modules, navigation data, and future gateway assertions use the new tenant-scoped Principal context. +- [x] Failed switching never changes the persisted or displayed tenant context, retains all old page data, presents localized accessible feedback, and allows retry. +- [x] English and Czech copy remain structurally aligned; anonymous and login pages never expose the tenant switcher or authenticated dashboard chrome. +- [x] Unit, PostgreSQL integration, and browser tests cover initial selection, listing, success, persistence, full reload, idempotence, failure/retry, revocation, redaction, keyboard, and narrow viewport behavior. +- [x] The final implementation documents the approved exception for Better Auth session mechanics without weakening the Action requirement for canonical Core or MicroVertical business state. ## Validation Commands Execute every command to validate the feature with zero regressions. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — run Core resolver unit and PostgreSQL - integration coverage, including multi-tenant selection and fail-closed states. -- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — run Auth schema/contract, loader, - dashboard Select, switching, localization, and existing Shell unit coverage. -- `mise exec -- pnpm --filter @app/shell-super-app db:generate` — prove the committed Auth schema and - generated migration snapshot are synchronized and produce no additional migration. -- `mise exec -- pnpm db:migrate` — apply Core and Shell Auth migrations, including - `auth.session.active_tenant_id`. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — run Core resolver unit and PostgreSQL integration coverage, including multi-tenant selection and fail-closed states. +- `mise exec -- pnpm --filter @app/shell-super-app test:unit` — run Auth schema/contract, loader, dashboard Select, switching, localization, and existing Shell unit coverage. +- `mise exec -- pnpm --filter @app/shell-super-app db:generate` — prove the committed Auth schema and generated migration snapshot are synchronized and produce no additional migration. +- `mise exec -- pnpm db:migrate` — apply Core and Shell Auth migrations, including `auth.session.active_tenant_id`. - `mise exec -- pnpm db:verify` — verify the typed Core/Auth schema against PostgreSQL after migration. -- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — prove real Better Auth session - creation, legacy upgrade, list/switch persistence, failures, module scope, and gateway context. -- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — prove localized browser selection, - reload, persistence, failure/retry, keyboard, and responsive behavior. +- `mise exec -- pnpm --filter @app/shell-super-app test:integration` — prove real Better Auth session creation, legacy upgrade, list/switch persistence, failures, module scope, and gateway context. +- `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — prove localized browser selection, reload, persistence, failure/retry, keyboard, and responsive behavior. - `mise exec -- pnpm i18n:boundaries` — validate Shell locale ownership and user-facing string rules. - `mise exec -- pnpm api:check` — validate strict Effect BFF topology and server/browser boundaries. - `mise exec -- pnpm contract:check` — validate route, topology, package, and ownership contracts. -- `mise exec -- pnpm typecheck` — type-check Core, Shell, the Better Auth additional session field, - generated client operations, UI-kit props, and tests. -- `mise exec -- pnpm build` — build the Shell, Module Federation types, and runtime bundles affected - by the new BFF and browser interaction. +- `mise exec -- pnpm typecheck` — type-check Core, Shell, the Better Auth additional session field, generated client operations, UI-kit props, and tests. +- `mise exec -- pnpm build` — build the Shell, Module Federation types, and runtime bundles affected by the new BFF and browser interaction. - `git diff --check` — detect whitespace errors and conflict markers. - `mise exec -- pnpm check` — Run the final repository quality gate. @@ -460,124 +235,69 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Implemented the complete tenant switcher and resolved the follow-up review findings: precise - operation error unions, exhaustive client failure mapping before the Promise edge, shared resolver - error translation, real resolver/Auth persistence failure handling, preservation of unexpected Auth - update failures as correlation-logged defects, complete switch suppression and redaction tests, - responsive failure feedback coverage, and removal of dead locale keys. -- Resolved the final repository-gate blockers: topology tests now derive installed verticals from the - authoritative topology, the tracked agent-skills lock and license satisfy fresh-checkout validation, - full-document tenant reload uses the router's native `reloadDocument` option, and generated topology - validation includes the tenant list/switch operations. -- Confirmed all work ran in `/Users/jiprochazka/.codex/worktrees/b361/ontos`, the requested `b361` - worktree at baseline `cc4fefea`. +- Implemented the complete tenant switcher and resolved the follow-up review findings: precise operation error unions, exhaustive client failure mapping before the Promise edge, shared resolver error translation, real resolver/Auth persistence failure handling, preservation of unexpected Auth update failures as correlation-logged defects, complete switch suppression and redaction tests, responsive failure feedback coverage, and removal of dead locale keys. +- Resolved the final repository-gate blockers: topology tests now derive installed verticals from the authoritative topology, the tracked agent-skills lock and license satisfy fresh-checkout validation, full-document tenant reload uses the router's native `reloadDocument` option, and generated topology validation includes the tenant list/switch operations. +- Confirmed all work ran in `/Users/jiprochazka/.codex/worktrees/b361/ontos`, the requested `b361` worktree at baseline `cc4fefea`. ### Changed Files -- Final task diff: 32 tracked files plus 5 new files, including the generated Auth migration, - snapshot, tracked skills metadata, and this specification; 2,221 tracked insertions and 204 tracked - deletions before this evidence update. +- Final task diff: 32 tracked files plus 5 new files, including the generated Auth migration, snapshot, tracked skills metadata, and this specification; 2,221 tracked insertions and 204 tracked deletions before this evidence update. ### Tests Written or Updated -- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — proves successful single-binding - listing/default/selected resolution and timestamp-based binding revocation in addition to the - zero/multiple and status-based invalid-state matrix. -- `apps/shell-super-app/tests/unit/layout.test.tsx` — proves disabled zero-choice state plus ignored - current and empty Select value events. -- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — proves real Core resolver and Better - Auth persistence `503` paths, plus unexpected Better Auth persistence defects through the real switch - and legacy-session upgrade paths; verifies correlation-aware `500` handling, response redaction, and - unchanged persisted tenant context after every failure. -- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — proves exact/redacted dispatch, - pending duplicate suppression, current-value suppression, typed failures, retry, and router-native - full-document reload behavior. -- `apps/shell-super-app/tests/unit/auth-boundary.test.ts` — proves Shell topology vertical references - and Module Federation remotes stay aligned without hardcoding a removed MicroVertical. -- `apps/shell-super-app/tests/unit/installed-verticals.test.ts` — proves the runtime derives the exact - installed IDs injected from the authoritative reference topology without hardcoded registrations. -- `apps/shell-super-app/tests/e2e/login.spec.ts` — proves failure feedback, tenant retention, Header, - navigation, page body, menu, and overflow behavior at 375px. -- Existing tenant resolver, contract, schema, loader, Select, locale, integration, and E2E coverage - remains part of the completed feature. +- `packages/core-runtime/tests/unit/principal-resolver.test.ts` — proves successful single-binding listing/default/selected resolution and timestamp-based binding revocation in addition to the zero/multiple and status-based invalid-state matrix. +- `apps/shell-super-app/tests/unit/layout.test.tsx` — proves disabled zero-choice state plus ignored current and empty Select value events. +- `apps/shell-super-app/tests/integration/auth-runtime.test.ts` — proves real Core resolver and Better Auth persistence `503` paths, plus unexpected Better Auth persistence defects through the real switch and legacy-session upgrade paths; verifies correlation-aware `500` handling, response redaction, and unchanged persisted tenant context after every failure. +- `apps/shell-super-app/tests/unit/routes/home/page.test.tsx` — proves exact/redacted dispatch, pending duplicate suppression, current-value suppression, typed failures, retry, and router-native full-document reload behavior. +- `apps/shell-super-app/tests/unit/auth-boundary.test.ts` — proves Shell topology vertical references and Module Federation remotes stay aligned without hardcoding a removed MicroVertical. +- `apps/shell-super-app/tests/unit/installed-verticals.test.ts` — proves the runtime derives the exact installed IDs injected from the authoritative reference topology without hardcoded registrations. +- `apps/shell-super-app/tests/e2e/login.spec.ts` — proves failure feedback, tenant retention, Header, navigation, page body, menu, and overflow behavior at 375px. +- Existing tenant resolver, contract, schema, loader, Select, locale, integration, and E2E coverage remains part of the completed feature. ### Validation -- `mise exec -- pnpm --filter @app/core-runtime exec node --test tests/unit/principal-resolver.test.ts` - — passed, 6/6. -- `mise exec -- pnpm --filter @app/shell-super-app exec rstest tests/unit/layout.test.tsx` — passed, - 9/9. +- `mise exec -- pnpm --filter @app/core-runtime exec node --test tests/unit/principal-resolver.test.ts` — passed, 6/6. +- `mise exec -- pnpm --filter @app/shell-super-app exec rstest tests/unit/layout.test.tsx` — passed, 9/9. - `mise exec -- pnpm --filter @app/shell-super-app exec rstest tests/unit/layout.test.tsx tests/unit/routes/home/page.test.tsx tests/unit/routes/login/locales.test.ts` — passed, 25/25. - `mise exec -- pnpm --filter @app/shell-super-app test:integration` — passed, 2/2. - `mise exec -- pnpm --filter @app/shell-super-app exec playwright test tests/e2e/login.spec.ts --grep "authenticated dashboard reachable"` — passed, 1/1. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — failed on the unrelated drifted default Core - database and existing SpiceDB configuration; tenant-switcher-focused resolver tests passed during - implementation against the dedicated validation database. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — failed on the unrelated drifted default Core database and existing SpiceDB configuration; tenant-switcher-focused resolver tests passed during implementation against the dedicated validation database. - `mise exec -- pnpm --filter @app/shell-super-app test:unit` — passed, 63/63. - `mise exec -- pnpm --filter @app/shell-super-app db:generate` — passed with no schema changes. -- `mise exec -- pnpm db:migrate` — blocked by the default database's legacy Core migration journal; - the generated Auth migration previously applied successfully to the dedicated validation database. -- `mise exec -- pnpm db:verify` — failed on the unrelated drifted default - `core.action_invocations` schema. +- `mise exec -- pnpm db:migrate` — blocked by the default database's legacy Core migration journal; the generated Auth migration previously applied successfully to the dedicated validation database. +- `mise exec -- pnpm db:verify` — failed on the unrelated drifted default `core.action_invocations` schema. - `mise exec -- pnpm --filter @app/shell-super-app test:e2e` — passed, 10/10. - `mise exec -- pnpm i18n:boundaries` — passed. - `mise exec -- pnpm api:check` — passed. -- `mise exec -- pnpm skills:check` — passed using the tracked `.agents/skills-lock.json`; absent local - skill bodies are an expected advisory in fresh check-only environments. +- `mise exec -- pnpm skills:check` — passed using the tracked `.agents/skills-lock.json`; absent local skill bodies are an expected advisory in fresh check-only environments. - `mise exec -- pnpm contract:check` — passed; the UltraModern workspace scaffold validated. - `mise exec -- pnpm typecheck` — passed. -- `mise exec -- pnpm build` — passed after the router-native full-document navigation change, - including Module Federation types and performance readiness. +- `mise exec -- pnpm build` — passed after the router-native full-document navigation change, including Module Federation types and performance readiness. - `git diff --check` — passed. -- `mise exec -- pnpm exec oxlint packages/core-runtime/tests/unit/principal-resolver.test.ts apps/shell-super-app/tests/unit/layout.test.tsx apps/shell-super-app/tests/integration/auth-runtime.test.ts` - — passed. -- `mise exec -- pnpm exec oxfmt --check packages/core-runtime/tests/unit/principal-resolver.test.ts apps/shell-super-app/tests/unit/layout.test.tsx apps/shell-super-app/tests/integration/auth-runtime.test.ts` - — passed. +- `mise exec -- pnpm exec oxlint packages/core-runtime/tests/unit/principal-resolver.test.ts apps/shell-super-app/tests/unit/layout.test.tsx apps/shell-super-app/tests/integration/auth-runtime.test.ts` — passed. +- `mise exec -- pnpm exec oxfmt --check packages/core-runtime/tests/unit/principal-resolver.test.ts apps/shell-super-app/tests/unit/layout.test.tsx apps/shell-super-app/tests/integration/auth-runtime.test.ts` — passed. - `mise exec -- pnpm lint` — previously passed for the complete implementation. -- `mise exec -- pnpm check` — passed end to end: formatting, lint, Action unit tests, typecheck, - skills, i18n, API, contract, and performance readiness. +- `mise exec -- pnpm check` — passed end to end: formatting, lint, Action unit tests, typecheck, skills, i18n, API, contract, and performance readiness. ### Review -- Re-read and complied with both applicable `AGENTS.md` files plus `MICROVERTICALS.md`, `ACTIONS.md`, - `ERRORS.md`, `DATABASE.md`, `OUTBOX_WORKERS.md`, `ULTRAMODERN.md`, `FRONTEND.md`, and the referenced - product/ADR context. -- Resolved all three missing Spec test findings. No raw Better Auth route/client, ad hoc fetch, Action - bypass, UI-kit replacement, plain CSS, new component, or unrelated API was added. -- Resolved the P1 Auth error-boundary finding: known session-update failures remain typed, while unknown - rejections retain their private Effect cause until the switch/list/current-session HTTP boundary logs - it with correlation context and returns the declared redacted `500`. -- Resolved all quality-gate drift without suppressions: replaced direct `window.location` use with the - router's full-document primitive, synchronized the topology validation contract, and added the - scaffold-pinned agent skills metadata under the supported tracked `.agents` layout. -- Browser validation passed the full localized pointer, keyboard, retry, reload, persistence, and - responsive suite. +- Re-read and complied with both applicable `AGENTS.md` files plus `MICROVERTICALS.md`, `ACTIONS.md`, `ERRORS.md`, `DATABASE.md`, `OUTBOX_WORKERS.md`, `ULTRAMODERN.md`, `FRONTEND.md`, and the referenced product/ADR context. +- Resolved all three missing Spec test findings. No raw Better Auth route/client, ad hoc fetch, Action bypass, UI-kit replacement, plain CSS, new component, or unrelated API was added. +- Resolved the P1 Auth error-boundary finding: known session-update failures remain typed, while unknown rejections retain their private Effect cause until the switch/list/current-session HTTP boundary logs it with correlation context and returns the declared redacted `500`. +- Resolved all quality-gate drift without suppressions: replaced direct `window.location` use with the router's full-document primitive, synchronized the topology validation contract, and added the scaffold-pinned agent skills metadata under the supported tracked `.agents` layout. +- Browser validation passed the full localized pointer, keyboard, retry, reload, persistence, and responsive suite. ### Deviations and Follow-ups - The latest re-review's P3 tenant-switcher prop data-clump remains a non-blocking design follow-up. -- The default local database still needs its pre-existing Core migration journal/schema drift repaired - before repository-wide database migration, verification, and Core integration gates can pass there. +- The default local database still needs its pre-existing Core migration journal/schema drift repaired before repository-wide database migration, verification, and Core integration gates can pass there. ## Notes - Source request: GitHub issue #78, `Implement the Tenant switcher`. -- Approval of this plan explicitly accepts issue #78 as superseding the older repository-level - `Tenant-Scoped BetterAuth User`/no-selector product decision. Repository-level `../docs` are - read-only under the current agent instruction and therefore remain historical context; the - authoritative implementation clarification is recorded under `app/docs/architecture/`. -- The developer confirmed on 2026-08-06 that Better Auth credential/session lifecycle, including - active tenant selection, is authentication mechanics analogous to existing sign-in/sign-out and - is not an Action. Canonical Core and MicroVertical business state changes remain Action-driven. -- The selected tenant is session-scoped, not a cross-session user preference. A new session starts - at the oldest still-eligible binding; switching persists across reloads for that session only. -- Concurrent switches from multiple tabs use session-level last-completed-write semantics. Every - subsequent read revalidates the persisted selection through Core, and the UI prevents duplicate - requests within one page instance. -- Better Auth `1.6.23` was verified to expose `session.additionalFields`, session-create database - hooks, and server-side `auth.api.updateSession`. `@techsio/ui-kit@0.25.1` was verified to expose - the planned controlled array value, `displayValue`, `onValueChange`, complete item anatomy, - `validateStatus`, and `Select.StatusText` APIs. -- No mandatory Codesmith generator applies under the recommended design: no Action, - MicroVertical page, Outbox Message, or Policy is created. The Auth migration must be generated by - the existing Drizzle package script. +- Approval of this plan explicitly accepts issue #78 as superseding the older repository-level `Tenant-Scoped BetterAuth User`/no-selector product decision. Repository-level `../docs` are read-only under the current agent instruction and therefore remain historical context; the authoritative implementation clarification is recorded under `app/docs/architecture/`. +- The developer confirmed on 2026-08-06 that Better Auth credential/session lifecycle, including active tenant selection, is authentication mechanics analogous to existing sign-in/sign-out and is not an Action. Canonical Core and MicroVertical business state changes remain Action-driven. +- The selected tenant is session-scoped, not a cross-session user preference. A new session starts at the oldest still-eligible binding; switching persists across reloads for that session only. +- Concurrent switches from multiple tabs use session-level last-completed-write semantics. Every subsequent read revalidates the persisted selection through Core, and the UI prevents duplicate requests within one page instance. +- Better Auth `1.6.23` was verified to expose `session.additionalFields`, session-create database hooks, and server-side `auth.api.updateSession`. `@techsio/ui-kit@0.25.1` was verified to expose the planned controlled array value, `displayValue`, `onValueChange`, complete item anatomy, `validateStatus`, and `Select.StatusText` APIs. +- No mandatory Codesmith generator applies under the recommended design: no Action, MicroVertical page, Outbox Message, or Policy is created. The Auth migration must be generated by the existing Drizzle package script. diff --git a/app/specs/feature-universal-module-state-gate.md b/app/specs/feature-universal-module-state-gate.md index 2b32b784a..8e7d0d9a7 100644 --- a/app/specs/feature-universal-module-state-gate.md +++ b/app/specs/feature-universal-module-state-gate.md @@ -8,58 +8,25 @@ created: 2026-08-06 ## Feature Description -Make tenant module state a universal Core invariant for every OntOS Business Module entrypoint. -Core must decide whether a structured entrypoint is loadable or dispatchable before permission -checks, Policy evaluation, private handler resolution, Module Federation loading, or module code -execution. The same closed state/access matrix must govern Actions, pages, public components, -module APIs, search, reports, and Outbox Workers. - -Implement the reusable Core gate and gateway contracts now, integrate them with the Action and -Outbox Worker runtimes that exist on `develop`, and establish fail-closed descriptors, Codesmith -output, repository checks, and application guidance for categories that do not yet have business -implementations. Future entrypoint code must be unable to pass the normal `pnpm check` gate unless -it declares a structured entrypoint and uses an approved Shell/Core gateway. The gateway must -batch state reads and reuse one immutable request-scoped snapshot so composing many entrypoints -does not create one database query per page, component, search provider, or report. +Make tenant module state a universal Core invariant for every OntOS Business Module entrypoint. Core must decide whether a structured entrypoint is loadable or dispatchable before permission checks, Policy evaluation, private handler resolution, Module Federation loading, or module code execution. The same closed state/access matrix must govern Actions, pages, public components, module APIs, search, reports, and Outbox Workers. + +Implement the reusable Core gate and gateway contracts now, integrate them with the Action and Outbox Worker runtimes that exist on `develop`, and establish fail-closed descriptors, Codesmith output, repository checks, and application guidance for categories that do not yet have business implementations. Future entrypoint code must be unable to pass the normal `pnpm check` gate unless it declares a structured entrypoint and uses an approved Shell/Core gateway. The gateway must batch state reads and reuse one immutable request-scoped snapshot so composing many entrypoints does not create one database query per page, component, search provider, or report. ## User Story -As a tenant administrator and OntOS operator -I want every module capability to respect the tenant's current module state -So that inactive, read-only, suspended, quarantined, deprecated, or archived modules cannot be -loaded or executed through an overlooked entrypoint +As a tenant administrator and OntOS operator I want every module capability to respect the tenant's current module state So that inactive, read-only, suspended, quarantined, deprecated, or archived modules cannot be loaded or executed through an overlooked entrypoint ## Problem Statement -`develop` persists all seven tenant module states, exposes Core reads and a Core-owned state-change -Action, filters the current Shell list to installed active modules, and restricts Outbox Worker -claims to consuming modules whose state is `active`. These are isolated behaviors rather than one -Core invariant. - -The Action runtime demonstrates the gap. `runAction` decodes the payload, retrieves the private -handler, validates trusted context, creates or resolves an invocation, checks SpiceDB permission, -evaluates Policies, and executes the handler without checking the owning module's tenant state. -The current generated Action and Outbox Worker descriptors carry owner keys but no common -structured entrypoint or access requirement. Generated MicroVertical pages carry `ownerAppId` in -route metadata but no governed load requirement. No equivalent registration or enforcement -contract exists yet for module APIs, public components, search, or reports. - -This permits present and future bypasses: an inactive module Action can execute, a direct route or -remote load can avoid Shell filtering, and new API/search/report/component implementations could -invent local state checks or omit them entirely. A reusable `isActive` helper would still be -optional and would allow the seven-state semantics to drift between runtimes. Likewise, a gateway -that performs one exact database lookup per entrypoint would turn a composed Shell page into an -N+1 query path and make universal enforcement unnecessarily expensive. +`develop` persists all seven tenant module states, exposes Core reads and a Core-owned state-change Action, filters the current Shell list to installed active modules, and restricts Outbox Worker claims to consuming modules whose state is `active`. These are isolated behaviors rather than one Core invariant. + +The Action runtime demonstrates the gap. `runAction` decodes the payload, retrieves the private handler, validates trusted context, creates or resolves an invocation, checks SpiceDB permission, evaluates Policies, and executes the handler without checking the owning module's tenant state. The current generated Action and Outbox Worker descriptors carry owner keys but no common structured entrypoint or access requirement. Generated MicroVertical pages carry `ownerAppId` in route metadata but no governed load requirement. No equivalent registration or enforcement contract exists yet for module APIs, public components, search, or reports. + +This permits present and future bypasses: an inactive module Action can execute, a direct route or remote load can avoid Shell filtering, and new API/search/report/component implementations could invent local state checks or omit them entirely. A reusable `isActive` helper would still be optional and would allow the seven-state semantics to drift between runtimes. Likewise, a gateway that performs one exact database lookup per entrypoint would turn a composed Shell page into an N+1 query path and make universal enforcement unnecessarily expensive. ## Solution Statement -Introduce a narrow Core-owned structured entrypoint contract, one closed state/access decision -matrix, a typed `ModuleStateGate` Effect service, and a `ModuleEntrypointGateway` that accepts -trusted tenant context plus lazy authorization/load/dispatch Effects. The gateway must evaluate -state before invoking downstream authorization or the lazy module implementation. Core system -capabilities use an explicit system-entrypoint classification and bypass tenant activation only; -they still pass through authentication, SpiceDB authorization, Policy, evidence, and other -applicable controls. Never infer the bypass from an arbitrary string prefix at the call site. +Introduce a narrow Core-owned structured entrypoint contract, one closed state/access decision matrix, a typed `ModuleStateGate` Effect service, and a `ModuleEntrypointGateway` that accepts trusted tenant context plus lazy authorization/load/dispatch Effects. The gateway must evaluate state before invoking downstream authorization or the lazy module implementation. Core system capabilities use an explicit system-entrypoint classification and bypass tenant activation only; they still pass through authentication, SpiceDB authorization, Policy, evidence, and other applicable controls. Never infer the bypass from an arbitrary string prefix at the call site. Use these access classes: @@ -74,24 +41,11 @@ Use these access classes: | `archived` | deny | allow | deny | deny | | missing row | deny | deny | deny | deny | -`historical_read` is an explicit entrypoint classification, never a fallback from a denied normal -read. It exists for permission-checked historical/audit/reporting paths and must not put inactive, -suspended, or archived modules into ordinary navigation. Quarantine denies every module-owned -entrypoint because its purpose includes defect, migration, and data-safety containment. - -Map entrypoint categories to access deliberately: Actions are `write`; Workers are `background`; -pages, public components, and search default to `read`; every API and report declares `read`, -`historical_read`, or `write` explicitly. An API write remains only a transport edge into an -Action—it does not gain an independent write handler. - -Separate state acquisition from state evaluation. At the start of one trusted Shell, SSR, route, -or BFF request, collect the distinct tenant-scoped module keys from the structured entrypoint set, -load them in one indexed batch query, decode them once, and build an immutable request-scoped -snapshot. Every gateway decision in that request is then a pure in-memory matrix evaluation. -Repeated checks for the same module do not query again. A tenant entrypoint absent from the -declared snapshot fails closed rather than issuing an implicit per-entrypoint query; the owning -composition boundary must declare the complete batch. Empty and system-only batches perform no -state query. +`historical_read` is an explicit entrypoint classification, never a fallback from a denied normal read. It exists for permission-checked historical/audit/reporting paths and must not put inactive, suspended, or archived modules into ordinary navigation. Quarantine denies every module-owned entrypoint because its purpose includes defect, migration, and data-safety containment. + +Map entrypoint categories to access deliberately: Actions are `write`; Workers are `background`; pages, public components, and search default to `read`; every API and report declares `read`, `historical_read`, or `write` explicitly. An API write remains only a transport edge into an Action—it does not gain an independent write handler. + +Separate state acquisition from state evaluation. At the start of one trusted Shell, SSR, route, or BFF request, collect the distinct tenant-scoped module keys from the structured entrypoint set, load them in one indexed batch query, decode them once, and build an immutable request-scoped snapshot. Every gateway decision in that request is then a pure in-memory matrix evaluation. Repeated checks for the same module do not query again. A tenant entrypoint absent from the declared snapshot fails closed rather than issuing an implicit per-entrypoint query; the owning composition boundary must declare the complete batch. Empty and system-only batches perform no state query. Use this database-query budget: @@ -104,33 +58,13 @@ Use this database-query budget: | One business Action attempt | One early indexed read plus one authoritative transactional recheck | | One Outbox Worker claim cycle | Zero additional queries beyond the existing transactional claim query/join | -Keep the snapshot request-scoped. Do not introduce a process-global, TTL, browser-authoritative, -or distributed cache in this increment: activation changes must affect the next independent -request without restart or invalidation coordination. A page-load decision never replaces the -independent BFF/Action check at the next trust boundary. Instrument gate acquisition/evaluation -with safe Effect telemetry for batch size, acquisition latency, snapshot reuse, scope/access, and -outcome, without arbitrary payloads or credentials. - -Integrate Actions after structural payload and trusted-context validation but before invocation -creation, permission, Policy, or handler resolution. A module-state denial creates no Action -Invocation Log because the request never enters the module Action lifecycle. Recheck a business -module's `write` access with the Core transaction immediately before handler execution so a state -transition between the early gate and dispatch cannot authorize a stale write. A failed locked -recheck rolls back the business attempt and follows the existing open-invocation retry semantics. -The `core.modules.change-tenant-module-state` system Action must remain usable for recovery even -when the target business module is not active. - -Refactor Outbox Worker eligibility to use the same `background` semantics while preserving its -transactional claim query, tenant isolation, leases, and no-attempt behavior for ineligible work. -The consuming module—not the producer—governs dispatch. Handler resolution remains after a -successful eligible claim. - -Add a dedicated repository boundary check to the root quality gate. It must validate generated -Action/page/Worker entrypoint metadata, approved gateway composition, lazy loads, owner/role/access -consistency, and forbidden direct private imports or raw `loadRemote(...)` calls. It must also -reserve fail-closed registration slots for API, public-component, search, and report entrypoints: -until a category has an approved generator and gateway adapter, introducing that category must -fail validation with an instruction to extend Codesmith and the gateway first. +Keep the snapshot request-scoped. Do not introduce a process-global, TTL, browser-authoritative, or distributed cache in this increment: activation changes must affect the next independent request without restart or invalidation coordination. A page-load decision never replaces the independent BFF/Action check at the next trust boundary. Instrument gate acquisition/evaluation with safe Effect telemetry for batch size, acquisition latency, snapshot reuse, scope/access, and outcome, without arbitrary payloads or credentials. + +Integrate Actions after structural payload and trusted-context validation but before invocation creation, permission, Policy, or handler resolution. A module-state denial creates no Action Invocation Log because the request never enters the module Action lifecycle. Recheck a business module's `write` access with the Core transaction immediately before handler execution so a state transition between the early gate and dispatch cannot authorize a stale write. A failed locked recheck rolls back the business attempt and follows the existing open-invocation retry semantics. The `core.modules.change-tenant-module-state` system Action must remain usable for recovery even when the target business module is not active. + +Refactor Outbox Worker eligibility to use the same `background` semantics while preserving its transactional claim query, tenant isolation, leases, and no-attempt behavior for ineligible work. The consuming module—not the producer—governs dispatch. Handler resolution remains after a successful eligible claim. + +Add a dedicated repository boundary check to the root quality gate. It must validate generated Action/page/Worker entrypoint metadata, approved gateway composition, lazy loads, owner/role/access consistency, and forbidden direct private imports or raw `loadRemote(...)` calls. It must also reserve fail-closed registration slots for API, public-component, search, and report entrypoints: until a category has an approved generator and gateway adapter, introducing that category must fail validation with an instruction to extend Codesmith and the gateway first. ## Relevant Files @@ -200,31 +134,15 @@ Use these files to implement the feature: ### Phase 1: Foundation -Update the existing Codesmith templates and fixture expectations first so no new generated Action, -page, or Worker can be produced with the old bypassable shape. Define the architecture document, -the closed entrypoint role/access vocabulary, the approved state matrix, explicit system scope, -typed failures, batched state acquisition, immutable request-scoped snapshots, and the Core -gate/gateway services. Add exhaustive unit, query-budget, and PostgreSQL integration tests beside -the foundation. +Update the existing Codesmith templates and fixture expectations first so no new generated Action, page, or Worker can be produced with the old bypassable shape. Define the architecture document, the closed entrypoint role/access vocabulary, the approved state matrix, explicit system scope, typed failures, batched state acquisition, immutable request-scoped snapshots, and the Core gate/gateway services. Add exhaustive unit, query-budget, and PostgreSQL integration tests beside the foundation. ### Phase 2: Core Implementation -Wire the gate into the existing Action and Outbox Worker runtimes. Preserve each specialized -lifecycle: Actions gate before invocation/authz/Policy/handler access and recheck under the -business transaction; Workers retain atomic claim eligibility and use the shared background -decision. Update descriptors, layers, public errors, runtime stages, test harnesses, and existing -integration fixtures without weakening idempotency, evidence, leases, or deployment seams. +Wire the gate into the existing Action and Outbox Worker runtimes. Preserve each specialized lifecycle: Actions gate before invocation/authz/Policy/handler access and recheck under the business transaction; Workers retain atomic claim eligibility and use the shared background decision. Update descriptors, layers, public errors, runtime stages, test harnesses, and existing integration fixtures without weakening idempotency, evidence, leases, or deployment seams. ### Phase 3: Integration -Add repository enforcement and future-category rails. Preserve structured page metadata through -route generation; require future vertical APIs to use the approved gateway adapter; require public -component, search, and report registrations to declare an entrypoint before they can be exported -or discovered; and reject raw remote/private implementation loading. Update `AGENTS.md` and every -affected architecture document so implementation agents are required to use the gateway and to -extend Codesmith before introducing a category the repository cannot yet scaffold safely. Make -batch acquisition and request-scoped snapshot reuse part of the same mandatory contract so future -composition code cannot replace security bypasses with N+1 state queries. +Add repository enforcement and future-category rails. Preserve structured page metadata through route generation; require future vertical APIs to use the approved gateway adapter; require public component, search, and report registrations to declare an entrypoint before they can be exported or discovered; and reject raw remote/private implementation loading. Update `AGENTS.md` and every affected architecture document so implementation agents are required to use the gateway and to extend Codesmith before introducing a category the repository cannot yet scaffold safely. Make batch acquisition and request-scoped snapshot reuse part of the same mandatory contract so future composition code cannot replace security bypasses with N+1 state queries. ## Step by Step Tasks @@ -306,26 +224,11 @@ IMPORTANT: Execute every step in order, top to bottom. ### Unit Tests -Exhaustively table-test the state/access matrix, including missing state, and test structured -descriptor construction, system classification, immutable values, typed safe failures, dependency -ordering, lazy authorization/load/handler behavior, Action stage order, and Worker descriptor/claim -behavior. Use counting fakes to prove batch-key deduplication, one state acquisition for many -descriptors, request-snapshot reuse, fail-closed undeclared keys, zero queries for system-only -compositions, one early business-Action read, and zero extra Worker queries. Test safe telemetry -attributes without asserting environment-specific timing values. Test Codesmith and the boundary -checker with disposable workspaces so future generated artifacts cannot omit or forge their -entrypoint metadata. +Exhaustively table-test the state/access matrix, including missing state, and test structured descriptor construction, system classification, immutable values, typed safe failures, dependency ordering, lazy authorization/load/handler behavior, Action stage order, and Worker descriptor/claim behavior. Use counting fakes to prove batch-key deduplication, one state acquisition for many descriptors, request-snapshot reuse, fail-closed undeclared keys, zero queries for system-only compositions, one early business-Action read, and zero extra Worker queries. Test safe telemetry attributes without asserting environment-specific timing values. Test Codesmith and the boundary checker with disposable workspaces so future generated artifacts cannot omit or forge their entrypoint metadata. ### Integration Tests -Use the existing PostgreSQL-backed Core fixtures to prove tenant-isolated gate reads, Action early -denial and locked recheck, concurrent state changes, recovery through the Core state Action, -Outbox Worker claim eligibility/no-attempt behavior, and reactivation. Add multi-key database -fixtures and repository instrumentation to prove the query budgets without relying on wall-clock -thresholds. No browser E2E test is required while `develop` contains no business MicroVertical -route or remote; fake lazy loaders and disposable generated vertical fixtures provide the current -load-order and batching proof. The first production page/public-component integration must add an -E2E direct-URL/remote-load denial test and verify its state-decision request remains batched. +Use the existing PostgreSQL-backed Core fixtures to prove tenant-isolated gate reads, Action early denial and locked recheck, concurrent state changes, recovery through the Core state Action, Outbox Worker claim eligibility/no-attempt behavior, and reactivation. Add multi-key database fixtures and repository instrumentation to prove the query budgets without relying on wall-clock thresholds. No browser E2E test is required while `develop` contains no business MicroVertical route or remote; fake lazy loaders and disposable generated vertical fixtures provide the current load-order and batching proof. The first production page/public-component integration must add an E2E direct-URL/remote-load denial test and verify its state-decision request remains batched. ### Edge Cases @@ -405,33 +308,19 @@ Execute every command to validate the feature with zero regressions. ### Summary -- Implemented the universal descriptor, closed state/access matrix, immutable request snapshot, - Core gate/gateway, Action early gate plus transactional recheck, Worker claim alignment, lazy - Shell adapter, Codesmith output, documentation, and repository bypass enforcement. -- Hardened the final design so only Core can mint snapshots, snapshots authorize exactly their - prepared descriptors, tenant/system ownership is enforced, trusted principal context is runtime - validated, all composed Shell descriptors are preflighted before loading, and typed loader and - persistence failures remain intact behind safe public errors. -- Added safe gate telemetry and comment-aware TypeScript boundary inspection without recording - tenant, module, principal, entrypoint, payload, credential, or raw persistence information. +- Implemented the universal descriptor, closed state/access matrix, immutable request snapshot, Core gate/gateway, Action early gate plus transactional recheck, Worker claim alignment, lazy Shell adapter, Codesmith output, documentation, and repository bypass enforcement. +- Hardened the final design so only Core can mint snapshots, snapshots authorize exactly their prepared descriptors, tenant/system ownership is enforced, trusted principal context is runtime validated, all composed Shell descriptors are preflighted before loading, and typed loader and persistence failures remain intact behind safe public errors. +- Added safe gate telemetry and comment-aware TypeScript boundary inspection without recording tenant, module, principal, entrypoint, payload, credential, or raw persistence information. ### Changed Files -- 56 intended paths under `app/` totaling 4,071 additions and 103 deletions: Core gate/runtime - code and tests, Shell gateway metadata and tests, Codesmith and boundary tooling, architecture - guidance, CI/package wiring, and this spec. +- 56 intended paths under `app/` totaling 4,071 additions and 103 deletions: Core gate/runtime code and tests, Shell gateway metadata and tests, Codesmith and boundary tooling, architecture guidance, CI/package wiring, and this spec. - No files under read-only `mvp/` or `mvp2/` were changed. ### Tests Written or Updated -- Added exhaustive unit/integration coverage for descriptor construction, state/access decisions, - immutable snapshots, batching/reuse, unavailable state, Action ordering and transactional - rechecks, system recovery, Worker claim eligibility, Shell lazy composition, telemetry safety, - Codesmith output, and every enforced bypass category. -- Added PostgreSQL coverage for all states, missing and foreign-tenant rows, concurrent state - transition, retry after reactivation, rollback/evidence behavior, and consumer-owned Worker - state. Database-enabled review also corrected stable fixture keys, tenant-state-change cleanup, - and a faithful unavailable-query fake. +- Added exhaustive unit/integration coverage for descriptor construction, state/access decisions, immutable snapshots, batching/reuse, unavailable state, Action ordering and transactional rechecks, system recovery, Worker claim eligibility, Shell lazy composition, telemetry safety, Codesmith output, and every enforced bypass category. +- Added PostgreSQL coverage for all states, missing and foreign-tenant rows, concurrent state transition, retry after reactivation, rollback/evidence behavior, and consumer-owned Worker state. Database-enabled review also corrected stable fixture keys, tenant-state-change cleanup, and a faithful unavailable-query fake. ### Validation @@ -445,31 +334,21 @@ Execute every command to validate the feature with zero regressions. - `mise exec -- pnpm --filter @app/core-runtime action:test:unit` — passed, 51 tests. - `mise exec -- pnpm --filter @app/core-runtime outbox:test:unit` — passed, 17 tests. - `mise exec -- pnpm --filter @app/core-runtime typecheck` — passed. -- `mise exec -- pnpm --filter @app/core-runtime db:test` — passed, 133 tests, using isolated - process-scoped PostgreSQL and SpiceDB test services after applying the repository migrations. +- `mise exec -- pnpm --filter @app/core-runtime db:test` — passed, 133 tests, using isolated process-scoped PostgreSQL and SpiceDB test services after applying the repository migrations. - `mise exec -- pnpm api:check` — passed. - `mise exec -- pnpm contract:check` — passed. - `mise exec -- pnpm build` — passed. - `mise exec -- pnpm check` — passed. -- Additional verification: Core migrations and exact 18-table schema verification passed against - the isolated PostgreSQL database; all 66 Shell unit tests and 9 focused gate tests passed. +- Additional verification: Core migrations and exact 18-table schema verification passed against the isolated PostgreSQL database; all 66 Shell unit tests and 9 focused gate tests passed. ### Review -- Re-read `../AGENTS.md`, `AGENTS.md`, and every architecture, frontend, product-context, - manifest, validation-report, and ADR reference named by this spec. The final implementation - preserves the documented Core/MicroVertical ownership, Action evidence, Worker claim, - generated BFF, trusted-context, typed-error, and historical-read boundaries. -- Final review found and corrected generator stale-entrypoint validation, reference-topology - wiring, integration hook order, public snapshot forgery, ownership/context bypasses, partial - Shell loading, erased loader errors, formatting-sensitive boundary checks, API discovery, - telemetry coverage, and the three database-fixture defects above. No findings remain. +- Re-read `../AGENTS.md`, `AGENTS.md`, and every architecture, frontend, product-context, manifest, validation-report, and ADR reference named by this spec. The final implementation preserves the documented Core/MicroVertical ownership, Action evidence, Worker claim, generated BFF, trusted-context, typed-error, and historical-read boundaries. +- Final review found and corrected generator stale-entrypoint validation, reference-topology wiring, integration hook order, public snapshot forgery, ownership/context bypasses, partial Shell loading, erased loader errors, formatting-sensitive boundary checks, API discovery, telemetry coverage, and the three database-fixture defects above. No findings remain. ### Deviations and Follow-ups -- None required for this feature. No browser E2E was added because there is still no production - business MicroVertical route or remote; the spec explicitly uses fake lazy loaders and - disposable generated fixtures until the first production integration exists. +- None required for this feature. No browser E2E was added because there is still no production business MicroVertical route or remote; the spec explicitly uses fake lazy loaders and disposable generated fixtures until the first production integration exists. ## Notes diff --git a/app/tools/oxlint/effect-native/README.md b/app/tools/oxlint/effect-native/README.md index 712334632..fffc838fd 100644 --- a/app/tools/oxlint/effect-native/README.md +++ b/app/tools/oxlint/effect-native/README.md @@ -1,12 +1,8 @@ # Effect-native Oxlint rules -Custom diagnostic rules derived from -[`EFFECT_V4_ANTIPATTERN_AUDIT.md`](../../../docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md). -All are explicitly registered and configured at **error** severity. There are **no autofixers or -suggestions**. This change introduces enforcement, not an application migration. +Custom diagnostic rules derived from [`EFFECT_V4_ANTIPATTERN_AUDIT.md`](../../../docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md). All are explicitly registered and configured at **error** severity. There are **no autofixers or suggestions**. This change introduces enforcement, not an application migration. -See the [audit-to-rule catalog and diagnostic snapshot](../../../docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md) -for the original rule counts, primary audit mappings, and intentionally non-static guarantees. +See the [audit-to-rule catalog and diagnostic snapshot](../../../docs/architecture/EFFECT_V4_LINT_ENFORCEMENT.md) for the original rule counts, primary audit mappings, and intentionally non-static guarantees. ## Run from the app workspace @@ -29,46 +25,19 @@ RULE=no-nested-effect-run pnpm exec rstest --project lint-rules tools/oxlint/eff node tools/oxlint/effect-native/tests/run-on-repo.mts no-nested-effect-run ``` -`pnpm check` runs the dedicated rule typecheck and tests before linting the application. The CI -matrix also runs them independently in **Effect Rule Implementation**, so expected application -lint failures do not prevent verification of the detectors. Existing application violations intentionally make `pnpm lint`, `pnpm lint:effect`, and therefore `pnpm check` -fail. Do not confuse those diagnostics with a failing rule test or a plugin crash. No new dependency -or Effect test harness is installed by this change. +`pnpm check` runs the dedicated rule typecheck and tests before linting the application. The CI matrix also runs them independently in **Effect Rule Implementation**, so expected application lint failures do not prevent verification of the detectors. Existing application violations intentionally make `pnpm lint`, `pnpm lint:effect`, and therefore `pnpm check` fail. Do not confuse those diagnostics with a failing rule test or a plugin crash. No new dependency or Effect test harness is installed by this change. -Both reporting commands scan `apps verticals packages scripts`. In report totals, test filenames -and test directories take precedence over `scripts/`; nested workspace scripts count as scripts. -The categories are disjoint. Counts are **diagnostics, not unique audit findings**: multiple rules -can identify different concerns at one location. `--json` includes every diagnostic and file count; -the text view explicitly limits the top-file list to 20. +Both reporting commands scan `apps verticals packages scripts`. In report totals, test filenames and test directories take precedence over `scripts/`; nested workspace scripts count as scripts. The categories are disjoint. Counts are **diagnostics, not unique audit findings**: multiple rules can identify different concerns at one location. `--json` includes every diagnostic and file count; the text view explicitly limits the top-file list to 20. ## Policy boundaries -Rules use AST, import identity, lexical scopes, and explicit path/option policies. They do not have a -TypeScript checker, even though the workspace retains `typeAware`, `typeCheck`, and `denyWarnings` -for its other lint rules. Each rule's source documents its audit mapping, default scope, options, -exceptions, and limitations. Configured re-export barrels are explicit trust assumptions, not -cross-file resolution. - -The audit records the original findings; focused architecture documents own current behavior. -Preserve forced outer process/framework Promise adapters, correct -Drizzle JSONB and HttpApi encoding, external test-body JSON serialization, deliberate malformed -rejection fixtures, legitimate `as const`/`satisfies`, line-preserving `.env` editing, native collection -operations, and correctly scoped fibers. Startup `Layer.orDie` requires the deliberate outer seam -and typed-cause logging; it is not a blanket library escape hatch. Operational success output is not -an observability failure. - -Some checks are **review heuristics**, not semantic proofs. In particular, adjacent yields without -lexical data dependencies do not prove safe concurrency, local timeout proximity does not prove a -whole-program deadline, and identifiers/schema names cannot establish complete business semantics. -Never mechanically parallelize effects or change wire formats just to silence a diagnostic. Prefer -an audit-grounded detector correction for false positives; use a narrow, justified rule option for a -real architectural exception. Do not hide existing debt with blanket disables or zero-count -"invalid" fixtures. - -These rules cannot prove runtime context propagation, tracer/exporter connectivity, redaction of -all secrets, business ordering, complete schema equivalence, cross-process ownership, or exhaustive -cross-file vocabulary reuse. Runtime/integration/property tests and architecture review still own -those guarantees. The audit's 145 clusters are not 145 syntactically decidable checks. +Rules use AST, import identity, lexical scopes, and explicit path/option policies. They do not have a TypeScript checker, even though the workspace retains `typeAware`, `typeCheck`, and `denyWarnings` for its other lint rules. Each rule's source documents its audit mapping, default scope, options, exceptions, and limitations. Configured re-export barrels are explicit trust assumptions, not cross-file resolution. + +The audit records the original findings; focused architecture documents own current behavior. Preserve forced outer process/framework Promise adapters, correct Drizzle JSONB and HttpApi encoding, external test-body JSON serialization, deliberate malformed rejection fixtures, legitimate `as const`/`satisfies`, line-preserving `.env` editing, native collection operations, and correctly scoped fibers. Startup `Layer.orDie` requires the deliberate outer seam and typed-cause logging; it is not a blanket library escape hatch. Operational success output is not an observability failure. + +Some checks are **review heuristics**, not semantic proofs. In particular, adjacent yields without lexical data dependencies do not prove safe concurrency, local timeout proximity does not prove a whole-program deadline, and identifiers/schema names cannot establish complete business semantics. Never mechanically parallelize effects or change wire formats just to silence a diagnostic. Prefer an audit-grounded detector correction for false positives; use a narrow, justified rule option for a real architectural exception. Do not hide existing debt with blanket disables or zero-count "invalid" fixtures. + +These rules cannot prove runtime context propagation, tracer/exporter connectivity, redaction of all secrets, business ordering, complete schema equivalence, cross-process ownership, or exhaustive cross-file vocabulary reuse. Runtime/integration/property tests and architecture review still own those guarantees. The audit's 145 clusters are not 145 syntactically decidable checks. ## Implementation layout @@ -79,38 +48,18 @@ those guarantees. The audit's 145 clusters are not 145 syntactically decidable c - `report.mts`: combined production-policy diagnostic report; never runs `--fix`. - `tsconfig.json`: strict, no-emit tooling typecheck; intentionally invalid fixtures are excluded. - `tests/fixtures//`: real Oxlint inputs, positive and negative cases, and one rule config. -- `tests/fixtures.test.mts`: actual Oxlint execution, exact positive counts where specified, - zero negative counts, linted-file coverage, and all failures reported together. -- `tests/production-options.test.mts`: stages inputs in owned temporary workspaces outside the - tooling/test ancestry and requires positive evidence for every rule using production settings. - Default-config negatives are checked too; explicitly overridden option fixtures stay in their - own suite. Set `EFFECT_NATIVE_TEST_TMPDIR` to choose a temporary root. -- `tests/paths.test.mts` and `tests/script-scope.test.mts`: verify nested script classification for - relative, POSIX, Windows drive-letter and UNC paths, plus real Oxlint default/opt-in behavior for - timer, dependency-parameter and factory-signature rules in app, vertical and package scripts. -- `tests/discover-rules.test.mts`: verifies isolated discovery and file-URL imports, including a - real ESM load from a path containing spaces, URL delimiters and Unicode. Path-string coverage - does not substitute for a native Windows run of the full harness. -- `tests/temporary-workspace.test.mts`: verifies cleanup after success and early/partial failures, - preserving the original error and caller-owned files. -- `tests/registration.test.mts`: imports the actual plugin/config and checks exports, error severity, - fixture coverage, preserved typed lint options, and absence of fixer/suggestion metadata. -- `tests/oxlint.test.mts`: regression coverage for loader failures, malformed JSON/diagnostics, - inconsistent exits, empty-file runs, and stderr failures. -- `tests/launcher.test.mts`: observes a real lint process to require Node plus Oxlint's JavaScript - entry point rather than platform-specific package-manager shims, and checks that the explicitly - opt-in `lint:fix` command covers the same directories as reporting-only `lint`. - -Each fixture configuration enables only its owned rule. The child process selects that one module -so an unfinished sibling cannot conceal its test results; the separate production registration gate -still imports **all** modules. A missing module, failed worker, or stale earlier pass is never a -successful verification. Formatting/linting intentionally exclude fixture source so adversarial -syntax and positions remain stable. - -Use Node 26's direct TypeScript execution: sibling imports include `.ts`/`.mts`, and runtime enums, -parameter properties, or other transform-required syntax are not supported. Fixture paths should -mirror production ownership paths rather than enabling an entire repository through test-only -options. Additional option tests must be identified as such. +- `tests/fixtures.test.mts`: actual Oxlint execution, exact positive counts where specified, zero negative counts, linted-file coverage, and all failures reported together. +- `tests/production-options.test.mts`: stages inputs in owned temporary workspaces outside the tooling/test ancestry and requires positive evidence for every rule using production settings. Default-config negatives are checked too; explicitly overridden option fixtures stay in their own suite. Set `EFFECT_NATIVE_TEST_TMPDIR` to choose a temporary root. +- `tests/paths.test.mts` and `tests/script-scope.test.mts`: verify nested script classification for relative, POSIX, Windows drive-letter and UNC paths, plus real Oxlint default/opt-in behavior for timer, dependency-parameter and factory-signature rules in app, vertical and package scripts. +- `tests/discover-rules.test.mts`: verifies isolated discovery and file-URL imports, including a real ESM load from a path containing spaces, URL delimiters and Unicode. Path-string coverage does not substitute for a native Windows run of the full harness. +- `tests/temporary-workspace.test.mts`: verifies cleanup after success and early/partial failures, preserving the original error and caller-owned files. +- `tests/registration.test.mts`: imports the actual plugin/config and checks exports, error severity, fixture coverage, preserved typed lint options, and absence of fixer/suggestion metadata. +- `tests/oxlint.test.mts`: regression coverage for loader failures, malformed JSON/diagnostics, inconsistent exits, empty-file runs, and stderr failures. +- `tests/launcher.test.mts`: observes a real lint process to require Node plus Oxlint's JavaScript entry point rather than platform-specific package-manager shims, and checks that the explicitly opt-in `lint:fix` command covers the same directories as reporting-only `lint`. + +Each fixture configuration enables only its owned rule. The child process selects that one module so an unfinished sibling cannot conceal its test results; the separate production registration gate still imports **all** modules. A missing module, failed worker, or stale earlier pass is never a successful verification. Formatting/linting intentionally exclude fixture source so adversarial syntax and positions remain stable. + +Use Node 26's direct TypeScript execution: sibling imports include `.ts`/`.mts`, and runtime enums, parameter properties, or other transform-required syntax are not supported. Fixture paths should mirror production ownership paths rather than enabling an entire repository through test-only options. Additional option tests must be identified as such. Fixture config template: @@ -122,40 +71,18 @@ Fixture config template: } ``` -Place examples under `invalid/` and `valid/`; `// expect-count: N` at the start of a positive fixture -pins its positive diagnostic count. False-positive repairs need negative regressions. Preserve -existing test evidence unless its expectation conflicts with the audit, and explain such corrections. +Place examples under `invalid/` and `valid/`; `// expect-count: N` at the start of a positive fixture pins its positive diagnostic count. False-positive repairs need negative regressions. Preserve existing test evidence unless its expectation conflicts with the audit, and explain such corrections. ## Native interface and operator policy -`no-promise-shaped-port` covers application packages, scripts, tests, and TSX. Owned interfaces -return Effect, including generic aliases, overloads, and callback parameters. Real SDK and test -runner callbacks keep their required Promise boundary. Private helpers qualify only when lexical -references establish that boundary; an exported Promise helper remains an owned interface. +`no-promise-shaped-port` covers application packages, scripts, tests, and TSX. Owned interfaces return Effect, including generic aliases, overloads, and callback parameters. Real SDK and test runner callbacks keep their required Promise boundary. Private helpers qualify only when lexical references establish that boundary; an exported Promise helper remains an owned interface. -`repository-policy.config.ts` checks all repository source for `instanceof` and manual `_tag` -comparisons, switches, and assertions. Negative lint fixtures are excluded because they deliberately -contain forbidden syntax. Use Schema, native predicates, and Effect failure combinators to inspect -values; full serialized-object assertions and diagnostic tag output remain valid. +`repository-policy.config.ts` checks all repository source for `instanceof` and manual `_tag` comparisons, switches, and assertions. Negative lint fixtures are excluded because they deliberately contain forbidden syntax. Use Schema, native predicates, and Effect failure combinators to inspect values; full serialized-object assertions and diagnostic tag output remain valid. ## Test runtime policy -`no-effect-run-in-tests` rejects references, calls, imports, re-exports, and dynamic imports of -`Effect.run*` inside tests, including test support and harness directories. Use `it.effect`, -`it.live`, and `it.layer` from `effect-rstest` so the runner owns services, scopes, -test time, and configuration. The external package owns the runner boundary; there is no -repository-owned implementation or harness-path allowlist. The immutable upstream canary currently -uses a temporary pnpm patch for [effect-rstest PR #4](https://github.com/ScriptedAlchemy/effect-rstest/pull/4). -[OntOS #507](https://github.com/TechsioCZ/ontos/issues/507) tracks replacing it with a published -upstream package and deleting the patch; do not add a local runner alias or wrapper. - -Playwright/e2e adapters remain exempt through `ignorePaths`. Type-only imports, non-Effect -bindings, and ManagedRuntime instance methods are not Effect root-function violations. Nested -Effect re-entry is diagnosed by `no-nested-effect-run`. Additional rule options are `testPaths`, -`effectModules`, and `effectModuleSources`; there is no fixer or suggestion. - -The workspace import policy rejects `node:test`, `node:assert`, `node:assert/strict`, -and `@rstest/core` in application, -package, vertical, script, and tooling tests, with `tests/e2e/**` exempt for Playwright. -Test files disable Sonar's hard-coded runner detector and the async-Promise-function rule because -Effect-native test APIs and `Effect.promise`/`Effect.tryPromise` thunks are intentional. +`no-effect-run-in-tests` rejects references, calls, imports, re-exports, and dynamic imports of `Effect.run*` inside tests, including test support and harness directories. Use `it.effect`, `it.live`, and `it.layer` from `effect-rstest` so the runner owns services, scopes, test time, and configuration. The external package owns the runner boundary; there is no repository-owned implementation or harness-path allowlist. The immutable upstream canary currently uses a temporary pnpm patch for [effect-rstest PR #4](https://github.com/ScriptedAlchemy/effect-rstest/pull/4). [OntOS #507](https://github.com/TechsioCZ/ontos/issues/507) tracks replacing it with a published upstream package and deleting the patch; do not add a local runner alias or wrapper. + +Playwright/e2e adapters remain exempt through `ignorePaths`. Type-only imports, non-Effect bindings, and ManagedRuntime instance methods are not Effect root-function violations. Nested Effect re-entry is diagnosed by `no-nested-effect-run`. Additional rule options are `testPaths`, `effectModules`, and `effectModuleSources`; there is no fixer or suggestion. + +The workspace import policy rejects `node:test`, `node:assert`, `node:assert/strict`, and `@rstest/core` in application, package, vertical, script, and tooling tests, with `tests/e2e/**` exempt for Playwright. Test files disable Sonar's hard-coded runner detector and the async-Promise-function rule because Effect-native test APIs and `Effect.promise`/`Effect.tryPromise` thunks are intentional. diff --git a/app/tools/oxlint/effect-native/index.ts b/app/tools/oxlint/effect-native/index.ts index 24d78d518..408aa2b10 100644 --- a/app/tools/oxlint/effect-native/index.ts +++ b/app/tools/oxlint/effect-native/index.ts @@ -1,4 +1,3 @@ -import { rule as noInstanceof } from './rules/no-instanceof.ts'; import { eslintCompatPlugin } from '@oxlint/plugins'; import { rule as noAdHocArgvInScripts } from './rules/no-ad-hoc-argv-in-scripts.ts'; @@ -22,6 +21,7 @@ import { rule as noHandBuiltProblemDetails } from './rules/no-hand-built-problem import { rule as noHandParsedEnvironmentValue } from './rules/no-hand-parsed-environment-value.ts'; import { rule as noHandRolledTaggedUnion } from './rules/no-hand-rolled-tagged-union.ts'; import { rule as noImperativeLoopInEffectGen } from './rules/no-imperative-loop-in-effect-gen.ts'; +import { rule as noInstanceof } from './rules/no-instanceof.ts'; import { rule as noInterfaceFirstCodec } from './rules/no-interface-first-codec.ts'; import { rule as noJsonSchemaAsDocumentContract } from './rules/no-json-schema-as-document-contract.ts'; import { rule as noLayerFresh } from './rules/no-layer-fresh.ts'; diff --git a/app/tools/oxlint/effect-native/report.config.ts b/app/tools/oxlint/effect-native/report.config.ts index 9efcab43b..32c68f5fe 100644 --- a/app/tools/oxlint/effect-native/report.config.ts +++ b/app/tools/oxlint/effect-native/report.config.ts @@ -1,13 +1,9 @@ -const { default: rootConfig } = await import( - new URL('../../../oxlint.config.ts', import.meta.url).href -); +const { default: rootConfig } = await import(new URL('../../../oxlint.config.ts', import.meta.url).href); // Diagnostic-only view of exactly the production Effect rule settings, without unrelated rules. export default { categories: { correctness: 'off' }, ignorePatterns: rootConfig.ignorePatterns, jsPlugins: [{ name: 'effect-native', specifier: './index.ts' }], - rules: Object.fromEntries( - Object.entries(rootConfig.rules).filter(([name]) => name.startsWith('effect-native/')), - ), + rules: Object.fromEntries(Object.entries(rootConfig.rules).filter(([name]) => name.startsWith('effect-native/'))), }; diff --git a/app/tools/oxlint/effect-native/report.mts b/app/tools/oxlint/effect-native/report.mts index 95e543bca..09808d627 100644 --- a/app/tools/oxlint/effect-native/report.mts +++ b/app/tools/oxlint/effect-native/report.mts @@ -4,10 +4,8 @@ import plugin from './index.ts'; import { appRoot, pluginDirectory, runOxlint } from './tests/oxlint.mts'; const args = process.argv.slice(2); -if (args.some((arg) => arg !== '--json')) - throw new Error('Usage: node tools/oxlint/effect-native/report.mts [--json]'); -if (Object.keys(plugin.rules).length === 0) - throw new Error('No Effect-native rules are registered.'); +if (args.some((arg) => arg !== '--json')) throw new Error('Usage: node tools/oxlint/effect-native/report.mts [--json]'); +if (Object.keys(plugin.rules).length === 0) throw new Error('No Effect-native rules are registered.'); const scope = ['apps', 'verticals', 'packages', 'scripts']; const run = runOxlint(join(pluginDirectory, 'report.config.ts'), scope, appRoot); @@ -25,8 +23,7 @@ for (const diagnostic of run.diagnostics) { const filename = diagnostic.filename.replaceAll('\\', '/'); row.total++; // Test files are tests even when they live under scripts/; categories never overlap. - if (/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(filename)) - row.tests++; + if (/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(filename)) row.tests++; else if (/(?:^|\/)scripts\//u.test(filename)) row.scripts++; else row.source++; byFile.set(filename, (byFile.get(filename) ?? 0) + 1); @@ -39,9 +36,7 @@ const report = { totalDiagnostics: run.diagnostics.length, filesWithDiagnostics: byFile.size, rules: [...byRule].map(([rule, counts]) => ({ rule, ...counts })), - files: [...byFile] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .map(([file, count]) => ({ file, count })), + files: [...byFile].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([file, count]) => ({ file, count })), diagnostics: run.diagnostics, }; diff --git a/app/tools/oxlint/effect-native/repository-policy.config.ts b/app/tools/oxlint/effect-native/repository-policy.config.ts index 7809ede16..dadd21cac 100644 --- a/app/tools/oxlint/effect-native/repository-policy.config.ts +++ b/app/tools/oxlint/effect-native/repository-policy.config.ts @@ -1,6 +1,7 @@ -import { testRestrictedImports } from './shared/test-restricted-imports.ts'; import { defineConfig } from 'oxlint'; +import { testRestrictedImports } from './shared/test-restricted-imports.ts'; + /** Repository policies also cover tooling tests and root configuration files. */ export default defineConfig({ categories: { correctness: 'off' }, diff --git a/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts index 8cdaa6047..26274bbd3 100644 --- a/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts @@ -11,7 +11,6 @@ * Report-only: no fixers or suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; import { literalText, propertyText, staticString, unwrap } from '../shared/ast.ts'; @@ -71,16 +70,10 @@ function numberList(value: unknown, fallback: readonly number[]): readonly numbe } function readOptions(raw: unknown): RuleOptions { - const given = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const given = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - allowEntryGuardIndices: numberList( - given.allowEntryGuardIndices, - DEFAULTS.allowEntryGuardIndices, - ), + allowEntryGuardIndices: numberList(given.allowEntryGuardIndices, DEFAULTS.allowEntryGuardIndices), forbiddenCliModules: stringList(given.forbiddenCliModules, DEFAULTS.forbiddenCliModules), }; } @@ -90,8 +83,7 @@ function staticIndex(node: ESTree.MemberExpression): number | null { if (!node.computed) return null; const property = unwrap(node.property, { maxDepth: 8 }); if (property === null) return null; - const value = - property.type === 'Literal' ? (property as { value?: unknown }).value : literalText(property); + const value = property.type === 'Literal' ? (property as { value?: unknown }).value : literalText(property); if (typeof value === 'number') return Number.isInteger(value) ? value : null; if (typeof value !== 'string') return null; const parsed = Number(value); @@ -315,11 +307,7 @@ export const rule = defineRule({ AssignmentPattern(node) { const pattern = node as ESTree.AssignmentPattern; - reportPattern( - pattern.left as AnyNode, - pattern.right as AnyNode, - pattern as unknown as AnyNode, - ); + reportPattern(pattern.left as AnyNode, pattern.right as AnyNode, pattern as unknown as AnyNode); }, AssignmentExpression(node) { @@ -327,22 +315,18 @@ export const rule = defineRule({ if (assignment.operator !== '=') return; const target = assignment.left as AnyNode; // Only destructuring targets: `foo.bar = process.argv` merely forwards the array on. - if ( - target.type !== 'Identifier' && - target.type !== 'ArrayPattern' && - target.type !== 'ObjectPattern' - ) - return; + if (target.type !== 'Identifier' && target.type !== 'ArrayPattern' && target.type !== 'ObjectPattern') return; reportPattern(target, assignment.right as AnyNode, assignment as unknown as AnyNode); }, SpreadElement(node) { - if (isArgvSource(node.argument)) - report(node, 'argvDestructuring', { expression: printed(node) }); + if (isArgvSource(node.argument)) report(node, 'argvDestructuring', { expression: printed(node) }); }, ForOfStatement(node) { if (isArgvSource(node.right)) - report(node.right, 'argvMemberAccess', { expression: printed(node.right) }); + report(node.right, 'argvMemberAccess', { + expression: printed(node.right), + }); }, NewExpression(node) { if ( @@ -371,8 +355,7 @@ export const rule = defineRule({ report(node, 'argvMemberAccess', { expression: printed(node) }); if (identity !== 'require') return; const module = staticStringValue(node.arguments[0]); - if (module !== null && forbiddenModules.has(packageName(module))) - report(node, 'cliPackageImport', { module }); + if (module !== null && forbiddenModules.has(packageName(module))) report(node, 'cliPackageImport', { module }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-ambient-date.ts b/app/tools/oxlint/effect-native/rules/no-ambient-date.ts index 89d3fb632..020b71cda 100644 --- a/app/tools/oxlint/effect-native/rules/no-ambient-date.ts +++ b/app/tools/oxlint/effect-native/rules/no-ambient-date.ts @@ -77,20 +77,13 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - parentOf, - skipWrappers, - unwrapNode, - memberName, - keyName as staticKeyName, -} from '../shared/ast.ts'; +import { parentOf, skipWrappers, unwrapNode, memberName, keyName as staticKeyName } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; -import { stringList } from '../shared/options.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; +import { stringList } from '../shared/options.ts'; import { isScriptFile, isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; @@ -169,20 +162,11 @@ const DEFAULT_BROWSER_EVALUATED_METHODS: readonly string[] = [ const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const TEST_MODES = new Set(['clock-only', 'all', 'off']); -const FUNCTION_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionExpression', - 'FunctionDeclaration', -]); +const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration']); interface RuleOptions { readonly testMode: string; @@ -214,41 +198,34 @@ function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); return { - testMode: - typeof given.testMode === 'string' && TEST_MODES.has(given.testMode) - ? given.testMode - : DEFAULTS.testMode, + testMode: typeof given.testMode === 'string' && TEST_MODES.has(given.testMode) ? given.testMode : DEFAULTS.testMode, dateMethods: stringList(given.dateMethods, DEFAULTS.dateMethods), allowDurationArithmetic: typeof given.allowDurationArithmetic === 'boolean' ? given.allowDurationArithmetic : DEFAULTS.allowDurationArithmetic, ignore: stringList(given.ignore, DEFAULTS.ignore), - ignoreScripts: - typeof given.ignoreScripts === 'boolean' ? given.ignoreScripts : DEFAULTS.ignoreScripts, + ignoreScripts: typeof given.ignoreScripts === 'boolean' ? given.ignoreScripts : DEFAULTS.ignoreScripts, includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, testPaths: stringList(given.testPaths, DEFAULTS.testPaths), productionPaths: stringList(given.productionPaths, DEFAULTS.productionPaths), ignoreReceivers: stringList(given.ignoreReceivers, DEFAULTS.ignoreReceivers), - browserEvaluatedMethods: stringList( - given.browserEvaluatedMethods, - DEFAULTS.browserEvaluatedMethods, - ), + browserEvaluatedMethods: stringList(given.browserEvaluatedMethods, DEFAULTS.browserEvaluatedMethods), }; } function unwrap(node: AnyNode, depth: number): AnyNode { - return unwrapNode(node, { wrappers: TRANSPARENT_PARENTS, maxDepth: Math.max(0, 9 - depth) }); + return unwrapNode(node, { + wrappers: TRANSPARENT_PARENTS, + maxDepth: Math.max(0, 9 - depth), + }); } function staticPropertyName(node: ESTree.MemberExpression): string | null { return memberName(node, { templates: true, singleQuasi: true }); } -function aliasInitializer( - context: Context, - node: Extract, -): AnyNode | null { +function aliasInitializer(context: Context, node: Extract): AnyNode | null { const variable = resolveVariable(context, node.name, node); if (variable?.defs.length !== 1) return null; if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; @@ -277,8 +254,7 @@ function identifierGlobalName( if (variable === null || variable.defs.length === 0) return name; const definition = variable.defs.length === 1 ? variable.defs[0] : undefined; if (definition?.type !== 'Variable') return null; - if (definition.node.type !== 'VariableDeclarator' || definition.node.id.type !== 'Identifier') - return null; + if (definition.node.type !== 'VariableDeclarator' || definition.node.id.type !== 'Identifier') return null; const init = aliasInitializer(context, node as ESTree.IdentifierReference); if (init === null) return null; return resolveGlobalName(context, init, depth + 1); @@ -302,18 +278,10 @@ function resolveGlobalName(context: Context, raw: AnyNode, depth = 0): string | /** The call this expression is the callee of, or the expression itself when used point-free. */ function callSiteOf(node: AnyNode): AnyNode { const { node: reference, parent } = skipWrappers(node); - if ( - parent !== null && - parent.type === 'CallExpression' && - (parent as ESTree.CallExpression).callee === reference - ) { + if (parent !== null && parent.type === 'CallExpression' && (parent as ESTree.CallExpression).callee === reference) { return parent; } - if ( - parent !== null && - parent.type === 'NewExpression' && - (parent as ESTree.NewExpression).callee === reference - ) { + if (parent !== null && parent.type === 'NewExpression' && (parent as ESTree.NewExpression).callee === reference) { return parent; } return reference; @@ -360,11 +328,7 @@ function durationName(name: string | null): string | null { return name !== null && DURATION_NAME.test(name) ? name : null; } -const DURATION_WRAPPERS = new Set([ - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSNonNullExpression', -]); +const DURATION_WRAPPERS = new Set(['ParenthesizedExpression', 'TSAsExpression', 'TSNonNullExpression']); /** Duration-suffixed identifier / property name appearing as a factor of the chain. */ function operandDurationName(node: AnyNode, depth: number): string | null { @@ -385,8 +349,7 @@ function operandDurationName(node: AnyNode, depth: number): string | null { const binary = node as ESTree.BinaryExpression; if (binary.operator !== '*' && binary.operator !== '/') return null; return ( - operandDurationName(binary.left as AnyNode, depth + 1) ?? - operandDurationName(binary.right as AnyNode, depth + 1) + operandDurationName(binary.left as AnyNode, depth + 1) ?? operandDurationName(binary.right as AnyNode, depth + 1) ); } @@ -404,10 +367,7 @@ function isEffectCallArgument(node: AnyNode, bindings: EffectBindings): boolean if (callee === current) return false; if (callee.type !== 'MemberExpression') return false; const object = (callee as ESTree.MemberExpression).object as AnyNode; - return ( - object.type === 'Identifier' && - bindings.namespaces.has((object as ESTree.IdentifierReference).name) - ); + return object.type === 'Identifier' && bindings.namespaces.has((object as ESTree.IdentifierReference).name); } current = parent; } @@ -434,19 +394,15 @@ function identifierName(node: AnyNode | null | undefined): string | null { } function assignmentName(node: ESTree.AssignmentExpression): string | null { - return node.left.type === 'MemberExpression' - ? staticPropertyName(node.left) - : identifierName(node.left); + return node.left.type === 'MemberExpression' ? staticPropertyName(node.left) : identifierName(node.left); } const OWNER_NAMES: ReadonlyMap string | null> = new Map([ ['VariableDeclarator', (node) => identifierName((node as ESTree.VariableDeclarator).id)], - ...['Property', 'PropertyDefinition', 'MethodDefinition'].map( - (kind): [string, (node: AnyNode) => string | null] => [ - kind, - (node) => keyName((node as { key: AnyNode }).key), - ], - ), + ...['Property', 'PropertyDefinition', 'MethodDefinition'].map((kind): [string, (node: AnyNode) => string | null] => [ + kind, + (node) => keyName((node as { key: AnyNode }).key), + ]), ['AssignmentExpression', (node) => assignmentName(node as ESTree.AssignmentExpression)], ['AssignmentPattern', (node) => identifierName((node as ESTree.AssignmentPattern).left)], ]); @@ -474,8 +430,7 @@ function fileIsTest(filename: string, options: RuleOptions): boolean { function browserFunction(node: AnyNode, methods: ReadonlySet): boolean { if (!FUNCTION_TYPES.has(node.type)) return false; const parent = parentOf(node); - if (parent?.type !== 'CallExpression' || !(parent.arguments as readonly AnyNode[]).includes(node)) - return false; + if (parent?.type !== 'CallExpression' || !(parent.arguments as readonly AnyNode[]).includes(node)) return false; const callee = unwrap(parent.callee, 0); if (callee.type !== 'MemberExpression') return false; const name = staticPropertyName(callee); @@ -496,23 +451,14 @@ function clockSite(node: ESTree.MemberExpression, global: string): AnyNode { return staticPropertyName(parent) === 'bigint' ? parent : node; } -function ignoredReceiver( - node: AnyNode, - ignored: ReadonlySet, - bindings: EffectBindings, -): boolean { +function ignoredReceiver(node: AnyNode, ignored: ReadonlySet, bindings: EffectBindings): boolean { if (node.type === 'ThisExpression') return ignored.has('this'); if (node.type === 'Super') return ignored.has('super'); - return ( - node.type === 'Identifier' && (ignored.has(node.name) || bindings.namespaces.has(node.name)) - ); + return node.type === 'Identifier' && (ignored.has(node.name) || bindings.namespaces.has(node.name)); } function durationIsNamed(node: ESTree.BinaryExpression): boolean { - return ( - durationName(ownerName(node)) !== null || - (node.operator === '*' && operandDurationName(node, 0) !== null) - ); + return durationName(ownerName(node)) !== null || (node.operator === '*' && operandDurationName(node, 0) !== null); } /** Effect-native rule: instants come from `DateTime`/`Clock`, intervals from `Duration`. */ @@ -553,8 +499,7 @@ export const rule = defineRule({ }, allowDurationArithmetic: { type: 'boolean', - description: - 'Allow hand millisecond arithmetic in duration-named bindings (default: false).', + description: 'Allow hand millisecond arithmetic in duration-named bindings (default: false).', }, ignore: { type: 'array', @@ -568,26 +513,22 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, testPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs force-treated as test files, overriding the built-in test-file detection.', + description: 'Globs force-treated as test files, overriding the built-in test-file detection.', }, productionPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs force-treated as production files even when the built-in test-file detection matches.', + description: 'Globs force-treated as production files even when the built-in test-file detection matches.', }, ignoreReceivers: { type: 'array', items: { type: 'string' }, - description: - 'Receiver expressions whose date-named method calls are ignored (default: ["this", "super"]).', + description: 'Receiver expressions whose date-named method calls are ignored (default: ["this", "super"]).', }, browserEvaluatedMethods: { type: 'array', @@ -629,7 +570,10 @@ export const rule = defineRule({ const ignoreReceivers = new Set(options.ignoreReceivers); const browserEvaluated = new Set(options.browserEvaluatedMethods); const clockTable = clockOnly ? TEST_CLOCK_MEMBERS : CLOCK_MEMBERS; - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; + let bindings: EffectBindings = { + namespaces: new Map(), + importsEffect: false, + }; /** Spans already reported, so one expression never emits two overlapping diagnostics. */ const reported = new Set(); @@ -675,17 +619,12 @@ export const rule = defineRule({ return null; }; - const identifierType = ( - node: Extract, - depth: number, - ): ESTree.TSType | null => { + const identifierType = (node: Extract, depth: number): ESTree.TSType | null => { const variable = resolveVariable(context, node.name, node); - if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) - return null; + if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) return null; const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; if (definition === undefined) return null; - const declared = (definition.name as { typeAnnotation?: ESTree.TSTypeAnnotation }) - .typeAnnotation; + const declared = (definition.name as { typeAnnotation?: ESTree.TSTypeAnnotation }).typeAnnotation; if (declared != null) return declared.typeAnnotation; if (definition.node.type === 'VariableDeclarator' && definition.node.init !== null) return declaredType(definition.node.init, depth + 1); @@ -706,18 +645,13 @@ export const rule = defineRule({ const dateAnnotation = (raw: AnyNode): ESTree.IdentifierReference | null => { const type = declaredType(raw); const resolved = type === null ? null : resolveType(type); - if (resolved?.type !== 'TSTypeReference' || resolved.typeName.type !== 'Identifier') - return null; + if (resolved?.type !== 'TSTypeReference' || resolved.typeName.type !== 'Identifier') return null; return resolved.typeName.name === 'Date' ? resolved.typeName : null; }; const declaredType = (raw: AnyNode, depth = 0): ESTree.TSType | null => { if (depth > 12) return null; - if ( - raw.type === 'TSAsExpression' || - raw.type === 'TSSatisfiesExpression' || - raw.type === 'TSTypeAssertion' - ) + if (raw.type === 'TSAsExpression' || raw.type === 'TSSatisfiesExpression' || raw.type === 'TSTypeAssertion') return raw.typeAnnotation; const node = unwrap(raw, 0); const annotation = (node as { typeAnnotation?: ESTree.TSTypeAnnotation }).typeAnnotation; @@ -811,8 +745,7 @@ export const rule = defineRule({ if (global === null) return; const members = clockTable.get(global); if (members === undefined) return; - for (const property of (id as ESTree.ObjectPattern).properties) - reportClockProperty(property, members); + for (const property of (id as ESTree.ObjectPattern).properties) reportClockProperty(property, members); }, // (4) a Duration spelled out as magic millisecond arithmetic. diff --git a/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts b/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts index 5bcc50286..124160f3c 100644 --- a/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts +++ b/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts @@ -1,4 +1,3 @@ -import { snippet } from '../shared/reporting.ts'; /** * effect-native/no-ambient-process-env * @@ -56,19 +55,13 @@ import { snippet } from '../shared/reporting.ts'; * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { includesRuleFile } from '../shared/paths.ts'; -import { - keyName as sharedKeyName, - memberName, - parentOf, - skipWrappers, - unwrapNode as unwrap, -} from '../shared/ast.ts'; +import { keyName as sharedKeyName, memberName, parentOf, skipWrappers, unwrapNode as unwrap } from '../shared/ast.ts'; import { isUnshadowedGlobal, resolveVariable } from '../shared/bindings.ts'; import { booleanOption, stringList } from '../shared/options.ts'; +import { includesRuleFile } from '../shared/paths.ts'; +import { snippet } from '../shared/reporting.ts'; type AnyNode = ESTree.Node; @@ -87,12 +80,7 @@ const MUTATING_CALLS: ReadonlyMap> = new Map([ ['Reflect', new Set(['set', 'defineProperty', 'deleteProperty'])], ]); -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; interface RuleOptions { readonly allowPaths: readonly string[]; @@ -158,11 +146,7 @@ function isContainerHost(context: Context, member: ESTree.MemberExpression): boo ); } -function isMutatingCall( - context: Context, - call: ESTree.CallExpression, - reference: AnyNode, -): boolean { +function isMutatingCall(context: Context, call: ESTree.CallExpression, reference: AnyNode): boolean { if (call.arguments[0] !== reference || call.callee.type !== 'MemberExpression') return false; const member = call.callee; if (member.object.type !== 'Identifier') return false; @@ -207,7 +191,12 @@ function patternSource(node: AnyNode): AnyNode | null { function isEnvProperty(property: ESTree.ObjectPattern['properties'][number]): boolean { if (property.type !== 'Property') return false; - return sharedKeyName(property.key, property.computed, { templates: true, unwrap: {} }) === 'env'; + return ( + sharedKeyName(property.key, property.computed, { + templates: true, + unwrap: {}, + }) === 'env' + ); } /** Effect-native rule: configuration is declared with `Config` and provided by one `ConfigProvider`. */ @@ -244,22 +233,24 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, }, }, ], defaultOptions: [ - { allowPaths: [], ignoreTestFiles: false, includePaths: [...DEFAULT_INCLUDE_PATHS] }, + { + allowPaths: [], + ignoreTestFiles: false, + includePaths: [...DEFAULT_INCLUDE_PATHS], + }, ], }, create(context) { const options = readOptions(context.options[0]); if (!includesRuleFile(context.filename, options)) return {}; - const printed = (node: AnyNode): string => - snippet(context.sourceCode.getText(node), 72, 69, '...'); + const printed = (node: AnyNode): string => snippet(context.sourceCode.getText(node), 72, 69, '...'); const report = (node: AnyNode, messageId: string): void => { context.report({ node, messageId, data: { expression: printed(node) } }); @@ -288,10 +279,7 @@ export const rule = defineRule({ case 'ImportExpression': return isProcessSource(inner.source); case 'CallExpression': - return ( - isUnshadowedGlobal(context, unwrap(inner.callee), 'require') && - isProcessSource(inner.arguments[0]) - ); + return isUnshadowedGlobal(context, unwrap(inner.callee), 'require') && isProcessSource(inner.arguments[0]); case 'MetaProperty': return isImportMeta(inner); case 'Identifier': @@ -306,15 +294,10 @@ export const rule = defineRule({ /** Climb the continued member chain before classifying its consumer. */ const classify = (envNode: AnyNode): string => { let current = skipWrappers(envNode); - while ( - current.parent?.type === 'MemberExpression' && - current.parent.object === current.node - ) { + while (current.parent?.type === 'MemberExpression' && current.parent.object === current.node) { current = skipWrappers(current.parent); } - return isMutation(context, current.parent, current.node) - ? 'ambientEnvMutation' - : 'ambientEnvRead'; + return isMutation(context, current.parent, current.node) ? 'ambientEnvMutation' : 'ambientEnvRead'; }; return { @@ -322,25 +305,19 @@ export const rule = defineRule({ ImportDeclaration(node) { if (node.importKind === 'type' || !PROCESS_MODULES.has(node.source.value)) return; for (const specifier of node.specifiers) { - if ( - specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier' - ) { + if (specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier') { continue; } if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; // `import { env } from "node:process"` *is* the ambient environment bag. if (imported === 'env') report(specifier as unknown as AnyNode, 'ambientEnvRead'); } }, ExportNamedDeclaration(node) { - if (!node.source || node.exportKind === 'type' || !PROCESS_MODULES.has(node.source.value)) - return; + if (!node.source || node.exportKind === 'type' || !PROCESS_MODULES.has(node.source.value)) return; for (const specifier of node.specifiers) { if ( specifier.type === 'ExportSpecifier' && diff --git a/app/tools/oxlint/effect-native/rules/no-async-script-program.ts b/app/tools/oxlint/effect-native/rules/no-async-script-program.ts index 0b08f0f55..1a6aa16ab 100644 --- a/app/tools/oxlint/effect-native/rules/no-async-script-program.ts +++ b/app/tools/oxlint/effect-native/rules/no-async-script-program.ts @@ -52,14 +52,11 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Ranged } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings as ImportedEffectBindings } from '../shared/effect-imports.ts'; type EffectBindings = ImportedEffectBindings & { context: Context }; -import { globToRegExp, inScriptScope, scriptScope, matchesAny } from '../shared/paths.ts'; - import { parentOf, skipWrappers as climbWrappers, @@ -69,8 +66,9 @@ import { literalText, propertyText, } from '../shared/ast.ts'; -import { provenance } from '../shared/provenance.ts'; import { stringList, booleanOption } from '../shared/options.ts'; +import { globToRegExp, inScriptScope, scriptScope, matchesAny } from '../shared/paths.ts'; +import { provenance } from '../shared/provenance.ts'; type AnyNode = ESTree.Node; @@ -84,12 +82,7 @@ const TRANSPARENT_PARENTS = new Set([ 'TSTypeAssertion', ]); -const FUNCTION_LIKE = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', - 'StaticBlock', -]); +const FUNCTION_LIKE = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', 'StaticBlock']); /** Length of the `async` / `await` keyword, used to anchor a diagnostic on the keyword itself. */ const KEYWORD_LENGTH = 5; @@ -158,16 +151,9 @@ function qualifiedEffectName(node: AnyNode, bindings: EffectBindings): string | } /** `call` is one of the configured driver-edge constructors (`Effect.tryPromise`, …). */ -function isDriverEdgeCall( - call: AnyNode, - bindings: EffectBindings, - callees: readonly string[], -): boolean { +function isDriverEdgeCall(call: AnyNode, bindings: EffectBindings, callees: readonly string[]): boolean { if (call.type !== 'CallExpression') return false; - const qualified = qualifiedEffectName( - unwrap((call as ESTree.CallExpression).callee as AnyNode), - bindings, - ); + const qualified = qualifiedEffectName(unwrap((call as ESTree.CallExpression).callee as AnyNode), bindings); return qualified !== null && callees.includes(qualified); } @@ -183,11 +169,7 @@ function isFirstArgumentOf(node: AnyNode, call: AnyNode): boolean { * `Effect.promise(async () => …)`, `Effect.callback(async (resume) => …)` or * `Effect.tryPromise({ try: async () => …, catch: toFailure })`. */ -function isDriverEdgeFunction( - fn: AnyNode, - bindings: EffectBindings, - callees: readonly string[], -): boolean { +function isDriverEdgeFunction(fn: AnyNode, bindings: EffectBindings, callees: readonly string[]): boolean { const { node, parent } = skipWrappers(fn); if (parent === null) return false; if (isFirstArgumentOf(node, parent)) return isDriverEdgeCall(parent, bindings, callees); @@ -203,11 +185,7 @@ function isDriverEdgeFunction( } /** The function, or any enclosing function, is a driver-edge Promise seam. */ -function insideDriverEdge( - fn: AnyNode, - bindings: EffectBindings, - callees: readonly string[], -): boolean { +function insideDriverEdge(fn: AnyNode, bindings: EffectBindings, callees: readonly string[]): boolean { let current: AnyNode | null = fn; while (current !== null) { if (isDriverEdgeFunction(current, bindings, callees)) return true; @@ -237,29 +215,16 @@ function isRunAdapterExpression(node: AnyNode, context: Context): boolean { if (expression?.type !== 'CallExpression') return false; const callee = syntax(expression.callee); if (isRunAdapter(context, callee)) return true; - if ( - callee?.type === 'MemberExpression' && - ['then', 'catch', 'finally'].includes(propertyText(callee) ?? '') - ) + if (callee?.type === 'MemberExpression' && ['then', 'catch', 'finally'].includes(propertyText(callee) ?? '')) return isRunAdapterExpression(callee.object, context); - return ( - isPipeCall(context, callee) && - expression.arguments.some((arg: AnyNode) => isRunAdapter(context, arg)) - ); + return isPipeCall(context, callee) && expression.arguments.some((arg: AnyNode) => isRunAdapter(context, arg)); } -const MEMBER_PARENTS = new Set([ - 'Property', - 'MethodDefinition', - 'PropertyDefinition', - 'TSAbstractMethodDefinition', -]); +const MEMBER_PARENTS = new Set(['Property', 'MethodDefinition', 'PropertyDefinition', 'TSAbstractMethodDefinition']); function variableFunctionName(fn: AnyNode): Extract | null { const parent = parentOf(fn); - return parent?.type === 'VariableDeclarator' && - parent.init === fn && - parent.id?.type === 'Identifier' + return parent?.type === 'VariableDeclarator' && parent.init === fn && parent.id?.type === 'Identifier' ? parent.id : null; } @@ -272,8 +237,7 @@ function declaredFunctionName(fn: AnyNode): Extract globToRegExp(glob).test(scriptScope(filename)))) - return {}; + if (options.allowPaths.some((glob) => globToRegExp(glob).test(scriptScope(filename)))) return {}; - let bindings: EffectBindings = { importsEffect: false, namespaces: new Map(), context }; + let bindings: EffectBindings = { + importsEffect: false, + namespaces: new Map(), + context, + }; const reportAsync = (node: AnyNode, isAsync: boolean): void => { if (!isAsync) return; @@ -389,21 +356,26 @@ export const rule = defineRule({ // `await` inside an async function is covered by the `asyncFunction` diagnostic. if (nearestFunction(site) !== null) return; if (isRunAdapterExpression(node.argument as unknown as AnyNode, context)) return; - context.report({ node: keywordAnchor(site, KEYWORD_LENGTH), messageId: 'topLevelAwait' }); + context.report({ + node: keywordAnchor(site, KEYWORD_LENGTH), + messageId: 'topLevelAwait', + }); }, VariableDeclaration(node) { - if ( - options.reportTopLevelAwait && - node.kind === 'await using' && - nearestFunction(node) === null - ) - context.report({ node: keywordAnchor(node, KEYWORD_LENGTH), messageId: 'topLevelAwait' }); + if (options.reportTopLevelAwait && node.kind === 'await using' && nearestFunction(node) === null) + context.report({ + node: keywordAnchor(node, KEYWORD_LENGTH), + messageId: 'topLevelAwait', + }); }, ForOfStatement(node) { if (!options.reportTopLevelAwait || node.await !== true) return; const site = node as unknown as AnyNode; if (nearestFunction(site) !== null) return; - context.report({ node: keywordAnchor(site, 3), messageId: 'topLevelForAwait' }); + context.report({ + node: keywordAnchor(site, 3), + messageId: 'topLevelForAwait', + }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts index 012a519b8..4d040333c 100644 --- a/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts @@ -39,24 +39,14 @@ * (`export { runPromise }`), none of which start a fiber. */ import { defineRule } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; + import { keyName, unwrapNode, walk as walkAst } from '../shared/ast.ts'; import { isTrackedReference } from '../shared/bindings.ts'; -import { importedName } from '../shared/imports.ts'; -import { - isNonReferencePosition, - isInTypePosition as inTypePosition, -} from '../shared/reference-positions.ts'; - import { bindingsFor, effectMember, type EffectBindings } from '../shared/effect-imports.ts'; -import { - globToRegExp, - isScriptFile, - isTestFile, - normalisePath, - matchesGlobs, -} from '../shared/paths.ts'; - -import type { Context, ESTree } from '@oxlint/plugins'; +import { importedName } from '../shared/imports.ts'; +import { globToRegExp, isScriptFile, isTestFile, normalisePath, matchesGlobs } from '../shared/paths.ts'; +import { isNonReferencePosition, isInTypePosition as inTypePosition } from '../shared/reference-positions.ts'; /** `runPromise`, `runSync`, `runFork`, `run` — but not `runtime`. */ const RUN_MEMBER = /^run(?:[A-Z]|$)/u; @@ -192,7 +182,11 @@ function isInTypePosition(node: ESTree.Node): boolean { return inTypePosition(node, TS_EXPRESSION_NODES); } function staticName(key: ESTree.Node, computed: boolean): string | null { - return keyName(key, computed, { templates: computed, rawTemplates: true, singleQuasi: true }); + return keyName(key, computed, { + templates: computed, + rawTemplates: true, + singleQuasi: true, + }); } /** @@ -220,7 +214,10 @@ function collectNamedBinding( if (imported === EFFECT_NAMESPACE && source.rootLike) { bindings.effectNamespaces.set(local, specifier.local); } else if (RUN_MEMBER.test(imported) && (source.rootLike || source.effectSubmodule)) { - bindings.runLocals.set(local, { declaration: specifier.local, member: imported }); + bindings.runLocals.set(local, { + declaration: specifier.local, + member: imported, + }); } } function collectImportBindings( @@ -248,8 +245,7 @@ function collectImportSpecifier( if (specifier.type === 'ImportSpecifier') { collectNamedBinding(specifier, source, bindings); } else if (specifier.type === 'ImportNamespaceSpecifier') { - if (source.effectSubmodule) - bindings.effectNamespaces.set(specifier.local.name, specifier.local); + if (source.effectSubmodule) bindings.effectNamespaces.set(specifier.local.name, specifier.local); else if (source.rootLike || source.emptySubmodule) bindings.packageNamespaces.set(specifier.local.name, specifier.local); } @@ -266,17 +262,13 @@ function collectRunBindings(context: Context, effectModules: readonly string[]): .map((module) => globToRegExp(module)); const ast = context.sourceCode.ast; for (const statement of ast.body) { - if (statement.type === 'ImportDeclaration') - collectImportBindings(statement, extraMatchers, bindings); + if (statement.type === 'ImportDeclaration') collectImportBindings(statement, extraMatchers, bindings); } if (bindings.effectNamespaces.size > 0 || bindings.packageNamespaces.size > 0) propagateLocalAliases(context, ast, bindings); return { ...bindings, - tracked: - bindings.effectNamespaces.size > 0 || - bindings.packageNamespaces.size > 0 || - bindings.runLocals.size > 0, + tracked: bindings.effectNamespaces.size > 0 || bindings.packageNamespaces.size > 0 || bindings.runLocals.size > 0, }; } @@ -284,11 +276,7 @@ function collectRunBindings(context: Context, effectModules: readonly string[]): * Follow `const Fx = Effect;`, `const Fx = Pkg.Effect;`, `const { runSync } = Effect;` and * `const { Effect } = Pkg;` to a fixed point, so a one-line re-binding cannot defeat the rule. */ -function trackedNamespace( - context: Context, - node: ESTree.Node, - namespaces: ReadonlyMap, -): boolean { +function trackedNamespace(context: Context, node: ESTree.Node, namespaces: ReadonlyMap): boolean { if (node.type !== 'Identifier') return false; const declaration = namespaces.get(node.name); return declaration !== undefined && isTrackedReference(context, node, declaration); @@ -298,24 +286,15 @@ function packageEffectRoot(node: ESTree.Node): ESTree.Node | null { if (staticName(node.property, node.computed) !== EFFECT_NAMESPACE) return null; return unwrapExpression(node.object); } -function namespaceKind( - context: Context, - init: ESTree.Node, - bindings: CollectedBindings, -): 'effect' | 'package' | null { +function namespaceKind(context: Context, init: ESTree.Node, bindings: CollectedBindings): 'effect' | 'package' | null { if (init.type === 'Identifier') { if (trackedNamespace(context, init, bindings.effectNamespaces)) return 'effect'; return trackedNamespace(context, init, bindings.packageNamespaces) ? 'package' : null; } const root = packageEffectRoot(init); - return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) - ? 'effect' - : null; + return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) ? 'effect' : null; } -function addNamespace( - map: Map, - target: Extract, -): boolean { +function addNamespace(map: Map, target: Extract): boolean { if (map.has(target.name)) return false; map.set(target.name, target); return true; @@ -330,8 +309,7 @@ function addDestructuredAlias( if (name === null) return false; const value = property.value.type === 'AssignmentPattern' ? property.value.left : property.value; if (value.type !== 'Identifier') return false; - if (kind === 'package') - return name === EFFECT_NAMESPACE && addNamespace(bindings.effectNamespaces, value); + if (kind === 'package') return name === EFFECT_NAMESPACE && addNamespace(bindings.effectNamespaces, value); if (!RUN_MEMBER.test(name) || bindings.runLocals.has(value.name)) return false; bindings.runLocals.set(value.name, { declaration: value, member: name }); return true; @@ -346,10 +324,7 @@ function propagateDeclarator( if (kind === null) return false; const target = declarator.id; if (target.type === 'Identifier') - return addNamespace( - kind === 'effect' ? bindings.effectNamespaces : bindings.packageNamespaces, - target, - ); + return addNamespace(kind === 'effect' ? bindings.effectNamespaces : bindings.packageNamespaces, target); if (target.type !== 'ObjectPattern') return false; let changed = false; for (const property of target.properties) { @@ -357,11 +332,7 @@ function propagateDeclarator( } return changed; } -function propagateLocalAliases( - context: Context, - ast: ESTree.Program, - bindings: CollectedBindings, -): void { +function propagateLocalAliases(context: Context, ast: ESTree.Program, bindings: CollectedBindings): void { const declarators: ESTree.VariableDeclarator[] = []; walk(ast, (node) => { if (node.type === 'VariableDeclarator' && node.init !== null) declarators.push(node); @@ -376,27 +347,18 @@ function propagateLocalAliases( } /** `Effect.runPromise` / `E["runSync"]` / ``Fx.Effect[`runFork`]`` → the run member name. */ -function runEntryPoint( - context: Context, - node: ESTree.MemberExpression, - bindings: RunBindings, -): string | null { +function runEntryPoint(context: Context, node: ESTree.MemberExpression, bindings: RunBindings): string | null { const member = staticName(node.property, node.computed); if (member === null || !RUN_MEMBER.test(member)) return null; const object = unwrapExpression(node.object); - if (object.type === 'Identifier') - return trackedNamespace(context, object, bindings.effectNamespaces) ? member : null; + if (object.type === 'Identifier') return trackedNamespace(context, object, bindings.effectNamespaces) ? member : null; const root = packageEffectRoot(object); - return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) - ? member - : null; + return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) ? member : null; } function isFunctionNode(node: ESTree.Node): node is FunctionNode { return ( - node.type === 'ArrowFunctionExpression' || - node.type === 'FunctionDeclaration' || - node.type === 'FunctionExpression' + node.type === 'ArrowFunctionExpression' || node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' ); } @@ -432,11 +394,7 @@ function owningCall(fn: FunctionNode): ESTree.CallExpression | null { } /** A function passed to an Effect/Layer/Stream combinator (`Effect.gen`, `Effect.fn("x")(...)`, `gen(...)`). */ -function isEffectOwnedFunction( - fn: FunctionNode, - bindings: RunBindings, - shared: EffectBindings, -): boolean { +function isEffectOwnedFunction(fn: FunctionNode, bindings: RunBindings, shared: EffectBindings): boolean { const call = owningCall(fn); if (call === null) return false; // `Effect.fn("name")(function* () { ... })` and `Effect.fn()(...)`. @@ -449,24 +407,18 @@ function isEffectOwnedFunction( } if (callee.type !== 'MemberExpression') return false; const member = effectMember(callee, shared); - if (member !== null) - return !(member.namespace === EFFECT_NAMESPACE && RUN_MEMBER.test(member.member)); + if (member !== null) return !(member.namespace === EFFECT_NAMESPACE && RUN_MEMBER.test(member.member)); return isPackageCombinator(callee, bindings); } function isPackageCombinator(callee: ESTree.MemberExpression, bindings: RunBindings): boolean { const root = packageEffectRoot(unwrapExpression(callee.object)); - if (root === null || root.type !== 'Identifier' || !bindings.packageNamespaces.has(root.name)) - return false; + if (root === null || root.type !== 'Identifier' || !bindings.packageNamespaces.has(root.name)) return false; const name = staticName(callee.property, callee.computed); return name !== null && !RUN_MEMBER.test(name); } /** True when the run site sits inside an Effect program body — the S1 nested re-entry case. */ -function isInsideEffectOwnedCode( - node: ESTree.Node, - bindings: RunBindings, - shared: EffectBindings, -): boolean { +function isInsideEffectOwnedCode(node: ESTree.Node, bindings: RunBindings, shared: EffectBindings): boolean { let current: ESTree.Node | null = node.parent; while (current !== null && current.type !== 'Program') { if (isFunctionNode(current) && isEffectOwnedFunction(current, bindings, shared)) return true; @@ -488,8 +440,7 @@ const DECLARATION_KEY_PARENTS = new Set([ ]); const NAME_PARENTS = new Set(['LabeledStatement', 'BreakStatement', 'ContinueStatement']); function isDeclarationPosition(node: ESTree.Node): boolean { - if (node.parent?.type === 'Property') - return Object.is(node.parent.key, node) && !node.parent.computed; + if (node.parent?.type === 'Property') return Object.is(node.parent.key, node) && !node.parent.computed; return isNonReferencePosition(node, { detached: false, keyParents: DECLARATION_KEY_PARENTS, @@ -536,13 +487,8 @@ export const rule = defineRule({ if ( isTestFile(filename) || isScriptFile(filename) || - matchesGlobs(filename, [ - 'apps/*/scripts/**', - 'verticals/*/scripts/**', - 'packages/*/scripts/**', - ]) || - (matchesGlobs(filename, options.browserGlobs) && - !matchesGlobs(filename, options.serverGlobs)) || + matchesGlobs(filename, ['apps/*/scripts/**', 'verticals/*/scripts/**', 'packages/*/scripts/**']) || + (matchesGlobs(filename, options.browserGlobs) && !matchesGlobs(filename, options.serverGlobs)) || matchesGlobs(filename, options.adapterFiles) ) { return {}; diff --git a/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts index 872b7a0eb..5305d06cc 100644 --- a/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts @@ -11,7 +11,6 @@ * Report-only: no fixers or suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; import { skipWrappers, syntax } from '../shared/ast.ts'; @@ -25,12 +24,7 @@ type AnyNode = ESTree.Node; const CONSOLE_MODULES = new Set(['console', 'node:console']); const DEFAULT_METHODS: readonly string[] = ['error', 'warn', 'debug', 'trace']; -const FUNCTION_LIKE = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', - 'StaticBlock', -]); +const FUNCTION_LIKE = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', 'StaticBlock']); interface RuleOptions { readonly allowPaths: readonly string[]; @@ -66,16 +60,9 @@ function observesSink(parent: AnyNode, outer: AnyNode): boolean { return parent.type === 'UnaryExpression' && ['void', 'typeof'].includes(parent.operator); } -function restoresSink( - context: Context, - parent: AnyNode, - outer: AnyNode, - identity: string, -): boolean { +function restoresSink(context: Context, parent: AnyNode, outer: AnyNode, identity: string): boolean { return ( - parent.type === 'AssignmentExpression' && - parent.right === outer && - provenance(context, parent.left) === identity + parent.type === 'AssignmentExpression' && parent.right === outer && provenance(context, parent.left) === identity ); } @@ -148,8 +135,7 @@ export const rule = defineRule({ create(context) { const options = readOptions(context.options[0]); const path = scriptScope(context.filename); - if (!inScriptScope(path) || options.allowPaths.some((glob) => globToRegExp(glob).test(path))) - return {}; + if (!inScriptScope(path) || options.allowPaths.some((glob) => globToRegExp(glob).test(path))) return {}; const methods = new Set(options.methods); const report = (node: AnyNode, id: string, data: Record) => { if (options.allowAtEntry && isEntryPosition(context, node)) return; @@ -161,8 +147,7 @@ export const rule = defineRule({ if (isRestoredCapture(context, node)) return; if (restoresSink(context, parent, outer, identity)) return; const called = parent.type === 'CallExpression' && parent.callee === outer; - if (called || options.reportReferences) - report(node, called ? 'consoleCall' : 'consoleReference', { method }); + if (called || options.reportReferences) report(node, called ? 'consoleCall' : 'consoleReference', { method }); }; const inspect = (node: AnyNode) => { const identity = provenance(context, node); @@ -183,8 +168,7 @@ export const rule = defineRule({ if (valueReference(context, node)) inspect(node as AnyNode); }, ExportNamedDeclaration(node) { - if (!node.source || !CONSOLE_MODULES.has(node.source.value) || node.exportKind === 'type') - return; + if (!node.source || !CONSOLE_MODULES.has(node.source.value) || node.exportKind === 'type') return; for (const s of node.specifiers) { if (s.exportKind === 'type') continue; const name = s.local.type === 'Identifier' ? s.local.name : s.local.value; @@ -200,8 +184,7 @@ export const rule = defineRule({ function isRestoredCapture(context: Context, node: AnyNode): boolean { const n = syntax(node), p = n?.parent; - if (!n || p?.type !== 'VariableDeclarator' || p.init !== n || p.id.type !== 'Identifier') - return false; + if (!n || p?.type !== 'VariableDeclarator' || p.init !== n || p.id.type !== 'Identifier') return false; const variable = lexicalVariable(context, p.id); if (!variable) return false; const reads = variable.references.filter((r) => r.isRead()); diff --git a/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts b/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts index a3b98424c..d8ce86775 100644 --- a/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts +++ b/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts @@ -12,22 +12,16 @@ * Report only; no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; -import { - keyName as staticKeyName, - unwrapBinding, - unwrapType as unwrapSharedType, -} from '../shared/ast.ts'; +import { keyName as staticKeyName, unwrapBinding, unwrapType as unwrapSharedType } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; import { booleanOption as boolean, compile, stringList } from '../shared/options.ts'; import { isSourceRuleInScope } from '../shared/source-rule-scope.ts'; type AnyNode = ESTree.Node; -const DEFAULT_DEPENDENCY_TYPE_PATTERN = - '(Service|Repository|Gateway|Resolver|Dependencies|ServiceFactory)$'; +const DEFAULT_DEPENDENCY_TYPE_PATTERN = '(Service|Repository|Gateway|Resolver|Dependencies|ServiceFactory)$'; const DEFAULT_ALLOW_TYPE_NAMES: readonly string[] = []; const DEFAULT_SERVICE_INDEX_KEYS: readonly string[] = ['Service']; const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; @@ -43,11 +37,7 @@ const TYPE_WRAPPERS = new Set([ 'TSRestType', ]); -type MessageId = - | 'dependencyParameter' - | 'layerParameter' - | 'inlineServiceRecord' - | 'dependencyOptionBag'; +type MessageId = 'dependencyParameter' | 'layerParameter' | 'inlineServiceRecord' | 'dependencyOptionBag'; interface Verdict { readonly messageId: MessageId; @@ -131,10 +121,8 @@ function typeQueryName(node: AnyNode): string | null { /** Members of an object type body, whichever container holds them. */ function membersOf(node: AnyNode): readonly AnyNode[] { - if (node.type === 'TSTypeLiteral') - return (node as unknown as { members: readonly AnyNode[] }).members; - if (node.type === 'TSInterfaceBody') - return (node as unknown as { body: readonly AnyNode[] }).body; + if (node.type === 'TSTypeLiteral') return (node as unknown as { members: readonly AnyNode[] }).members; + if (node.type === 'TSInterfaceBody') return (node as unknown as { body: readonly AnyNode[] }).body; if (node.type === 'TSInterfaceDeclaration') { const body = (node as unknown as { body: AnyNode }).body; return (body as unknown as { body: readonly AnyNode[] }).body; @@ -205,13 +193,11 @@ export const rule = defineRule({ }, includeScripts: { type: 'boolean', - description: - 'Also report inside scripts/** (default: false — B3 migrates only consequential scripts).', + description: 'Also report inside scripts/** (default: false — B3 migrates only consequential scripts).', }, includeTests: { type: 'boolean', - description: - "Also report inside test files (default: false — the audit's D tier blesses test fixtures).", + description: "Also report inside test files (default: false — the audit's D tier blesses test fixtures).", }, serviceIndexKeys: { type: 'array', @@ -299,15 +285,14 @@ export const rule = defineRule({ }; function classifyIndexed(node: AnyNode): Verdict | null { - const indexed = node as unknown as { objectType: AnyNode; indexType: AnyNode }; + const indexed = node as unknown as { + objectType: AnyNode; + indexType: AnyNode; + }; const owner = typeQueryName(unwrapType(indexed.objectType)); const index = unwrapType(indexed.indexType); - const literal = - index.type === 'TSLiteralType' ? (index as unknown as { literal: AnyNode }).literal : null; - const key = - literal !== null && literal.type === 'Literal' - ? (literal as { value?: unknown }).value - : undefined; + const literal = index.type === 'TSLiteralType' ? (index as unknown as { literal: AnyNode }).literal : null; + const key = literal !== null && literal.type === 'Literal' ? (literal as { value?: unknown }).value : undefined; if (owner !== null && typeof key === 'string' && options.serviceIndexKeys.has(key)) { return { member: null, @@ -321,8 +306,7 @@ export const rule = defineRule({ function classifyLayer(node: AnyNode, qualifier: string | null): Verdict { const args = (node as unknown as { typeArguments: AnyNode | null }).typeArguments; - const first = - args === null ? undefined : (args as unknown as { params: readonly AnyNode[] }).params[0]; + const first = args === null ? undefined : (args as unknown as { params: readonly AnyNode[] }).params[0]; const provided = first === undefined ? null @@ -344,8 +328,7 @@ export const rule = defineRule({ const result = local.returnType?.typeAnnotation; if (!result || returnsEffect(local.returnType)) return false; return !( - result.type === 'TSTypeReference' && - ['Promise', 'PromiseLike'].includes(lastTypeName(result.typeName) ?? '') + result.type === 'TSTypeReference' && ['Promise', 'PromiseLike'].includes(lastTypeName(result.typeName) ?? '') ); } @@ -395,8 +378,7 @@ export const rule = defineRule({ if (name === null) return null; const qualifier = qualifierName(typeName); const origin = importedPath(typeName); - if (/^(?:root\.)?Layer(?:\.Layer)?$/u.test(origin ?? '')) - return classifyLayer(node, qualifier); + if (/^(?:root\.)?Layer(?:\.Layer)?$/u.test(origin ?? '')) return classifyLayer(node, qualifier); if (origin !== null) return null; return classifyApplicationReference(node, typeName, name, qualifier, depth); } @@ -447,17 +429,23 @@ export const rule = defineRule({ if (memberName === null) continue; const verdict = classify(signature.typeAnnotation, depth); if (verdict === null) continue; - return { ...verdict, member: memberName, messageId: 'dependencyOptionBag' }; + return { + ...verdict, + member: memberName, + messageId: 'dependencyOptionBag', + }; } return null; } function objectParameterName(binding: AnyNode): string { const keys: string[] = []; - for (const property of (binding as unknown as { properties: readonly AnyNode[] }) - .properties) { + for (const property of (binding as unknown as { properties: readonly AnyNode[] }).properties) { if (property.type !== 'Property') continue; - const entry = property as unknown as { key: AnyNode; computed: boolean }; + const entry = property as unknown as { + key: AnyNode; + computed: boolean; + }; const name = keyName(entry.key, entry.computed); if (name !== null) keys.push(name); if (keys.length === 3) break; diff --git a/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts index b424e780b..5c1982f1f 100644 --- a/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts @@ -80,7 +80,6 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree, Variable } from '@oxlint/plugins'; import { memberName, skipWrappers, staticString, unwrapNode as unwrap } from '../shared/ast.ts'; @@ -120,10 +119,7 @@ const DEFAULTS: RuleOptions = { }; function readOptions(raw: unknown): RuleOptions { - const given = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const given = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), modules: stringList(given.modules, DEFAULTS.modules), @@ -155,15 +151,11 @@ function staticStringValue(node: AnyNode | null | undefined): string | null { } function matchesRequiredBinding(variable: Variable, declarators: ReadonlySet): boolean { - return variable.defs.some( - (definition) => definition.type === 'Variable' && declarators.has(definition.node.start), - ); + return variable.defs.some((definition) => definition.type === 'Variable' && declarators.has(definition.node.start)); } /** The base identifier of a (possibly nested, possibly optional) member chain: `fs.promises.readFile`. */ -function memberChainRoot( - node: ESTree.MemberExpression, -): { readonly root: AnyNode; readonly path: string[] } | null { +function memberChainRoot(node: ESTree.MemberExpression): { readonly root: AnyNode; readonly path: string[] } | null { const path: string[] = []; let current: AnyNode = node; while (current.type === 'MemberExpression') { @@ -266,10 +258,7 @@ export const rule = defineRule({ const variable = resolveVariable(context, name, node); // Unresolved: the module-level declaration already proved the binding exists. if (variable === null || variable.defs.length === 0) return recordedModule; - if ( - fromImport !== undefined && - variable.defs.some((definition) => definition.type === 'ImportBinding') - ) { + if (fromImport !== undefined && variable.defs.some((definition) => definition.type === 'ImportBinding')) { return fromImport; } if (fromRequire === undefined) return null; @@ -300,10 +289,13 @@ export const rule = defineRule({ const reportRequireCall = (node: ESTree.CallExpression, callee: AnyNode): boolean => { if (callee.type !== 'Identifier') return false; const required = staticStringValue(node.arguments[0] as AnyNode | undefined); - if (required === null || !isNodeIo(required) || provenance(context, callee) !== 'require') - return false; + if (required === null || !isNodeIo(required) || provenance(context, callee) !== 'require') return false; registerRequireBinding(node, required); - context.report({ node, messageId: 'nodeIoRequire', data: { module: required } }); + context.report({ + node, + messageId: 'nodeIoRequire', + data: { module: required }, + }); return true; }; @@ -329,24 +321,34 @@ export const rule = defineRule({ (node.specifiers.length > 0 && node.specifiers.every((s) => s.exportKind === 'type')) ) return; - context.report({ node, messageId: 'nodeIoImport', data: { module: source.value } }); + context.report({ + node, + messageId: 'nodeIoImport', + data: { module: source.value }, + }); }, ExportAllDeclaration(node) { if (!isNodeIo(node.source.value)) return; if (node.exportKind === 'type') return; - context.report({ node, messageId: 'nodeIoImport', data: { module: node.source.value } }); + context.report({ + node, + messageId: 'nodeIoImport', + data: { module: node.source.value }, + }); }, ImportExpression(node) { const module = staticStringValue(node.source as AnyNode); if (module === null || !isNodeIo(module)) return; - context.report({ node, messageId: 'nodeIoDynamicImport', data: { module } }); + context.report({ + node, + messageId: 'nodeIoDynamicImport', + data: { module }, + }); }, TSImportEqualsDeclaration(node) { const reference = node.moduleReference as AnyNode; if (reference.type !== 'TSExternalModuleReference') return; - const module = staticStringValue( - (reference as ESTree.TSExternalModuleReference).expression as AnyNode, - ); + const module = staticStringValue((reference as ESTree.TSExternalModuleReference).expression as AnyNode); if (module === null || !isNodeIo(module)) return; if (node.importKind === 'type') return; importedLocals.set(node.id.name, module); @@ -364,7 +366,11 @@ export const rule = defineRule({ const name = (callee as ESTree.IdentifierReference).name; const module = resolvesToNodeIo(callee as AnyNode, name); if (module === null) return; - context.report({ node, messageId: 'nodeIoCall', data: { call: `${name}()`, module } }); + context.report({ + node, + messageId: 'nodeIoCall', + data: { call: `${name}()`, module }, + }); return; } diff --git a/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts b/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts index fe2ee8ed3..4ab9b9ba6 100644 --- a/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts +++ b/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts @@ -70,14 +70,13 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { staticString, unwrapNode as unwrap } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; import { importedName } from '../shared/imports.ts'; import { stringList as stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; /** * Any dotenv-family loader package, with or without a subpath (`dotenv/config` is the side-effect @@ -93,12 +92,7 @@ const DOTENV_MODULE = const NODE_MODULE_SPECIFIER = /^(?:node:module|module)$/u; /** Audit scope: application, vertical, package and script sources. Tests included by default. */ -const DEFAULT_SCOPE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_SCOPE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; /** * The local bootstrap composition root loads dotenv into a local record (processEnv), not the @@ -121,14 +115,10 @@ const DEFAULTS: RuleOptions = { type AnyNode = ESTree.Node; function readOptions(raw: unknown): RuleOptions { - const given = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const given = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { allowPaths: stringArray(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, + ignoreTestFiles: typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, scopePaths: stringArray(given.scopePaths, DEFAULTS.scopePaths), }; } @@ -169,27 +159,15 @@ function staticMemberName(node: ESTree.MemberExpression): string | null { */ interface Tracker { /** Record a binding introduced by an `import` declaration (def type `ImportBinding`). */ - readonly addImport: ( - name: string, - value: string, - ...anchors: readonly (AnyNode | null | undefined)[] - ) => void; + readonly addImport: (name: string, value: string, ...anchors: readonly (AnyNode | null | undefined)[]) => void; /** Record a binding introduced by a declarator / declaration, anchored on the given nodes. */ - readonly addDeclared: ( - name: string, - value: string, - ...anchors: readonly (AnyNode | null | undefined)[] - ) => void; + readonly addDeclared: (name: string, value: string, ...anchors: readonly (AnyNode | null | undefined)[]) => void; readonly resolve: (node: AnyNode, name: string) => string | null; } function createTracker(context: Context): Tracker { const anchored = new Map(); - const add = ( - _name: string, - value: string, - ...anchors: readonly (AnyNode | null | undefined)[] - ): void => { + const add = (_name: string, value: string, ...anchors: readonly (AnyNode | null | undefined)[]): void => { for (const node of anchors) { const start = startOf(node); if (start !== null) anchored.set(start, value); @@ -202,8 +180,7 @@ function createTracker(context: Context): Tracker { const variable = resolveVariable(context, name, node); if (!variable || variable.defs.length !== 1) return null; // Do not infer the current value after a reassignment. - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; const definition = variable.defs[0]; return ( anchored.get(startOf(definition.name as AnyNode) ?? -1) ?? @@ -313,9 +290,7 @@ export const rule = defineRule({ if (staticMemberName(callee as ESTree.MemberExpression) !== 'createRequire') return false; const object = unwrap((callee as ESTree.MemberExpression).object as AnyNode); if (object.type === 'Identifier') { - return ( - moduleNamespace.resolve(object, (object as ESTree.IdentifierReference).name) !== null - ); + return moduleNamespace.resolve(object, (object as ESTree.IdentifierReference).name) !== null; } return isAmbientModuleRequire(object); }; @@ -415,9 +390,7 @@ export const rule = defineRule({ }; /** The dotenv module a member chain's root object resolves to, plus a readable label. */ - const resolveDotenvObject = ( - expression: AnyNode, - ): { readonly module: string; readonly label: string } | null => { + const resolveDotenvObject = (expression: AnyNode): { readonly module: string; readonly label: string } | null => { const target = unwrap(expression); if (target.type === 'Identifier') { const name = (target as ESTree.IdentifierReference).name; @@ -455,25 +428,35 @@ export const rule = defineRule({ const source = node.source; if (source === null || !isDotenvSpecifier(source.value)) return; if (node.exportKind === 'type') return; - context.report({ node, messageId: 'dotenvImport', data: { module: source.value } }); + context.report({ + node, + messageId: 'dotenvImport', + data: { module: source.value }, + }); }, ExportAllDeclaration(node) { if (!isDotenvSpecifier(node.source.value)) return; if (node.exportKind === 'type') return; - context.report({ node, messageId: 'dotenvImport', data: { module: node.source.value } }); + context.report({ + node, + messageId: 'dotenvImport', + data: { module: node.source.value }, + }); }, ImportExpression(node) { const module = staticStringValue(node.source as AnyNode); if (module === null || !isDotenvSpecifier(module)) return; - context.report({ node, messageId: 'dotenvDynamicImport', data: { module } }); + context.report({ + node, + messageId: 'dotenvDynamicImport', + data: { module }, + }); }, TSImportEqualsDeclaration(node) { if (node.importKind === 'type') return; const reference = node.moduleReference as AnyNode; if (reference.type !== 'TSExternalModuleReference') return; - const module = staticStringValue( - (reference as ESTree.TSExternalModuleReference).expression as AnyNode, - ); + const module = staticStringValue((reference as ESTree.TSExternalModuleReference).expression as AnyNode); if (module === null) return; if (NODE_MODULE_SPECIFIER.test(module)) { moduleNamespace.addImport(node.id.name, 'node:module', node.id, node); @@ -517,7 +500,11 @@ export const rule = defineRule({ if (isModuleLoaderCallee(callee)) { const module = staticStringValue((node.arguments[0] as AnyNode | undefined) ?? null); if (module === null || !isDotenvSpecifier(module)) return; - context.report({ node, messageId: 'dotenvRequire', data: { module } }); + context.report({ + node, + messageId: 'dotenvRequire', + data: { module }, + }); return; } @@ -526,7 +513,11 @@ export const rule = defineRule({ const name = (callee as ESTree.IdentifierReference).name; const module = dotenv.resolve(callee, name); if (module === null) return; - context.report({ node, messageId: 'dotenvCall', data: { call: `${name}()`, module } }); + context.report({ + node, + messageId: 'dotenvCall', + data: { call: `${name}()`, module }, + }); return; } @@ -536,7 +527,11 @@ export const rule = defineRule({ if (object === null) return; const member = staticMemberName(callee as ESTree.MemberExpression); const call = member === null ? `${object.label}[…]()` : `${object.label}.${member}()`; - context.report({ node, messageId: 'dotenvCall', data: { call, module: object.module } }); + context.report({ + node, + messageId: 'dotenvCall', + data: { call, module: object.module }, + }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts b/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts index 6ab8b3c15..c021f2f97 100644 --- a/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts +++ b/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A5** — "Introduce an Effect-shaped persistence seam and typed database failures" * in `docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`: *"PostgreSQL failures are either walked @@ -55,20 +54,15 @@ import { optionRecord } from '../shared/options.ts'; * reports; it never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - isNode, - EXPRESSION_WRAPPERS, - staticString as readStaticString, - keyName, -} from '../shared/ast.ts'; +import { isNode, EXPRESSION_WRAPPERS, staticString as readStaticString, keyName } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; import { effectOrigin } from '../shared/effect-identity.ts'; +import { optionRecord } from '../shared/options.ts'; import { compile, stringArray } from '../shared/options.ts'; -import { snippet } from '../shared/reporting.ts'; import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { snippet } from '../shared/reporting.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -140,26 +134,14 @@ const DEFAULT_EXIT_NAME_PATTERN = 'exit$'; const DEFAULT_PREFIX_METHODS = ['startsWith', 'endsWith']; /** Membership probes that make a bare literal a driver-code comparison. */ -const MEMBERSHIP_METHODS = new Set([ - 'startsWith', - 'endsWith', - 'includes', - 'has', - 'indexOf', - 'match', - 'test', -]); +const MEMBERSHIP_METHODS = new Set(['startsWith', 'endsWith', 'includes', 'has', 'indexOf', 'match', 'test']); const EQUALITY_OPERATORS = new Set(['===', '!==', '==', '!=']); const CAUSE_KEY = 'cause'; /** `/^(?:08|40|53)/u` and `/^08$/u` — SQLSTATE class prefix matchers. */ -const SQLSTATE_REGEX_PATTERNS = [ - /^\^\((?:\?:)?[0-9]{2}(?:\|[0-9]{2})*\)/u, - /^\^[0-9]{2}\$?$/u, - /^\^[0-9]\[[0-9-]+\]/u, -]; +const SQLSTATE_REGEX_PATTERNS = [/^\^\((?:\?:)?[0-9]{2}(?:\|[0-9]{2})*\)/u, /^\^[0-9]{2}\$?$/u, /^\^[0-9]\[[0-9-]+\]/u]; const TWO_DIGIT = /^[0-9]{2}$/u; @@ -191,11 +173,7 @@ function readOptions(context: Context): RuleOptions { decoderPaths: stringArray(record.decoderPaths, []), narrowedKeys: new Set(stringArray(record.narrowedKeys, DEFAULT_NARROWED_KEYS)), ambiguousKeys: new Set(stringArray(record.ambiguousKeys, DEFAULT_AMBIGUOUS_KEYS)), - failureOperandPattern: compile( - record.failureOperandPattern, - DEFAULT_FAILURE_OPERAND_PATTERN, - 'iu', - ), + failureOperandPattern: compile(record.failureOperandPattern, DEFAULT_FAILURE_OPERAND_PATTERN, 'iu'), networkCodes: new Set(stringArray(record.networkCodes, DEFAULT_NETWORK_CODES)), causeSinks: stringArray(record.causeSinks, DEFAULT_CAUSE_SINKS), sqlStatePattern: compile(record.sqlStatePattern, DEFAULT_SQLSTATE_PATTERN, 'u'), @@ -237,8 +215,7 @@ function causeMemberKey(node: AnyNode, key: string): boolean { function objectLooksLikeExit(object: unknown, pattern: RegExp): boolean { const target = unwrap(object); if (target === null) return false; - if (target.type === 'Identifier') - return typeof target.name === 'string' && pattern.test(target.name); + if (target.type === 'Identifier') return typeof target.name === 'string' && pattern.test(target.name); if (target.type === 'MemberExpression') { const name = memberPropertyName(target); return name !== null && pattern.test(name); @@ -250,9 +227,7 @@ function objectLooksLikeExit(object: unknown, pattern: RegExp): boolean { function isCauseSink(context: Context, callee: unknown, sinks: readonly string[]): boolean { const target = unwrap(callee); if (target === null) return false; - const origin = effectOrigin(context, target as unknown as ESTree.Node, [ - '@modern-js/plugin-bff/effect-edge', - ]); + const origin = effectOrigin(context, target as unknown as ESTree.Node, ['@modern-js/plugin-bff/effect-edge']); if (origin?.length !== 2) return false; const resolved = { namespace: origin[0], member: origin[1] }; return sinks.some((sink) => { @@ -279,12 +254,7 @@ function insideCauseSink(context: Context, node: AnyNode, sinks: readonly string return false; } -function callConsumesCause( - context: Context, - call: AnyNode, - value: AnyNode, - sinks: readonly string[], -): boolean { +function callConsumesCause(context: Context, call: AnyNode, value: AnyNode, sinks: readonly string[]): boolean { const args = Array.isArray(call.arguments) ? call.arguments : []; if (args.includes(value) && isCauseSink(context, call.callee, sinks)) return true; const origin = isNode(call.callee) ? effectOrigin(context, call.callee, []) : null; @@ -321,12 +291,7 @@ const NON_EXPRESSION_PARENTS = new Set([ 'Directive', 'ExpressionStatement', ]); -const PROPERTY_PARENTS = new Set([ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'AccessorProperty', -]); +const PROPERTY_PARENTS = new Set(['Property', 'PropertyDefinition', 'MethodDefinition', 'AccessorProperty']); /** Positions where a string literal is real runtime data rather than a key, type or module specifier. */ function isExpressionContext(node: AnyNode): boolean { @@ -335,9 +300,7 @@ function isExpressionContext(node: AnyNode): boolean { if (NON_EXPRESSION_PARENTS.has(parent.type)) return false; if (PROPERTY_PARENTS.has(parent.type)) { return ( - (parent.type === 'Property' && - isNode(parent.parent) && - parent.parent.type === 'ObjectExpression') || + (parent.type === 'Property' && isNode(parent.parent) && parent.parent.type === 'ObjectExpression') || (parent as AnyNode).key !== node ); } @@ -375,8 +338,7 @@ function isMembershipArgument(parent: AnyNode, node: AnyNode): boolean { function operandLooksLikeFailure(node: unknown, pattern: RegExp): boolean { const target = unwrap(node); if (target === null) return false; - if (target.type === 'Identifier') - return typeof target.name === 'string' && pattern.test(target.name); + if (target.type === 'Identifier') return typeof target.name === 'string' && pattern.test(target.name); if (target.type === 'MemberExpression') { const property = memberPropertyName(target); if (property !== null && pattern.test(property)) return true; @@ -403,9 +365,7 @@ function unshadowedGlobal(context: Context, input: unknown, name: string): boole if ( scope.set .get(name) - ?.defs.some( - (def) => !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'].includes(def.node.type), - ) + ?.defs.some((def) => !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'].includes(def.node.type)) ) return false; scope = scope.upper; @@ -418,9 +378,7 @@ function isReassignment(reference: { isWrite(): boolean; init?: boolean }): bool } function isConstantDeclaration(node: ESTree.Node | undefined): node is ESTree.VariableDeclarator { return ( - node?.type === 'VariableDeclarator' && - node.parent?.type === 'VariableDeclaration' && - node.parent.kind === 'const' + node?.type === 'VariableDeclarator' && node.parent?.type === 'VariableDeclaration' && node.parent.kind === 'const' ); } @@ -428,8 +386,7 @@ function constValue(context: Context, input: unknown, depth = 0): AnyNode | null const node = unwrap(input); if (!node || node.type !== 'Identifier' || depth > 24) return node; const variable = resolveVariable(context, String(node.name), node as unknown as ESTree.Node); - if (!variable || variable.defs.length !== 1 || variable.references.some(isReassignment)) - return node; + if (!variable || variable.defs.length !== 1 || variable.references.some(isReassignment)) return node; const declaration = variable.defs[0]?.node; if (!isConstantDeclaration(declaration)) return node; return constValue(context, declaration.init, depth + 1); @@ -442,8 +399,7 @@ function codePrefixSubject(context: Context, input: unknown, depth = 0): boolean const node = constValue(context, input); if (!node) return false; if (node.type === 'Identifier') return /(?:code|sqlstate)$/iu.test(String(node.name)); - if (node.type === 'MemberExpression') - return /(?:code|sqlstate)$/iu.test(memberPropertyName(node) ?? ''); + if (node.type === 'MemberExpression') return /(?:code|sqlstate)$/iu.test(memberPropertyName(node) ?? ''); if (node.type === 'CallExpression' && unshadowedGlobal(context, node.callee, 'String')) { return codePrefixSubject(context, (node.arguments as unknown[])[0], depth + 1); } @@ -463,16 +419,11 @@ function hasDriverEvidence(input: AnyNode, options: RuleOptions): boolean { let region = input; while ( isNode(region.parent) && - !['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'Program'].includes( - region.type, - ) + !['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'Program'].includes(region.type) ) region = region.parent; const walk = (node: AnyNode): boolean => { - if ( - node !== region && - ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) - ) + if (node !== region && ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return false; if (isDriverEvidence(node, options)) return true; for (const [key, value] of Object.entries(node)) { @@ -504,9 +455,7 @@ function isDriverEvidence(node: AnyNode, options: RuleOptions): boolean { function hasSqlStateRegex(node: AnyNode): boolean { const regex = node.regex as { pattern?: string } | undefined; - return Boolean( - regex?.pattern && SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!)), - ); + return Boolean(regex?.pattern && SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!))); } function isPrefixComparison(context: Context, value: unknown, other: unknown): boolean { @@ -514,8 +463,7 @@ function isPrefixComparison(context: Context, value: unknown, other: unknown): b const call = unwrap(other); if (text === null || !TWO_DIGIT.test(text) || call?.type !== 'CallExpression') return false; const callee = unwrap(call.callee); - if (callee?.type !== 'MemberExpression' || !codePrefixSubject(context, callee.object)) - return false; + if (callee?.type !== 'MemberExpression' || !codePrefixSubject(context, callee.object)) return false; return isPrefixSlice(callee, call.arguments as unknown[]); } function isPrefixSlice(callee: AnyNode, args: unknown[]): boolean { @@ -532,8 +480,7 @@ function isOwnKeyProbe(context: Context, callee: AnyNode, method: string | null) } function isPrototypeOwnProbe(context: Context, input: unknown): boolean { const own = unwrap(input); - if (own?.type !== 'MemberExpression' || memberPropertyName(own) !== 'hasOwnProperty') - return false; + if (own?.type !== 'MemberExpression' || memberPropertyName(own) !== 'hasOwnProperty') return false; const prototype = unwrap(own.object); return ( prototype?.type === 'MemberExpression' && @@ -674,30 +621,25 @@ export const rule = defineRule({ if (!options.includeTests && isTestFile(path)) return {}; if (isScriptFile(path)) return {}; - const reportNode = ( - node: ESTree.Node, - messageId: string, - data: Record, - ): void => { - context.report({ node, messageId, data: { text: excerpt(context, node), ...data } }); + const reportNode = (node: ESTree.Node, messageId: string, data: Record): void => { + context.report({ + node, + messageId, + data: { text: excerpt(context, node), ...data }, + }); }; const narrowing = (node: ESTree.Node, subject: unknown, key: string | null): void => { if (key === null || !options.narrowedKeys.has(key)) return; if (key === 'code' && !hasDriverEvidence(node as unknown as AnyNode, options)) return; - if ( - options.ambiguousKeys.has(key) && - !operandLooksLikeFailure(subject, options.failureOperandPattern) - ) - return; + if (options.ambiguousKeys.has(key) && !operandLooksLikeFailure(subject, options.failureOperandPattern)) return; reportNode(node, 'inNarrowing', { key }); }; const literal = (node: ESTree.Node): void => { const raw = node as unknown as AnyNode; const regex = raw.regex as { pattern?: string } | undefined; if (regex?.pattern) { - if (SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!))) - reportNode(node, 'sqlStateRegex', {}); + if (SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!))) reportNode(node, 'sqlStateRegex', {}); return; } const text = staticString(raw); @@ -706,10 +648,7 @@ export const rule = defineRule({ reportNode(node, 'networkCode', {}); return; } - if ( - options.sqlStatePattern.test(text) && - (options.sqlStateAnywhere || isCodeComparisonPosition(raw)) - ) + if (options.sqlStatePattern.test(text) && (options.sqlStateAnywhere || isCodeComparisonPosition(raw))) reportNode(node, 'sqlStateLiteral', {}); }; const runtimeRegex = (node: ESTree.CallExpression | ESTree.NewExpression): void => { @@ -740,13 +679,7 @@ export const rule = defineRule({ const raw = node as unknown as AnyNode; if (!causeMemberKey(raw, CAUSE_KEY)) return; const object = unwrap(raw.object); - if ( - !object || - object.type === 'ThisExpression' || - object.type === 'Super' || - isAssignmentTarget(raw) - ) - return; + if (!object || object.type === 'ThisExpression' || object.type === 'Super' || isAssignmentTarget(raw)) return; if ( objectLooksLikeExit(raw.object, options.exitNamePattern) || insideCauseSink(context, raw, options.causeSinks) @@ -783,12 +716,7 @@ export const rule = defineRule({ const method = memberPropertyName(callee); const args = node.arguments; if (isOwnKeyProbe(context, callee, method)) narrowing(node, args[0], staticString(args[1])); - if ( - !method || - !options.prefixMethods.has(method) || - !codePrefixSubject(context, callee.object) - ) - return; + if (!method || !options.prefixMethods.has(method) || !codePrefixSubject(context, callee.object)) return; const text = staticString(args[0]); if (text !== null && TWO_DIGIT.test(text)) { reportNode(node, 'codePrefix', {}); diff --git a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts index b56fe9737..28bc3160a 100644 --- a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts +++ b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-duplicate-literal-vocabulary * @@ -81,13 +80,13 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { collectSchemaLocals } from '../shared/imports.ts'; import { memberName, staticString, unwrapNode } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { collectSchemaLocals } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { booleanOption, positiveInteger, stringArray } from '../shared/options.ts'; import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; @@ -148,7 +147,10 @@ const VOCABULARY_WRAPPERS: ReadonlySet = new Set([ /** Preserve the vocabulary rule's bounded, deliberately narrow wrapper policy. */ function unwrap(node: ESTree.Node): ESTree.Node { - return unwrapNode(node, { wrappers: VOCABULARY_WRAPPERS, maxDepth: MAX_NAME_DEPTH }); + return unwrapNode(node, { + wrappers: VOCABULARY_WRAPPERS, + maxDepth: MAX_NAME_DEPTH, + }); } function constBindingDeclarator( @@ -174,18 +176,12 @@ function constInitializer( identifier: Extract, ): ESTree.Node | null { const declarator = constBindingDeclarator(context, identifier); - if (declarator === null || declarator.init === null || declarator.id.type !== 'Identifier') - return null; + if (declarator === null || declarator.init === null || declarator.id.type !== 'Identifier') return null; return unwrap(declarator.init); } /** `true` when this expression denotes Effect's `Schema` namespace (possibly through a const alias). */ -function isSchemaNamespace( - node: ESTree.Node, - context: Context, - locals: SchemaLocals, - hops: number, -): boolean { +function isSchemaNamespace(node: ESTree.Node, context: Context, locals: SchemaLocals, hops: number): boolean { // `Schema.Literals([...])` / `S.Literals([...])` / `Schema['Literals']([...])`. if (node.type === 'Identifier') { if (locals.schema.has(node.name)) return resolvesToImport(context, node); @@ -233,8 +229,7 @@ function factoryOf( hops: number, ): string | null { const callee = unwrap(node); - if (callee.type === 'Identifier') - return identifierFactory(callee, context, locals, factories, hops); + if (callee.type === 'Identifier') return identifierFactory(callee, context, locals, factories, hops); if (callee.type !== 'MemberExpression') return null; const member = memberName(callee); if (member === null || !factories.includes(member)) return null; @@ -372,11 +367,7 @@ function duplicateMessage(canonical: Occurrence) { return canonical.name === null ? 'duplicateAnonymous' : 'duplicateOfNamed'; } -function reportDuplicateGroup( - context: Context, - group: Group, - reported: Set, -): void { +function reportDuplicateGroup(context: Context, group: Group, reported: Set): void { // Calls built from a shared constant are authorities, never copies to report. const copies = group.occurrences.filter((occurrence) => !occurrence.authority); if (copies.length === 0) return; @@ -507,11 +498,7 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); const schemaLocals = collectSchemaLocals(program, bindings, resolved.reexportModules); - if ( - schemaLocals.schema.size === 0 && - schemaLocals.barrel.size === 0 && - schemaLocals.direct.size === 0 - ) { + if (schemaLocals.schema.size === 0 && schemaLocals.barrel.size === 0 && schemaLocals.direct.size === 0) { return false; } locals = schemaLocals; diff --git a/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts b/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts index 06dcf2730..fb8bb6925 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts @@ -52,23 +52,18 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - unwrapNode as unwrap, - memberName as staticMemberName, - FUNCTION_TYPES, -} from '../shared/ast.ts'; +import { unwrapNode as unwrap, memberName as staticMemberName, FUNCTION_TYPES } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { bindingsFor } from '../shared/effect-imports.ts'; +import type { EffectBindings } from '../shared/effect-imports.ts'; import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; +import { isTestFile, matchesAny } from '../shared/paths.ts'; import { isInTypePosition as inTypePosition, isNonReferencePosition as nonReferencePosition, } from '../shared/reference-positions.ts'; -import { bindingsFor } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesAny } from '../shared/paths.ts'; const EFFECT_ROOT_MODULE = 'effect'; /** `effect/Effect`, and the same module reached through a deeper path (`effect/unstable/.../Effect`). */ @@ -121,9 +116,7 @@ function readStringArray(value: unknown, fallback: readonly string[]): readonly function resolveOptions(context: Context): ResolvedOptions { const raw = context.options?.[0]; const option: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { rootFiles: readStringArray(option.rootFiles, DEFAULT_ROOT_FILES), members: new Set(readStringArray(option.members, DEFAULT_MEMBERS)), @@ -144,13 +137,9 @@ function collectProvideBindings( const isSubmodule = (source: string) => EFFECT_EFFECT_MODULE.test(source); const policy = { valueOnly: true }; const namespaces = new Set( - [...bindings.namespaces] - .filter(([, name]) => name === EFFECT_NAMESPACE) - .map(([local]) => local), - ); - const pipes = new Set( - [...bindings.namespaces].filter(([, name]) => name === PIPE_NAMESPACE).map(([local]) => local), + [...bindings.namespaces].filter(([, name]) => name === EFFECT_NAMESPACE).map(([local]) => local), ); + const pipes = new Set([...bindings.namespaces].filter(([, name]) => name === PIPE_NAMESPACE).map(([local]) => local)); const barrels = collectRootNamespaces(program, isRoot, policy); for (const local of collectRootNamespaces(program, isSubmodule, policy)) namespaces.add(local); for (const [local, name] of collectNamedImports(program, isRoot, undefined, policy)) { @@ -158,12 +147,8 @@ function collectProvideBindings( else if (name === PIPE_NAMESPACE) pipes.add(local); } const direct = collectNamedImports(program, isSubmodule, undefined, policy); - const directRuns = new Set( - [...direct].filter(([, name]) => RUN_MEMBER.test(name)).map(([local]) => local), - ); - const directMembers = new Map( - [...direct].filter(([, name]) => !RUN_MEMBER.test(name) && members.has(name)), - ); + const directRuns = new Set([...direct].filter(([, name]) => RUN_MEMBER.test(name)).map(([local]) => local)); + const directMembers = new Map([...direct].filter(([, name]) => !RUN_MEMBER.test(name) && members.has(name))); for (const [local, name] of direct) { if (name === PIPE_NAMESPACE && !members.has(name)) pipes.add(local); } @@ -194,7 +179,11 @@ function isInTypePosition(node: ESTree.Node): boolean { } function memberName(node: ESTree.MemberExpression): string | null { - return staticMemberName(node, { templates: true, rawTemplates: true, singleQuasi: true }); + return staticMemberName(node, { + templates: true, + rawTemplates: true, + singleQuasi: true, + }); } /** @@ -250,11 +239,7 @@ function preservesPipeline(node: ESTree.Node): boolean { } /** `program.pipe(...)` or `pipe(program, ...)` — the only links allowed inside the run seam pipeline. */ -function isPipeCall( - context: Context, - call: ESTree.CallExpression, - bindings: ProvideBindings, -): boolean { +function isPipeCall(context: Context, call: ESTree.CallExpression, bindings: ProvideBindings): boolean { const callee = unwrap(call.callee); if (callee.type === 'MemberExpression') return memberName(callee) === 'pipe'; if (callee.type !== 'Identifier') return false; @@ -264,8 +249,7 @@ function isPipeCall( function isRunReference(context: Context, node: ESTree.Node, bindings: ProvideBindings): boolean { const callee = unwrap(node); - if (callee.type === 'Identifier') - return bindings.directRuns.has(callee.name) && resolvesToImport(context, callee); + if (callee.type === 'Identifier') return bindings.directRuns.has(callee.name) && resolvesToImport(context, callee); if (callee.type !== 'MemberExpression') return false; const member = resolveProvideMember(context, callee, bindings); return member !== null && /^run(?:Promise|Sync|Fork|Callback)(?:Exit)?$/u.test(member); @@ -278,14 +262,11 @@ function isModuleEvaluation(node: ESTree.Node): boolean { } function isProgramParent(parent: ESTree.Node | null): boolean { - if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') - parent = parent.parent; + if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') parent = parent.parent; return parent?.type === 'Program'; } -function entryFunctionIdentifier( - fn: ESTree.Node, -): Extract | null { +function entryFunctionIdentifier(fn: ESTree.Node): Extract | null { let parent = fn.parent; let id: Extract | null = null; if (fn.type === 'FunctionDeclaration') id = fn.id; @@ -296,10 +277,7 @@ function entryFunctionIdentifier( return isProgramParent(parent) ? id : null; } -function isSingleModuleCall( - context: Context, - id: Extract, -): boolean { +function isSingleModuleCall(context: Context, id: Extract): boolean { const variable = lookupVariable(context, id); if (variable === null) return false; const reads = variable.references.filter( @@ -343,8 +321,7 @@ function aliasRead(context: Context, node: ESTree.VariableDeclarator): ESTree.No if (node.id.type !== 'Identifier') return null; const variable = lookupVariable(context, node.id); const reads = variable?.references.filter((ref) => ref.isRead()) ?? []; - if (reads.length !== 1 || variable?.references.some((ref) => ref.isWrite() && !ref.init)) - return null; + if (reads.length !== 1 || variable?.references.some((ref) => ref.isWrite() && !ref.init)) return null; return reads[0]!.identifier; } @@ -362,11 +339,7 @@ function pipelineAlias( ); } -function hasTerminalRun( - context: Context, - call: ESTree.CallExpression, - bindings: ProvideBindings, -): boolean { +function hasTerminalRun(context: Context, call: ESTree.CallExpression, bindings: ProvideBindings): boolean { const terminal = call.arguments.at(-1); return terminal !== undefined && isRunReference(context, terminal, bindings); } @@ -381,8 +354,7 @@ function visitPipelineCall( const pipe = isPipeCall(context, call, bindings); if (state.inPipeline && pipe && hasTerminalRun(context, call, bindings)) state.sawRunSeam = true; if (Object.is(unwrap(call.callee), child) || Object.is(call.callee, child)) return; - if (state.inPipeline && !state.sawRunSeam && isRunReference(context, call.callee, bindings)) - state.sawRunSeam = true; + if (state.inPipeline && !state.sawRunSeam && isRunReference(context, call.callee, bindings)) state.sawRunSeam = true; else if (!pipe) state.inPipeline = false; } @@ -401,12 +373,7 @@ function visitPipelineNode( } else if (!preservesPipeline(current)) state.inPipeline = false; } -function isOuterRunSeam( - context: Context, - node: ESTree.Node, - bindings: ProvideBindings, - hops = 0, -): boolean { +function isOuterRunSeam(context: Context, node: ESTree.Node, bindings: ProvideBindings, hops = 0): boolean { if (hops > 8) return false; let child = node; const state: PipelineState = { inPipeline: true, sawRunSeam: false }; @@ -424,16 +391,8 @@ function isOuterRunSeam( return state.sawRunSeam; } -const REFERENCE_KEYS = new Set([ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'AccessorProperty', -]); -function isRuntimeImportReference( - context: Context, - node: Extract, -): boolean { +const REFERENCE_KEYS = new Set(['Property', 'PropertyDefinition', 'MethodDefinition', 'AccessorProperty']); +function isRuntimeImportReference(context: Context, node: Extract): boolean { return ( !nonReferencePosition(node, { keyParents: REFERENCE_KEYS }) && !isInTypePosition(node) && diff --git a/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts index 777967e95..231694ed4 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts @@ -62,27 +62,16 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - parentOf, - keyName, - unwrapNode, - skipWrappers as sharedSkipWrappers, - walk as walkAst, -} from '../shared/ast.ts'; +import { parentOf, keyName, unwrapNode, skipWrappers as sharedSkipWrappers, walk as walkAst } from '../shared/ast.ts'; import { resolveVariable, isTrackedReference as trackedReference } from '../shared/bindings.ts'; -import { importedName } from '../shared/imports.ts'; -import { - isNonReferencePosition, - isInTypePosition as inTypePosition, -} from '../shared/reference-positions.ts'; -import { nearestFunction, isTopLevel, programLevelFunctionName } from '../shared/script-entry.ts'; - import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; +import { importedName } from '../shared/imports.ts'; import { isScriptFile, isTestFile, matchesAny } from '../shared/paths.ts'; +import { isNonReferencePosition, isInTypePosition as inTypePosition } from '../shared/reference-positions.ts'; +import { nearestFunction, isTopLevel, programLevelFunctionName } from '../shared/script-entry.ts'; /** `runPromise`, `runPromiseExit`, `runSync`, `runSyncExit`, `runFork`, `runCallback`, `run*With`. */ const RUN_MEMBER = /^run[A-Z]/u; @@ -93,13 +82,7 @@ const PROMISE_CHAIN_METHODS = new Set(['then', 'catch']); const CLEANUP_CHAIN_METHOD = 'finally'; /** Loops turn one syntactic run site into one root fiber per iteration (S1). */ -const LOOP_NODES = new Set([ - 'DoWhileStatement', - 'ForInStatement', - 'ForOfStatement', - 'ForStatement', - 'WhileStatement', -]); +const LOOP_NODES = new Set(['DoWhileStatement', 'ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement']); /** Wrappers that do not change "is this expression the callee / object / init of its parent". */ const TRANSPARENT_PARENTS = new Set([ @@ -123,12 +106,7 @@ const TS_EXPRESSION_NODES = new Set([ 'TSTypeAssertion', ]); -const FUNCTION_LIKE = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', - 'StaticBlock', -]); +const FUNCTION_LIKE = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', 'StaticBlock']); /** Export syntax: a re-export mentions the entrypoint without ever invoking it. */ const EXPORT_REFERENCE_PARENTS = new Set([ @@ -181,15 +159,11 @@ function readOptions(raw: unknown): RuleOptions { return { allowPaths: strings(given.allowPaths, DEFAULTS.allowPaths), maxRunSites: - typeof given.maxRunSites === 'number' && - Number.isInteger(given.maxRunSites) && - given.maxRunSites >= 0 + typeof given.maxRunSites === 'number' && Number.isInteger(given.maxRunSites) && given.maxRunSites >= 0 ? given.maxRunSites : DEFAULTS.maxRunSites, reportPromiseChain: - typeof given.reportPromiseChain === 'boolean' - ? given.reportPromiseChain - : DEFAULTS.reportPromiseChain, + typeof given.reportPromiseChain === 'boolean' ? given.reportPromiseChain : DEFAULTS.reportPromiseChain, effectModules: strings(given.effectModules, DEFAULTS.effectModules), scriptGlobs: strings(given.scriptGlobs, DEFAULTS.scriptGlobs), scriptPaths: strings(given.scriptPaths, DEFAULTS.scriptPaths), @@ -207,12 +181,7 @@ function isInTypePosition(node: AnyNode): boolean { return inTypePosition(node, TS_EXPRESSION_NODES); } const DECLARATION_PARENTS = new Set(['LabeledStatement', 'BreakStatement', 'ContinueStatement']); -const DECLARATION_KEYS = new Set([ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'AccessorProperty', -]); +const DECLARATION_KEYS = new Set(['Property', 'PropertyDefinition', 'MethodDefinition', 'AccessorProperty']); function isDeclarationPosition(node: AnyNode): boolean { return isNonReferencePosition(node, { detached: false, @@ -222,7 +191,11 @@ function isDeclarationPosition(node: AnyNode): boolean { }); } function staticName(key: AnyNode, computed: boolean): string | null { - return keyName(key, computed, { templates: computed, rawTemplates: true, singleQuasi: true }); + return keyName(key, computed, { + templates: computed, + rawTemplates: true, + singleQuasi: true, + }); } function sameSpan(left: AnyNode, right: AnyNode): boolean { @@ -269,21 +242,17 @@ interface BindingState { readonly runLocals: Map; readonly effectModules: readonly string[]; } -function collectNamedRunner( - state: BindingState, - specifier: ESTree.ImportSpecifier, - source: string, -): void { +function collectNamedRunner(state: BindingState, specifier: ESTree.ImportSpecifier, source: string): void { if (specifier.importKind === 'type') return; const imported = importedName(specifier); const local = specifier.local; const submodule = source.split('/').at(-1) ?? ''; if (state.effectModules.includes(imported)) { - state.namespaces.set(local.name, { declaration: local, namespace: imported }); - } else if ( - RUN_MEMBER.test(imported) && - (source === 'effect' || state.effectModules.includes(submodule)) - ) { + state.namespaces.set(local.name, { + declaration: local, + namespace: imported, + }); + } else if (RUN_MEMBER.test(imported) && (source === 'effect' || state.effectModules.includes(submodule))) { state.runLocals.set(local.name, { declaration: local, member: imported, @@ -307,11 +276,7 @@ function collectRunnerImport(state: BindingState, statement: ESTree.ImportDeclar } } } -function collectRunBindings( - context: Context, - program: ESTree.Program, - effectModules: readonly string[], -): RunBindings { +function collectRunBindings(context: Context, program: ESTree.Program, effectModules: readonly string[]): RunBindings { const state: BindingState = { context, effectModules, @@ -331,10 +296,7 @@ function collectRunBindings( }; } type AliasKind = NamespaceBinding | 'package'; -function packageNamespace( - state: BindingState, - member: ESTree.MemberExpression, -): NamespaceBinding | null { +function packageNamespace(state: BindingState, member: ESTree.MemberExpression): NamespaceBinding | null { const name = staticName(member.property, member.computed); if (name === null || !state.effectModules.includes(name)) return null; const object = unwrapExpression(member.object); @@ -348,26 +310,22 @@ function aliasKind(state: BindingState, init: AnyNode): AliasKind | null { if (init.type === 'MemberExpression') return packageNamespace(state, init); if (init.type !== 'Identifier') return null; const known = state.namespaces.get(init.name); - if (known !== undefined && isTrackedReference(state.context, init, known.declaration)) - return known; + if (known !== undefined && isTrackedReference(state.context, init, known.declaration)) return known; const declaration = state.packages.get(init.name); - return declaration !== undefined && isTrackedReference(state.context, init, declaration) - ? 'package' - : null; + return declaration !== undefined && isTrackedReference(state.context, init, declaration) ? 'package' : null; } function addBinding(map: Map, name: string, value: T): boolean { if (map.has(name)) return false; map.set(name, value); return true; } -function bindAlias( - state: BindingState, - target: ESTree.BindingIdentifier, - kind: AliasKind, -): boolean { +function bindAlias(state: BindingState, target: ESTree.BindingIdentifier, kind: AliasKind): boolean { return kind === 'package' ? addBinding(state.packages, target.name, target) - : addBinding(state.namespaces, target.name, { declaration: target, namespace: kind.namespace }); + : addBinding(state.namespaces, target.name, { + declaration: target, + namespace: kind.namespace, + }); } function bindProperty( state: BindingState, @@ -381,7 +339,10 @@ function bindProperty( if (kind === 'package') { return ( state.effectModules.includes(key) && - addBinding(state.namespaces, value.name, { declaration: value, namespace: key }) + addBinding(state.namespaces, value.name, { + declaration: value, + namespace: key, + }) ); } return ( @@ -393,22 +354,11 @@ function bindProperty( }) ); } -function runnerAlias( - state: BindingState, - init: AnyNode, -): { member: string; namespace: string } | null { +function runnerAlias(state: BindingState, init: AnyNode): { member: string; namespace: string } | null { if (init.type !== 'MemberExpression') return null; - const namespace = namespaceOfObject( - state.context, - init, - state.namespaces, - state.packages, - state.effectModules, - ); + const namespace = namespaceOfObject(state.context, init, state.namespaces, state.packages, state.effectModules); const member = staticName(init.property, init.computed); - return namespace !== null && member !== null && RUN_MEMBER.test(member) - ? { member, namespace } - : null; + return namespace !== null && member !== null && RUN_MEMBER.test(member) ? { member, namespace } : null; } function propagateDeclarator(state: BindingState, declarator: ESTree.VariableDeclarator): boolean { if (declarator.init == null) return false; @@ -418,7 +368,10 @@ function propagateDeclarator(state: BindingState, declarator: ESTree.VariableDec if (runner !== null) { return ( target.type === 'Identifier' && - addBinding(state.runLocals, target.name, { declaration: target, ...runner }) + addBinding(state.runLocals, target.name, { + declaration: target, + ...runner, + }) ); } const kind = aliasKind(state, init); @@ -462,9 +415,7 @@ function namespaceOfObject( const object = unwrapExpression(node.object as unknown as AnyNode); if (object.type === 'Identifier') { const binding = namespaces.get((object as ESTree.IdentifierReference).name); - return binding !== undefined && isTrackedReference(context, object, binding.declaration) - ? binding.namespace - : null; + return binding !== undefined && isTrackedReference(context, object, binding.declaration) ? binding.namespace : null; } if (object.type !== 'MemberExpression') return null; const inner = object as ESTree.MemberExpression; @@ -494,8 +445,7 @@ function runMember( const tracked = bindings.namespaces.get((object as ESTree.IdentifierReference).name); if (tracked === undefined) return null; // Cross-check with the shared import tracker for the plain `Effect.runPromise` shape. - const namespace = - effectMember(node as unknown as ESTree.Node, shared)?.namespace ?? tracked.namespace; + const namespace = effectMember(node as unknown as ESTree.Node, shared)?.namespace ?? tracked.namespace; if (!effectModules.includes(namespace)) return null; if (!isTrackedReference(context, object, tracked.declaration)) return null; return { member, namespace }; @@ -525,9 +475,7 @@ function packageRunMember( /** Return a call only when the wrapped expression is its callee. */ function invocation(node: AnyNode): ESTree.CallExpression | null { const wrapped = skipWrappers(node); - return wrapped.parent?.type === 'CallExpression' && wrapped.parent.callee === wrapped.node - ? wrapped.parent - : null; + return wrapped.parent?.type === 'CallExpression' && wrapped.parent.callee === wrapped.node ? wrapped.parent : null; } function isTopLevelImmediatelyInvoked(fn: AnyNode): boolean { const call = invocation(fn); @@ -599,11 +547,7 @@ function switchHasNoFallthrough(node: ESTree.SwitchStatement): boolean { return true; if (statement.type === 'BlockStatement') return terminates(statement.body.at(-1)); if (statement.type === 'IfStatement') - return ( - terminates(statement.consequent) && - statement.alternate !== null && - terminates(statement.alternate) - ); + return terminates(statement.consequent) && statement.alternate !== null && terminates(statement.alternate); return false; }; return node.cases @@ -627,11 +571,12 @@ function decisionPath(site: AnyNode): readonly Decision[] { let current = parentOf(child); while (current !== null) { if (current.type === 'IfStatement' || current.type === 'ConditionalExpression') { - const branching = current as unknown as { consequent?: unknown; alternate?: unknown }; - if (branching.consequent === child) - path.push({ branch: 'then', id: (current as ESTree.Span).start }); - else if (branching.alternate === child) - path.push({ branch: 'else', id: (current as ESTree.Span).start }); + const branching = current as unknown as { + consequent?: unknown; + alternate?: unknown; + }; + if (branching.consequent === child) path.push({ branch: 'then', id: (current as ESTree.Span).start }); + else if (branching.alternate === child) path.push({ branch: 'else', id: (current as ESTree.Span).start }); } else if (current.type === 'SwitchCase') { const parent = parentOf(current); if ( @@ -675,24 +620,17 @@ function promiseChainMethod(site: AnyNode): string | null { (member as ESTree.MemberExpression).property as unknown as AnyNode, (member as ESTree.MemberExpression).computed, ); - if (method === null || (!PROMISE_CHAIN_METHODS.has(method) && method !== CLEANUP_CHAIN_METHOD)) - return null; + if (method === null || (!PROMISE_CHAIN_METHODS.has(method) && method !== CLEANUP_CHAIN_METHOD)) return null; return invocation(member) !== null ? method : null; } function isAliasInitializer(node: AnyNode): boolean { const wrapped = skipWrappers(node); const parent = wrapped.parent; - return ( - parent?.type === 'VariableDeclarator' && - parent.init === wrapped.node && - parent.id.type === 'Identifier' - ); + return parent?.type === 'VariableDeclarator' && parent.init === wrapped.node && parent.id.type === 'Identifier'; } function chargeSlot(charged: Array>, path: readonly Decision[]): number { - const slot = charged.findIndex((alternatives) => - alternatives.every((other) => mutuallyExclusive(other, path)), - ); + const slot = charged.findIndex((alternatives) => alternatives.every((other) => mutuallyExclusive(other, path))); if (slot !== -1) { charged[slot]?.push(path); return slot; @@ -753,8 +691,7 @@ export const rule = defineRule({ effectModules: { type: 'array', items: { type: 'string' }, - description: - 'Effect namespaces whose run* members start a root fiber (default: ["Effect"]).', + description: 'Effect namespaces whose run* members start a root fiber (default: ["Effect"]).', }, scriptGlobs: { type: 'array', @@ -839,9 +776,7 @@ export const rule = defineRule({ }, 'Program:exit'() { if (sites.length === 0) return; - const ordered = [...sites].sort( - (left, right) => left.start - right.start || right.end - left.end, - ); + const ordered = [...sites].sort((left, right) => left.start - right.start || right.end - left.end); // Skip run sites nested inside another run site's expression; the outer one is the report. const outer = ordered.filter( (site) => @@ -874,7 +809,11 @@ export const rule = defineRule({ } const method = options.reportPromiseChain ? promiseChainMethod(site.node) : null; if (method === CLEANUP_CHAIN_METHOD) { - context.report({ node: site.node, messageId: 'promiseFinallyOnRun', data }); + context.report({ + node: site.node, + messageId: 'promiseFinallyOnRun', + data, + }); } else if (method !== null) { context.report({ node: site.node, diff --git a/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts b/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts index 49865f2d5..6901f6b97 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts @@ -1,4 +1,3 @@ -import { collectNamedImports } from '../shared/imports.ts'; /** * effect-native/no-effect-run-in-tests * @@ -51,11 +50,11 @@ import { collectNamedImports } from '../shared/imports.ts'; * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; +import { collectNamedImports } from '../shared/imports.ts'; import { globToRegExp, isTestFile, matchesAny } from '../shared/paths.ts'; /** `run`, `runPromise`, `runSyncExit`, `runPromiseWith`, … but not `runtime`. */ @@ -78,11 +77,7 @@ const ERASED_WRAPPERS = new Set([ const MAX_ALIAS_HOPS = 8; /** D-tier: Promise adapters forced by Playwright and other browser drivers. */ -const DEFAULT_IGNORE_PATHS: readonly string[] = [ - '**/tests/e2e/**', - '**/*.e2e.*', - '**/playwright/**', -]; +const DEFAULT_IGNORE_PATHS: readonly string[] = ['**/tests/e2e/**', '**/*.e2e.*', '**/playwright/**']; const DEFAULT_EFFECT_MODULES: readonly string[] = ['Effect']; @@ -160,17 +155,11 @@ function moduleExportName(node: ESTree.Node): string | null { * Supplement `collectEffectBindings` with namespaces re-exported by non-`effect` barrels, so * `import { Effect } from "@modern-js/plugin-bff/effect-edge"` is tracked exactly like `from "effect"`. */ -function collectBarrelBindings( - program: ESTree.Program, - sources: readonly string[], -): Map { +function collectBarrelBindings(program: ESTree.Program, sources: readonly string[]): Map { const patterns = sources.map(globToRegExp); - return collectNamedImports( - program, - (source) => patterns.some((pattern) => pattern.test(source)), - undefined, - { valueOnly: true }, - ); + return collectNamedImports(program, (source) => patterns.some((pattern) => pattern.test(source)), undefined, { + valueOnly: true, + }); } /** @@ -251,7 +240,10 @@ export const rule = defineRule({ if (matchesAny(filename, options.ignorePaths)) return {}; if (!isTestFile(filename) && !matchesAny(filename, options.testPaths)) return {}; - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; + let bindings: EffectBindings = { + namespaces: new Map(), + importsEffect: false, + }; /** `import * as X from "effect"` — `X.Effect` is the Effect namespace. */ let rootNamespaces = new Set(); /** `const Effect = await import("effect/Effect")` — local name → submodule name. */ @@ -288,8 +280,7 @@ export const rule = defineRule({ const variable = lookupVariable(node, node.name); return ( variable?.defs.some( - (definition) => - definition.name.start === declaration.start && definition.name.end === declaration.end, + (definition) => definition.name.start === declaration.start && definition.name.end === declaration.end, ) ?? false ); } @@ -345,9 +336,7 @@ export const rule = defineRule({ return alias === null ? false : isEffectNamespace(alias, hops + 1); } - function isImportedEffectNamespace( - target: Extract, - ): boolean { + function isImportedEffectNamespace(target: Extract): boolean { const dynamic = dynamicNamespaces.get(target.name); if ( dynamic !== undefined && @@ -402,7 +391,10 @@ export const rule = defineRule({ const key = staticKey(property.key, property.computed); if (key === null || !options.effectModules.includes(key)) continue; if (property.value.type !== 'Identifier') continue; - dynamicNamespaces.set(property.value.name, { namespace: key, declaration: property.value }); + dynamicNamespaces.set(property.value.name, { + namespace: key, + declaration: property.value, + }); } } @@ -410,7 +402,10 @@ export const rule = defineRule({ const submodule = SUBMODULE_SOURCE.exec(source)?.[1]; if (submodule !== undefined && options.effectModules.includes(submodule)) { if (id.type === 'Identifier') - dynamicNamespaces.set(id.name, { namespace: submodule, declaration: id }); + dynamicNamespaces.set(id.name, { + namespace: submodule, + declaration: id, + }); else if (id.type === 'ObjectPattern') collectRunProperties(id, dynamicSites); return; } @@ -477,10 +472,7 @@ export const rule = defineRule({ if (member === null) return; const parent = node.parent; const called = - parent !== null && - parent !== undefined && - parent.type === 'CallExpression' && - parent.callee === node; + parent !== null && parent !== undefined && parent.type === 'CallExpression' && parent.callee === node; (called ? callSites : referenceSites).push({ node, member }); }, diff --git a/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts b/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts index 8d7e4f7e4..5245f02fc 100644 --- a/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts +++ b/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts @@ -1,4 +1,3 @@ -import { snippet } from '../shared/reporting.ts'; /** * effect-native/no-environment-record-type * @@ -74,23 +73,18 @@ import { snippet } from '../shared/reporting.ts'; * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { includesRuleFile } from '../shared/paths.ts'; import { parentOf } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; import { stringList } from '../shared/options.ts'; +import { includesRuleFile } from '../shared/paths.ts'; +import { snippet } from '../shared/reporting.ts'; type AnyNode = ESTree.Node; -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; /** Type constructors that spell "string-keyed dictionary". */ const RECORD_NAMES = new Set(['Record', 'ReadonlyRecord', 'Record.ReadonlyRecord']); @@ -126,8 +120,7 @@ function readOptions(raw: unknown): RuleOptions { const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, + ignoreTestFiles: typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, }; } @@ -187,8 +180,7 @@ function isOptionalStringUnion(node: AnyNode): boolean { continue; } if (member.type === 'TSStringKeyword') hasString = true; - else if (member.type === 'TSUndefinedKeyword' || member.type === 'TSNullKeyword') - hasAbsence = true; + else if (member.type === 'TSUndefinedKeyword' || member.type === 'TSNullKeyword') hasAbsence = true; else return false; } return pending.length === 0 && hasString && hasAbsence; @@ -205,8 +197,7 @@ function isInsidePartial(node: AnyNode): boolean { current = parent; continue; } - if (!['TSTypeReference', 'TSInterfaceHeritage', 'TSClassImplements'].includes(parent.type)) - return false; + if (!['TSTypeReference', 'TSInterfaceHeritage', 'TSClassImplements'].includes(parent.type)) return false; const name = typeReferenceName(parent); if (name === null || !TRANSPARENT_WRAPPERS.has(name)) return false; if (name === 'Partial') return true; @@ -221,11 +212,7 @@ function singleImport(variable: Variable) { return definition?.type === 'ImportBinding' ? definition.node : null; } -function importedRecordName( - variable: Variable, - imported: string | undefined, - rest: readonly string[], -): string | null { +function importedRecordName(variable: Variable, imported: string | undefined, rest: readonly string[]): string | null { const specifier = singleImport(variable); if (!specifier) return null; const declaration = parentOf(specifier) as ESTree.ImportDeclaration | null; @@ -237,9 +224,7 @@ function importedRecordName( rest.length === 0 ) return 'ReadonlyRecord'; - return imported === 'Record' && rest.join('.') === 'ReadonlyRecord' - ? 'Record.ReadonlyRecord' - : null; + return imported === 'Record' && rest.join('.') === 'ReadonlyRecord' ? 'Record.ReadonlyRecord' : null; } function isImportedEnvQuery(variable: Variable, segments: readonly string[]): boolean { @@ -258,10 +243,7 @@ function isImportedEnvQuery(variable: Variable, segments: readonly string[]): bo function isGlobalEnvQuery(segments: readonly string[]): boolean { if (segments.length === 2) return ENV_HOSTS.has(segments[0]) && segments[1] === 'env'; return ( - segments.length === 3 && - CONTAINER_GLOBALS.has(segments[0]) && - ENV_HOSTS.has(segments[1]) && - segments[2] === 'env' + segments.length === 3 && CONTAINER_GLOBALS.has(segments[0]) && ENV_HOSTS.has(segments[1]) && segments[2] === 'env' ); } @@ -281,20 +263,14 @@ function isWithinConstraint(node: AnyNode): boolean { } function isStringValue(node: AnyNode | undefined): boolean { - return ( - node !== undefined && - (unwrapParens(node).type === 'TSStringKeyword' || isOptionalStringUnion(node)) - ); + return node !== undefined && (unwrapParens(node).type === 'TSStringKeyword' || isOptionalStringUnion(node)); } function isEnvironmentRecord(node: AnyNode): boolean { const args = typeArgumentsOf(node); if (args.length !== 2 || unwrapParens(args[0]).type !== 'TSStringKeyword') return false; const value = args[1]; - return ( - isOptionalStringUnion(value) || - (unwrapParens(value).type === 'TSStringKeyword' && isInsidePartial(node)) - ); + return isOptionalStringUnion(value) || (unwrapParens(value).type === 'TSStringKeyword' && isInsidePartial(node)); } /** Effect-native rule: configuration is a Schema decoded through Config and injected as a service. */ @@ -335,22 +311,24 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, }, }, ], defaultOptions: [ - { allowPaths: [], ignoreTestFiles: true, includePaths: [...DEFAULT_INCLUDE_PATHS] }, + { + allowPaths: [], + ignoreTestFiles: true, + includePaths: [...DEFAULT_INCLUDE_PATHS], + }, ], }, create(context) { const options = readOptions(context.options[0]); if (!includesRuleFile(context.filename, options)) return {}; - const printed = (node: AnyNode): string => - snippet(context.sourceCode.getText(node), 80, 77, '...'); + const printed = (node: AnyNode): string => snippet(context.sourceCode.getText(node), 80, 77, '...'); const report = (node: AnyNode, messageId: string): void => { context.report({ @@ -375,9 +353,7 @@ export const rule = defineRule({ if (!name) return false; const segments = (indexed ? `${name}.env` : name).split('.'); const variable = resolveVariable(context, segments[0], expression); - return variable && variable.defs.length > 0 - ? isImportedEnvQuery(variable, segments) - : isGlobalEnvQuery(segments); + return variable && variable.defs.length > 0 ? isImportedEnvQuery(variable, segments) : isGlobalEnvQuery(segments); }; const inspectReference = (node: AnyNode): void => { // A generic utility constraint is not a declaration of configuration authority. @@ -398,17 +374,10 @@ export const rule = defineRule({ TSMappedType(node) { const constraint = node.constraint; const value = node.typeAnnotation; - if ( - !constraint || - unwrapParens(constraint).type !== 'TSStringKeyword' || - !value || - node.nameType - ) - return; + if (!constraint || unwrapParens(constraint).type !== 'TSStringKeyword' || !value || node.nameType) return; if ( isOptionalStringUnion(value) || - ((node.optional === true || node.optional === '+') && - unwrapParens(value).type === 'TSStringKeyword') + ((node.optional === true || node.optional === '+') && unwrapParens(value).type === 'TSStringKeyword') ) report(node, 'environmentIndexSignature'); }, @@ -429,11 +398,9 @@ export const rule = defineRule({ TSIndexSignature(node) { const parameter = (node.parameters as readonly AnyNode[])[0]; if (parameter === undefined) return; - const keyType = (parameter as { typeAnnotation?: { typeAnnotation?: AnyNode } }) - .typeAnnotation?.typeAnnotation; + const keyType = (parameter as { typeAnnotation?: { typeAnnotation?: AnyNode } }).typeAnnotation?.typeAnnotation; if (keyType === undefined || unwrapParens(keyType).type !== 'TSStringKeyword') return; - const valueType = (node.typeAnnotation as { typeAnnotation?: AnyNode } | null) - ?.typeAnnotation; + const valueType = (node.typeAnnotation as { typeAnnotation?: AnyNode } | null)?.typeAnnotation; if (valueType === undefined || valueType === null) return; if (!isOptionalStringUnion(valueType)) return; report(node as unknown as AnyNode, 'environmentIndexSignature'); diff --git a/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts b/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts index b3cf7a2e9..78605b461 100644 --- a/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts +++ b/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A4** — "Rebuild the error system around typed channels and contract-owned Problem * Details" ("`Effect.mapError(() => oneGenericError)` discarding original failures", "Preserve original @@ -52,14 +51,14 @@ import { optionRecord } from '../shared/options.ts'; * rule only reports; it never fixes or suggests, and no source file is edited to satisfy it. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { effectOrigin } from '../shared/effect-identity.ts'; import { isNode, keyName, memberName, EXPRESSION_WRAPPERS, skipWrappers } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; @@ -69,13 +68,7 @@ const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?Effect$/u; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; -const DEFAULT_IGNORE = [ - '**/dist/**', - '**/build/**', - '**/node_modules/**', - 'tools/**', - 'scripts/**', -]; +const DEFAULT_IGNORE = ['**/dist/**', '**/build/**', '**/node_modules/**', 'tools/**', 'scripts/**']; /** Members whose error callback replaces or absorbs the failure channel wholesale. */ const DEFAULT_MEMBERS = [ @@ -145,21 +138,15 @@ interface EffectLocals { readonly direct: ReadonlyMap; } -function collectEffectLocals( - program: ESTree.Program, - bindings: EffectBindings, - options: RuleOptions, -): EffectLocals { +function collectEffectLocals(program: ESTree.Program, bindings: EffectBindings, options: RuleOptions): EffectLocals { const submodule = (source: string) => EFFECT_SUBMODULE.test(source); const root = (source: string) => - !submodule(source) && - (source === EFFECT_ROOT_MODULE || matchesGlobs(source, options.effectModules)); + !submodule(source) && (source === EFFECT_ROOT_MODULE || matchesGlobs(source, options.effectModules)); const namespace = collectRootNamespaces(program, submodule); for (const [local, exported] of bindings.namespaces) { if (exported === EFFECT_NAMESPACE) namespace.add(local); } - for (const local of collectNamedImports(program, root, new Set([EFFECT_NAMESPACE])).keys()) - namespace.add(local); + for (const local of collectNamedImports(program, root, new Set([EFFECT_NAMESPACE])).keys()) namespace.add(local); return { namespace, barrel: collectRootNamespaces(program, root), @@ -184,7 +171,9 @@ function selectedProperty(property: unknown, key: string): { value: unknown } | if (!isNode(property)) return null; if (property.type === 'SpreadElement') return { value: null }; if (property.type !== 'Property' || !isNode(property.key)) return null; - const name = keyName(property.key, property.computed === true, { templates: true }); + const name = keyName(property.key, property.computed === true, { + templates: true, + }); if (name === null && property.computed === true) return { value: null }; return name === key ? { value: property.kind === 'init' ? property.value : null } : null; } @@ -204,11 +193,7 @@ function objectProperty(object: AnyNode, key: string): unknown { * puts it at argument 0; data-first (`Effect.mapError(self, f)`) at argument 1. Members listed in * `OPTION_PROPERTY` carry it on an options object in either position. */ -function errorCallback( - context: Context, - member: string, - argumentsList: readonly unknown[], -): AnyNode | null { +function errorCallback(context: Context, member: string, argumentsList: readonly unknown[]): AnyNode | null { const property = OPTION_PROPERTY.get(member); if (property !== undefined) { for (const argument of argumentsList) { @@ -253,8 +238,7 @@ function classifyFunction(context: Context, fn: AnyNode): Classification { if (pattern || name === null) return 'uses'; const declared = context.sourceCode.getDeclaredVariables(fn as unknown as ESTree.Node); const variable = declared.find( - (entry) => - entry.name === name && entry.defs.some((definition) => definition.type === 'Parameter'), + (entry) => entry.name === name && entry.defs.some((definition) => definition.type === 'Parameter'), ); if (variable === undefined) return 'unknown'; return variable.references.some((reference) => reference.isRead()) ? 'uses' : 'unusedParameter'; @@ -308,17 +292,13 @@ function mutatesOptionObject(identifier: ESTree.Node): boolean { function stableVariable(context: Context, identifier: AnyNode): Variable | null { const variable = lookupVariable(context, identifier as unknown as ESTree.Node); if (!variable || variable.defs.length !== 1) return null; - return variable.references.some((reference) => reference.isWrite() && !reference.init) - ? null - : variable; + return variable.references.some((reference) => reference.isWrite() && !reference.init) ? null : variable; } function constDeclaration(variable: Variable): ESTree.VariableDeclarator | null { const declaration = variable.defs[0]?.node; if (declaration?.type !== 'VariableDeclarator') return null; - return declaration.parent?.type === 'VariableDeclaration' && declaration.parent.kind === 'const' - ? declaration - : null; + return declaration.parent?.type === 'VariableDeclaration' && declaration.parent.kind === 'const' ? declaration : null; } function resolveValue(context: Context, input: unknown, depth = 0): AnyNode | null { @@ -330,8 +310,7 @@ function resolveValue(context: Context, input: unknown, depth = 0): AnyNode | nu if (declaration === null) return node; // A const binding does not freeze its option properties. Visible writes/method calls // invalidate this local snapshot; arbitrary escaped-object mutation is not modeled. - if (variable.references.some((reference) => mutatesOptionObject(reference.identifier))) - return node; + if (variable.references.some((reference) => mutatesOptionObject(reference.identifier))) return node; return resolveValue(context, declaration.init, depth + 1); } @@ -348,20 +327,13 @@ function reportFunction( if (classification === 'zeroArity') { context.report({ node, - messageId: - member === 'orElseFail' - ? 'discardingLazyFailure' - : indirect - ? 'indirectZeroArity' - : 'zeroArity', + messageId: member === 'orElseFail' ? 'discardingLazyFailure' : indirect ? 'indirectZeroArity' : 'zeroArity', data: { member, name }, }); return; } if (classification !== 'unusedParameter') return; - const parameter = firstParameterName( - Array.isArray(definition.params) ? definition.params : [], - ).name; + const parameter = firstParameterName(Array.isArray(definition.params) ? definition.params : []).name; context.report({ node, messageId: indirect ? 'indirectUnusedParameter' : 'unusedParameter', @@ -369,12 +341,7 @@ function reportFunction( }); } -function reportCallback( - context: Context, - callback: AnyNode, - member: string, - flagMemberReferences: boolean, -): void { +function reportCallback(context: Context, callback: AnyNode, member: string, flagMemberReferences: boolean): void { if (isFunctionNode(callback)) { reportFunction(context, callback, callback, member, false); return; @@ -468,8 +435,7 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); const locals = collectEffectLocals(program, bindings, options); - if (locals.namespace.size === 0 && locals.barrel.size === 0 && locals.direct.size === 0) - return {}; + if (locals.namespace.size === 0 && locals.barrel.size === 0 && locals.direct.size === 0) return {}; return { CallExpression(node: ESTree.CallExpression): void { diff --git a/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts b/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts index 52094d395..b51cdc9c6 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts @@ -51,7 +51,6 @@ * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope } from '@oxlint/plugins'; import { globToRegExp, isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; @@ -74,12 +73,7 @@ const DEFAULT_SERVER_MODULES: readonly string[] = [ const DEFAULT_SERVER_FACTORIES: readonly string[] = ['createServer', 'createSecureServer']; /** D-tier: browser drivers legitimately drive a real server they own. */ -const DEFAULT_IGNORE_PATHS: readonly string[] = [ - '**/tests/e2e/**', - '**/*.e2e.*', - '**/e2e/**', - '**/playwright/**', -]; +const DEFAULT_IGNORE_PATHS: readonly string[] = ['**/tests/e2e/**', '**/*.e2e.*', '**/e2e/**', '**/playwright/**']; interface RuleOptions { readonly serverModules?: readonly string[]; @@ -223,13 +217,11 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const filename = normalisePath(context.filename).replace(FIXTURE_PREFIX, ''); - if (matchesAny(filename, options.allowPaths) || matchesAny(filename, options.ignorePaths)) - return {}; + if (matchesAny(filename, options.allowPaths) || matchesAny(filename, options.ignorePaths)) return {}; if (!isTestFile(filename) && !matchesAny(filename, options.testPaths)) return {}; const modulePatterns = options.serverModules.map(globToRegExp); - const isServerModule = (source: string): boolean => - modulePatterns.some((pattern) => pattern.test(source)); + const isServerModule = (source: string): boolean => modulePatterns.some((pattern) => pattern.test(source)); type Variable = Scope['variables'][number]; function variable(node: Extract): Variable | undefined { @@ -244,12 +236,7 @@ export const rule = defineRule({ const writes = new Map(); const calls: Array = []; const reports: Report[] = []; - const factoryNames = new Set([ - ...options.serverFactories, - 'Server', - 'Http2Server', - 'Http2SecureServer', - ]); + const factoryNames = new Set([...options.serverFactories, 'Server', 'Http2Server', 'Http2SecureServer']); // Provenance is local and scope-resolved, not type inference. Unknown writes invalidate // aliases; object fields, function returns and cross-file re-exports are not followed. function isTypeSpecifier(spec: ESTree.Node): boolean { @@ -258,8 +245,7 @@ export const rule = defineRule({ function importOrigin(def: Variable['defs'][number]): string | null { const spec = def.node; const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') - return null; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; if (isTypeSpecifier(spec)) return null; const source = declaration.source.value; const name = spec.type === 'ImportSpecifier' ? staticKey(spec.imported, false) : '*'; @@ -274,11 +260,7 @@ export const rule = defineRule({ function factoryOrigin(key: string | null): string | null { return key !== null && factoryNames.has(key) ? 'factory' : null; } - function destructuredOrigin( - pattern: ESTree.ObjectPattern, - name: string, - value: string | null, - ): string | null { + function destructuredOrigin(pattern: ESTree.ObjectPattern, name: string, value: string | null): string | null { const property = pattern.properties.find( (p) => p.type === 'Property' && p.value.type === 'Identifier' && p.value.name === name, ); @@ -296,18 +278,13 @@ export const rule = defineRule({ if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; if (def.node.init === null) continue; let value = origin(def.node.init, next); - if (def.node.id.type === 'ObjectPattern') - value = destructuredOrigin(def.node.id, node.name, value); + if (def.node.id.type === 'ObjectPattern') value = destructuredOrigin(def.node.id, node.name, value); if (value === null) return null; values.push(value); } return writtenOrigin(binding, values, next); } - function writtenOrigin( - binding: Variable, - values: string[], - next: Set, - ): string | null { + function writtenOrigin(binding: Variable, values: string[], next: Set): string | null { for (const write of writes.get(binding) ?? []) { const value = origin(write, next); if (value === null) return null; @@ -320,8 +297,7 @@ export const rule = defineRule({ next: Set, ): string | null { const binding = variable(node); - if (binding === undefined || binding.defs.length === 0) - return node.name === 'require' ? 'require' : null; + if (binding === undefined || binding.defs.length === 0) return node.name === 'require' ? 'require' : null; return variableOrigin(node, binding, next); } function memberOrigin(node: ESTree.MemberExpression, next: Set): string | null { @@ -334,15 +310,11 @@ export const rule = defineRule({ const source = literalSource(node); return source !== null && isServerModule(source) ? 'namespace' : null; } - function callOrigin( - node: ESTree.CallExpression | ESTree.NewExpression, - next: Set, - ): string | null { + function callOrigin(node: ESTree.CallExpression | ESTree.NewExpression, next: Set): string | null { const callee = origin(node.callee, next); if (callee === 'factory') return 'server'; if (callee === 'createRequire') return 'require'; - if (callee === 'require' && node.arguments[0] !== undefined) - return sourceOrigin(unwrap(node.arguments[0])); + if (callee === 'require' && node.arguments[0] !== undefined) return sourceOrigin(unwrap(node.arguments[0])); return null; } function origin(input: ESTree.Node, seen = new Set()): string | null { @@ -372,7 +344,11 @@ export const rule = defineRule({ const member = staticKey(callee.property as ESTree.Node, callee.computed); const object = unwrap(callee.object as ESTree.Node); if (member === 'listen' && object.type === 'Identifier' && origin(object) === 'server') { - reports.push({ node, messageId: 'serverListen', data: { name: object.name } }); + reports.push({ + node, + messageId: 'serverListen', + data: { name: object.name }, + }); } if ( options.includeFetch && @@ -406,7 +382,11 @@ export const rule = defineRule({ if (identity === 'require' && node.arguments[0] !== undefined) { const source = literalSource(unwrap(node.arguments[0] as ESTree.Node)); if (source !== null && isServerModule(source)) - reports.push({ node, messageId: 'dynamicServerModuleImport', data: { source } }); + reports.push({ + node, + messageId: 'dynamicServerModuleImport', + data: { source }, + }); } if (callee.type === 'MemberExpression') { inspectMemberCall(node, callee); @@ -415,14 +395,13 @@ export const rule = defineRule({ } } function exported(node: ESTree.ExportNamedDeclaration | ESTree.ExportAllDeclaration): void { - if (node.source === null || node.exportKind === 'type' || !isServerModule(node.source.value)) - return; - if ( - node.type === 'ExportNamedDeclaration' && - node.specifiers.every((spec) => spec.exportKind === 'type') - ) - return; - reports.push({ node, messageId: 'serverModuleImport', data: { source: node.source.value } }); + if (node.source === null || node.exportKind === 'type' || !isServerModule(node.source.value)) return; + if (node.type === 'ExportNamedDeclaration' && node.specifiers.every((spec) => spec.exportKind === 'type')) return; + reports.push({ + node, + messageId: 'serverModuleImport', + data: { source: node.source.value }, + }); } return { ImportDeclaration(node) { @@ -451,7 +430,11 @@ export const rule = defineRule({ ImportExpression(node) { const source = literalSource(node.source as ESTree.Node); if (source !== null && isServerModule(source)) - reports.push({ node, messageId: 'dynamicServerModuleImport', data: { source } }); + reports.push({ + node, + messageId: 'dynamicServerModuleImport', + data: { source }, + }); }, AssignmentExpression(node) { if (node.left.type !== 'Identifier') return; @@ -469,8 +452,7 @@ export const rule = defineRule({ }, 'Program:exit'() { for (const node of calls) inspectCall(node); - for (const report of reports.sort((a, b) => a.node.start - b.node.start)) - context.report(report); + for (const report of reports.sort((a, b) => a.node.start - b.node.start)) context.report(report); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts b/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts index 278ad2d2d..e5c0bdb26 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-hand-built-problem-details * @@ -72,11 +71,11 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. The existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; import { unwrapNode } from '../shared/ast.ts'; import { effectOrigin } from '../shared/effect-identity.ts'; +import { optionRecord } from '../shared/options.ts'; import { compile, stringArray } from '../shared/options.ts'; import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; @@ -137,10 +136,7 @@ interface RuleOptions { readonly reportRawDriverMessages: boolean; } -function statusRange( - value: unknown, - fallback: readonly [number, number], -): readonly [number, number] { +function statusRange(value: unknown, fallback: readonly [number, number]): readonly [number, number] { if (!Array.isArray(value) || value.length !== 2) return fallback; const [low, high] = value; if (typeof low !== 'number' || typeof high !== 'number') return fallback; @@ -209,15 +205,13 @@ function isUriLike(node: ESTree.Node | null): boolean { const text = stringLiteralValue(node); if (text !== null) return /^(?:https?:\/\/|urn:|about:blank$|\/|#)/u.test(text); return ( - node.type === 'TemplateLiteral' && - node.quasis.some((quasi) => /(?:problems?\/|https?:\/\/)/u.test(quasi.value.raw)) + node.type === 'TemplateLiteral' && node.quasis.some((quasi) => /(?:problems?\/|https?:\/\/)/u.test(quasi.value.raw)) ); } function integerLiteral(node: ESTree.Node | null): number | null { if (node === null) return null; - if (node.type === 'Literal' && typeof node.value === 'number' && Number.isInteger(node.value)) - return node.value; + if (node.type === 'Literal' && typeof node.value === 'number' && Number.isInteger(node.value)) return node.value; return null; } @@ -248,24 +242,14 @@ const EXEMPT_WALK_BOUNDARIES: ReadonlySet = new Set([ 'BlockStatement', ]); -function isSchemaArgument( - context: Context, - node: ESTree.Node, - argument: ESTree.Node, - options: RuleOptions, -): boolean { +function isSchemaArgument(context: Context, node: ESTree.Node, argument: ESTree.Node, options: RuleOptions): boolean { if (node.type !== 'CallExpression' && node.type !== 'NewExpression') return false; return ( - node.arguments.some((candidate) => Object.is(candidate, argument)) && - isSchemaCallee(context, node.callee, options) + node.arguments.some((candidate) => Object.is(candidate, argument)) && isSchemaCallee(context, node.callee, options) ); } -function isExemptContext( - context: Context, - node: ESTree.ObjectExpression, - options: RuleOptions, -): boolean { +function isExemptContext(context: Context, node: ESTree.ObjectExpression, options: RuleOptions): boolean { let previous: ESTree.Node = node; let current: ESTree.Node | null | undefined = node.parent; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { @@ -324,9 +308,7 @@ function leaksDriverMessage(node: ESTree.Node, options: RuleOptions, depth: numb case 'CallExpression': return leaksCallMessage(target, options, depth); default: - return messageExpressions(target).some((expression) => - leaksDriverMessage(expression, options, depth + 1), - ); + return messageExpressions(target).some((expression) => leaksDriverMessage(expression, options, depth + 1)); } } @@ -368,11 +350,7 @@ function isStringifyCallee(node: ESTree.Node): boolean { ); } -function leaksCallMessage( - node: ESTree.CallExpression, - options: RuleOptions, - depth: number, -): boolean { +function leaksCallMessage(node: ESTree.CallExpression, options: RuleOptions, depth: number): boolean { const callee = unwrap(node.callee); const jsonStringify = isJsonStringify(callee); if (!jsonStringify && !isStringifyCallee(callee)) return false; @@ -409,8 +387,7 @@ function problemFields(properties: Properties, options: RuleOptions) { function problemShape(properties: Properties, options: RuleOptions) { const fields = problemFields(properties, options); const status = integerLiteral(indexedValue(properties, 'status')); - const inRange = - status !== null && status >= options.statusRange[0] && status <= options.statusRange[1]; + const inRange = status !== null && status >= options.statusRange[0] && status <= options.statusRange[1]; const corroborated = fields.taggedProblem || (fields.hasProblemType && fields.hasProse); const reportStatus = inRange && corroborated; const reportTag = shouldReportTag(fields, reportStatus, options); @@ -431,10 +408,7 @@ function shouldReportTag( options: RuleOptions, ): boolean { return ( - !reportStatus && - options.reportTagOnlyLiterals && - fields.taggedProblem && - (fields.hasProse || fields.hasProblemType) + !reportStatus && options.reportTagOnlyLiterals && fields.taggedProblem && (fields.hasProse || fields.hasProblemType) ); } @@ -457,17 +431,17 @@ function reportProblemShape(context: Context, shape: ReturnType 0 ? includePaths : DEFAULT_INCLUDE_PATHS, environmentIdentifiers: - typeof identifiers === 'string' && identifiers.length > 0 - ? identifiers - : DEFAULT_ENVIRONMENT_IDENTIFIERS, + typeof identifiers === 'string' && identifiers.length > 0 ? identifiers : DEFAULT_ENVIRONMENT_IDENTIFIERS, environmentReaders: stringArray(given.environmentReaders, DEFAULT_ENVIRONMENT_READERS), }; } @@ -258,17 +246,14 @@ function staticKey(node: ESTree.MemberExpression): string | null { const property = unwrap(node.property as AnyNode) as AnyNode; if (property.type === 'TemplateLiteral' && property.expressions.length === 0) return property.quasis[0]?.value.cooked ?? null; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; + if (!node.computed) return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; if (property.type !== 'Literal') return null; const value = (property as { value?: unknown }).value; return typeof value === 'string' ? value : null; } function identifierName(node: AnyNode | null): string | null { - return node !== null && node.type === 'Identifier' - ? (node as ESTree.IdentifierReference).name - : null; + return node !== null && node.type === 'Identifier' ? (node as ESTree.IdentifierReference).name : null; } function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { @@ -388,8 +373,7 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, environmentIdentifiers: { type: 'string', @@ -462,17 +446,14 @@ export const rule = defineRule({ if (imported) return processModule(imported.source) && imported.member === 'default'; if (ENV_HOSTS.has(name) && isUnshadowedGlobal(context, host, name)) return true; const declaration = declaratorOf(context, host); - return ( - declaration?.id.type === 'Identifier' && isEnvHost(declaration.init as AnyNode, depth + 1) - ); + return declaration?.id.type === 'Identifier' && isEnvHost(declaration.init as AnyNode, depth + 1); }; const isEnvHost = (node: AnyNode | null, depth = 0): boolean => { const host = unwrap(node); if (!host || depth > MAX_DEPTH) return false; if (host.type === 'AwaitExpression') return isEnvHost(host.argument, depth + 1); if (host.type === 'ImportExpression') return processModule(staticString(host.source) ?? ''); - if (host.type === 'MetaProperty') - return host.meta.name === 'import' && host.property.name === 'meta'; + if (host.type === 'MetaProperty') return host.meta.name === 'import' && host.property.name === 'meta'; const name = identifierName(host); if (name) return isNamedEnvHost(host, name, depth); return isGlobalEnvHostMember(host); @@ -487,12 +468,8 @@ export const rule = defineRule({ isUnshadowedGlobal(context, owner as AnyNode, ownerName) ); }; - const isDestructuredEnvBag = ( - declaration: ESTree.VariableDeclarator, - name: string, - ): boolean => { - if (declaration.id.type !== 'ObjectPattern' || !isEnvHost(declaration.init as AnyNode)) - return false; + const isDestructuredEnvBag = (declaration: ESTree.VariableDeclarator, name: string): boolean => { + if (declaration.id.type !== 'ObjectPattern' || !isEnvHost(declaration.init as AnyNode)) return false; return declaration.id.properties.some( (property) => property.type === 'Property' && @@ -506,15 +483,13 @@ export const rule = defineRule({ const isAmbientEnvBag = (node: AnyNode | null, depth = 0): boolean => { const bag = unwrap(node); if (!bag || depth > MAX_DEPTH) return false; - if (bag.type === 'MemberExpression') - return staticKey(bag) === 'env' && isEnvHost(bag.object as AnyNode); + if (bag.type === 'MemberExpression') return staticKey(bag) === 'env' && isEnvHost(bag.object as AnyNode); if (bag.type !== 'Identifier') return false; const imported = importOf(bag); if (imported) return processModule(imported.source) && imported.member === 'env'; const declaration = declaratorOf(context, bag); if (!declaration) return false; - if (declaration.id.type === 'Identifier') - return isAmbientEnvBag(declaration.init as AnyNode, depth + 1); + if (declaration.id.type === 'Identifier') return isAmbientEnvBag(declaration.init as AnyNode, depth + 1); return isDestructuredEnvBag(declaration, bag.name); }; const isLiteralObject = (node: AnyNode | null, depth = 0): boolean => { @@ -543,10 +518,7 @@ export const rule = defineRule({ const declaration = declaratorOf(context, record); if (declaration?.init && isLiteralObject(declaration.init as AnyNode)) return false; if (environmentIdentifier.test(name) && !importOf(record)) return true; - return ( - declaration?.id.type === 'Identifier' && - isEnvironmentRecord(declaration.init as AnyNode, depth + 1) - ); + return declaration?.id.type === 'Identifier' && isEnvironmentRecord(declaration.init as AnyNode, depth + 1); }; const isReader = (node: AnyNode, depth = 0): boolean => { if (depth > MAX_DEPTH) return false; @@ -645,16 +617,11 @@ export const rule = defineRule({ if (value.type === 'LogicalExpression') return isDerivedLogical(value, depth); if (value.type === 'ConditionalExpression') - return ( - isEnvironmentDerived(value.consequent, depth + 1) || - isEnvironmentDerived(value.alternate, depth + 1) - ); + return isEnvironmentDerived(value.consequent, depth + 1) || isEnvironmentDerived(value.alternate, depth + 1); if (value.type === 'TemplateLiteral') { const template = value as ESTree.TemplateLiteral; - return template.expressions.some((expression) => - isEnvironmentDerived(expression as AnyNode, depth + 1), - ); + return template.expressions.some((expression) => isEnvironmentDerived(expression as AnyNode, depth + 1)); } if (value.type === 'CallExpression') return isDerivedStringCall(value, depth); @@ -672,13 +639,19 @@ export const rule = defineRule({ const reportable = new Map< string, - { readonly messageId: string; readonly data: Record } | null + { + readonly messageId: string; + readonly data: Record; + } | null >(); /** The diagnostic `node` would raise on its own, or `null` when it is not a hand parse. */ const classify = ( node: AnyNode, - ): { readonly messageId: string; readonly data: Record } | null => { + ): { + readonly messageId: string; + readonly data: Record; + } | null => { const cached = reportable.get(spanOf(node)); if (cached !== undefined) return cached; const verdict = computeClassification(node); @@ -718,10 +691,7 @@ export const rule = defineRule({ } return null; } - function classifyMemberCall( - member: ESTree.MemberExpression, - firstArgument: AnyNode | null, - ): Classification { + function classifyMemberCall(member: ESTree.MemberExpression, firstArgument: AnyNode | null): Classification { const key = staticKey(member); if (key === null) return null; const namespaced = classifyNamespacedCall(member, key, firstArgument); @@ -762,10 +732,7 @@ export const rule = defineRule({ function classifyUnary(node: ESTree.UnaryExpression): Classification { if (node.operator === '!' && isEnvironmentLength(node.argument)) return { messageId: 'envLengthCheck', data: { operation: 'length' } }; - if ( - (node.operator === '+' || node.operator === '-') && - isEnvironmentDerived(node.argument, 0) - ) + if ((node.operator === '+' || node.operator === '-') && isEnvironmentDerived(node.argument, 0)) return { messageId: 'envCoercion', data: { operation: node.operator } }; return null; } @@ -778,9 +745,7 @@ export const rule = defineRule({ data: { literal: context.sourceCode.getText(branch.test) }, }; } - function classifyComparison( - node: ESTree.BinaryExpression | ESTree.PrivateInExpression, - ): Classification { + function classifyComparison(node: ESTree.BinaryExpression | ESTree.PrivateInExpression): Classification { const comparison = node as ESTree.BinaryExpression; if (!COMPARISON_OPERATORS.has(comparison.operator)) return null; const left = comparison.left as AnyNode; @@ -797,7 +762,9 @@ export const rule = defineRule({ const raw = (literal as { raw?: string | null }).raw; return { messageId: 'envLiteralComparison', - data: { literal: raw ?? String((literal as { value?: unknown }).value) }, + data: { + literal: raw ?? String((literal as { value?: unknown }).value), + }, }; } /** The diagnostic a node would raise on its own. */ @@ -823,16 +790,9 @@ export const rule = defineRule({ let current = parentOf(node); for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) { if (current === null || current.type === 'Program') return false; - if ( - classify(current) !== null && - (current.type !== 'SwitchStatement' || node.end <= current.discriminant.end) - ) + if (classify(current) !== null && (current.type !== 'SwitchStatement' || node.end <= current.discriminant.end)) return true; - if ( - ['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes( - current.type, - ) - ) + if (['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(current.type)) return false; current = parentOf(current); } @@ -843,7 +803,11 @@ export const rule = defineRule({ const verdict = classify(node); if (verdict === null) return; if (hasReportableAncestor(node)) return; - context.report({ node: node as never, messageId: verdict.messageId, data: verdict.data }); + context.report({ + node: node as never, + messageId: verdict.messageId, + data: verdict.data, + }); }; return { diff --git a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts index d892bccf7..2b5460121 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts @@ -79,14 +79,13 @@ * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { keyName } from '../shared/ast.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { optionRecord, stringArray } from '../shared/options.ts'; -import { keyName } from '../shared/ast.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const DEFAULT_DISCRIMINANT_KEYS: readonly string[] = ['_tag']; @@ -138,10 +137,7 @@ function propertyKeyName(node: ESTree.TSPropertySignature): string | null { * same TypeScript type as `'Found'`, so swapping the quote style must not defeat the rule. A * template with substitutions (`` `contacts/${string}` ``) is a *derived* tag and returns `null`. */ -function noSubstitutionTemplate( - quasis: readonly ESTree.TemplateElement[], - substitutions: number, -): string | null { +function noSubstitutionTemplate(quasis: readonly ESTree.TemplateElement[], substitutions: number): string | null { if (substitutions !== 0 || quasis.length !== 1) return null; const only = quasis[0]; if (only === undefined) return null; @@ -185,9 +181,7 @@ function tagLiterals(type: ESTree.Node): readonly string[] | null { } /** `Readonly` → `{ name: "Readonly", qualifier: null }`; `Types.Simplify` → `{ …, qualifier: "Types" }`. */ -function referenceName( - node: ESTree.TSTypeReference, -): { name: string; qualifier: string | null } | null { +function referenceName(node: ESTree.TSTypeReference): { name: string; qualifier: string | null } | null { const typeName = node.typeName; if (typeName.type === 'Identifier') return { name: typeName.name, qualifier: null }; if (typeName.type !== 'TSQualifiedName') return null; @@ -202,11 +196,7 @@ function referenceName( * *declared* here. A qualified wrapper (`Types.Simplify<…>`) is only transparent when its qualifier * is a tracked `effect` / `effect/*` namespace binding, so a same-named local helper stays opaque. */ -function isTransparentWrapper( - node: ESTree.TSTypeReference, - options: RuleOptions, - bindings: EffectBindings, -): boolean { +function isTransparentWrapper(node: ESTree.TSTypeReference, options: RuleOptions, bindings: EffectBindings): boolean { const reference = referenceName(node); if (reference === null) return false; if (!options.wrapperTypes.includes(reference.name)) return false; @@ -230,9 +220,7 @@ function isTransparentHeritage( } /** `Readonly` / `Types.Simplify` written as an expression (heritage clauses, `extends` bases). */ -function expressionReferenceName( - node: ESTree.Node, -): { name: string; qualifier: string | null } | null { +function expressionReferenceName(node: ESTree.Node): { name: string; qualifier: string | null } | null { if (node.type === 'Identifier') return { name: node.name, qualifier: null }; if (node.type !== 'MemberExpression' || node.computed) return null; if (node.property.type !== 'Identifier') return null; @@ -263,11 +251,7 @@ const TRANSPARENT_TYPE_ANCESTORS: ReadonlySet = new Set([ 'TSTypeReference', ]); -function hasTransparentParameterOwner( - node: ESTree.Node, - options: RuleOptions, - bindings: EffectBindings, -): boolean { +function hasTransparentParameterOwner(node: ESTree.Node, options: RuleOptions, bindings: EffectBindings): boolean { const owner = node.parent; if (owner == null) return false; if (owner.type === 'TSTypeReference') return isTransparentWrapper(owner, options, bindings); @@ -301,8 +285,7 @@ function declarationName(current: ESTree.Node, previous: ESTree.Node): string | const owns = current.body === previous || previous.type === 'TSInterfaceHeritage'; return owns ? current.id.name : null; } - if (current.type === 'TSTypeAliasDeclaration') - return current.typeAnnotation === previous ? current.id.name : null; + if (current.type === 'TSTypeAliasDeclaration') return current.typeAnnotation === previous ? current.id.name : null; return null; } @@ -335,13 +318,11 @@ function classKeyName(node: ESTree.PropertyDefinition): string | null { * `null` when the initialiser is anything else (a parameter, a computed value, a call, …). */ function initialiserTag(node: ESTree.Node): string | null { - if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') - return initialiserTag(node.expression); + if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') return initialiserTag(node.expression); if (node.type === 'ParenthesizedExpression' || node.type === 'TSNonNullExpression') return initialiserTag(node.expression); if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : null; - if (node.type === 'TemplateLiteral') - return noSubstitutionTemplate(node.quasis, node.expressions.length); + if (node.type === 'TemplateLiteral') return noSubstitutionTemplate(node.quasis, node.expressions.length); return null; } @@ -402,10 +383,7 @@ function isAmbient(node: ESTree.Node): boolean { while (current != null && current.type !== 'Program') { if ((current as { declare?: boolean }).declare === true) return true; if (current.type === 'TSModuleDeclaration') { - if ( - current.kind === 'global' || - (current.id.type === 'Literal' && typeof current.id.value === 'string') - ) + if (current.kind === 'global' || (current.id.type === 'Literal' && typeof current.id.value === 'string')) return true; } current = current.parent; @@ -491,7 +469,10 @@ export const rule = defineRule({ if (options.discriminantKeys.length === 0) return {}; if (options.ignoreAmbient && /\.d\.[cm]?ts$/u.test(path)) return {}; - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; + let bindings: EffectBindings = { + namespaces: new Map(), + importsEffect: false, + }; function reportClassField(node: ESTree.PropertyDefinition): void { if (!shouldCheckClassField(node, options)) return; diff --git a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts index 2a477cbed..addd3fc53 100644 --- a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts +++ b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-imperative-loop-in-effect-gen * @@ -71,7 +70,6 @@ import { optionRecord } from '../shared/options.ts'; * those stay reported on purpose. Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; import { @@ -85,11 +83,8 @@ import { } from '../shared/ast.ts'; import { lookupVariable as lexicalVariable } from '../shared/bindings.ts'; import { isGenCallee } from '../shared/effect-identity.ts'; -import { - bindingsWithExtraModules, - collectDirectMemberImports, - collectRootNamespaces, -} from '../shared/imports.ts'; +import { bindingsWithExtraModules, collectDirectMemberImports, collectRootNamespaces } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { booleanOption as boolean, stringArray } from '../shared/options.ts'; import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; @@ -98,16 +93,9 @@ const DEFAULT_IGNORE = ['**/dist/**', '**/build/**', '**/node_modules/**', 'tool /** Wrappers whose generator argument is an Effect program body. */ const DEFAULT_GEN_MEMBERS = ['gen', 'fn', 'fnUntraced']; /** Barrels that re-export `Effect` verbatim, so `Effect.gen` there is the same generator. */ -const DEFAULT_EFFECT_MODULES = [ - '@modern-js/plugin-bff/effect-client', - '@modern-js/plugin-bff/effect-edge', -]; +const DEFAULT_EFFECT_MODULES = ['@modern-js/plugin-bff/effect-client', '@modern-js/plugin-bff/effect-edge']; -const MEMBER_TYPES = new Set([ - 'ComputedMemberExpression', - 'MemberExpression', - 'StaticMemberExpression', -]); +const MEMBER_TYPES = new Set(['ComputedMemberExpression', 'MemberExpression', 'StaticMemberExpression']); const LOOP_LABELS: Record = { DoWhileStatement: 'do...while', ForInStatement: 'for...in', @@ -173,10 +161,7 @@ function isTerminalYield(node: AnyNode, loop: AnyNode): boolean { * `true` when the loop contains a delegating `yield*` that belongs to the loop's own generator — * nested functions (including nested generators) own their own yields and are not descended into. */ -function containsDelegatingYield( - loop: AnyNode, - visitorKeys: Readonly>, -): boolean { +function containsDelegatingYield(loop: AnyNode, visitorKeys: Readonly>): boolean { let found = false; walk(loop, visitorKeys, (node) => { if (found) return false; @@ -216,9 +201,7 @@ function collectLoopMutations( return true; }); const mutatesOuter = [...assigned].some((variable) => - variable.defs.some( - (definition) => definition.node.start < loop.start || definition.node.end > loop.end, - ), + variable.defs.some((definition) => definition.node.start < loop.start || definition.node.end > loop.end), ); return { assigned, mutatesOuter }; } @@ -234,11 +217,7 @@ function patternIdentifiers(pattern: AnyNode): AnyNode[] { return left === null ? [] : patternIdentifiers(left); } const entries = - pattern.type === 'ObjectPattern' - ? pattern.properties - : pattern.type === 'ArrayPattern' - ? pattern.elements - : []; + pattern.type === 'ObjectPattern' ? pattern.properties : pattern.type === 'ArrayPattern' ? pattern.elements : []; if (!Array.isArray(entries)) return []; return entries.flatMap((entry) => { const node = asNode(entry); @@ -301,20 +280,13 @@ function isIncludedFile(context: Context, options: RuleOptions): boolean { return options.includeTests || !isTestFile(path); } -function allowsUnmutatingForOf( - loop: AnyNode, - options: RuleOptions, - mutatesOuter: boolean, -): boolean { +function allowsUnmutatingForOf(loop: AnyNode, options: RuleOptions, mutatesOuter: boolean): boolean { return options.allowForOfWithoutMutation && loop.type === 'ForOfStatement' && !mutatesOuter; } function hasGeneratorImports(program: ESTree.Program, options: RuleOptions): boolean { const rootNamespaces = collectRootNamespaces(program); - const directMembers = collectDirectMemberImports( - program, - new Map([['Effect', new Set(options.genMembers)]]), - ); + const directMembers = collectDirectMemberImports(program, new Map([['Effect', new Set(options.genMembers)]])); const bindings = bindingsWithExtraModules(program, options.effectModules); return bindings.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0; } @@ -331,14 +303,9 @@ function generatorDefinitionValue( return declaration.init === null ? null : generatorValue(context, declaration.init, seen); } -function generatorValue( - context: Context, - value: ESTree.Node, - seen = new Set(), -): ESTree.Node | null { +function generatorValue(context: Context, value: ESTree.Node, seen = new Set()): ESTree.Node | null { const node = identityUnwrap(value); - if (node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') - return node.generator ? node : null; + if (node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') return node.generator ? node : null; if (node.type !== 'Identifier') return null; const variable = lexicalVariable(context, node); if (variable === null || seen.has(variable) || variable.defs.length !== 1) return null; @@ -461,8 +428,7 @@ export const rule = defineRule({ }; return { CallExpression(node) { - if (!isGenCallee(context, asNode(node.callee), options.genMembers, options.effectModules)) - return; + if (!isGenCallee(context, asNode(node.callee), options.genMembers, options.effectModules)) return; for (const argument of node.arguments) { const generator = generatorValue(context, argument); if (generator !== null) effectGenerators.add(generator.start); diff --git a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts index a49d34b57..e1b641a0a 100644 --- a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A2** — "Make Schema the sole authority for contracts and domain models" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A2 counts "approximately 119 @@ -58,15 +57,15 @@ import { optionRecord } from '../shared/options.ts'; * Reports are informational only; this rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { booleanOption as boolean, stringArray } from '../shared/options.ts'; import { memberName, typeNameSegments, unwrapNode as unwrapExpression } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import { collectSchemaLocals } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { booleanOption as boolean, stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; import { isNonReferencePosition } from '../shared/reference-positions.ts'; const SCHEMA_NAMESPACE = 'Schema'; @@ -204,14 +203,10 @@ export const rule = defineRule({ if (locals.schema.size === 0 && locals.barrel.size === 0 && locals.direct.size === 0) return {}; const codecTypeLocals = new Set( - [...locals.direct] - .filter(([, imported]) => options.codecTypes.includes(imported)) - .map(([local]) => local), + [...locals.direct].filter(([, imported]) => options.codecTypes.includes(imported)).map(([local]) => local), ); const suspendLocals = new Set( - [...locals.direct] - .filter(([, imported]) => imported === SUSPEND_MEMBER) - .map(([local]) => local), + [...locals.direct].filter(([, imported]) => imported === SUSPEND_MEMBER).map(([local]) => local), ); const candidates: Candidate[] = []; @@ -230,8 +225,7 @@ export const rule = defineRule({ const schemaMemberName = (node: ESTree.MemberExpression): string | null => { const viaShared = effectMember(node, bindings); if (viaShared !== null && viaShared.namespace === SCHEMA_NAMESPACE) { - if (node.object.type === 'Identifier' && !resolvesToImport(context, node.object)) - return null; + if (node.object.type === 'Identifier' && !resolvesToImport(context, node.object)) return null; return viaShared.member; } const member = memberName(node); @@ -287,20 +281,15 @@ export const rule = defineRule({ return isSchemaCall(current, depth); }; - const isSchemaCall = ( - current: ESTree.CallExpression | ESTree.NewExpression, - depth: number, - ): boolean => { + const isSchemaCall = (current: ESTree.CallExpression | ESTree.NewExpression, depth: number): boolean => { const callee = unwrapExpression(current.callee); // ANY instance-method chain, not just `.pipe`: Effect v4 Schemas carry `.annotate(...)`, // `.check(...)`, `.pipe(...)` and friends, so the receiver — never the method name — decides. - if (callee.type === 'MemberExpression' && isSchemaExpression(callee.object, depth + 1)) - return true; + if (callee.type === 'MemberExpression' && isSchemaExpression(callee.object, depth + 1)) return true; if (isSchemaExpression(callee, depth + 1)) return true; if (isPipeCallee(callee)) { return current.arguments.some( - (argument) => - argument.type !== 'SpreadElement' && isSchemaExpression(argument, depth + 1), + (argument) => argument.type !== 'SpreadElement' && isSchemaExpression(argument, depth + 1), ); } return false; @@ -339,7 +328,9 @@ export const rule = defineRule({ let ancestor = current.parent; while (ancestor != null) { const parameters = ( - ancestor as { typeParameters?: ESTree.TSTypeParameterDeclaration | null } + ancestor as { + typeParameters?: ESTree.TSTypeParameterDeclaration | null; + } ).typeParameters; if (parameters?.params.some((parameter) => parameter.name.name === name)) return true; ancestor = ancestor.parent; @@ -347,21 +338,15 @@ export const rule = defineRule({ return false; }; - const namedDerivation = ( - current: ESTree.TSTypeReference, - seen: Set, - ): boolean | null => { + const namedDerivation = (current: ESTree.TSTypeReference, seen: Set): boolean | null => { if (current.typeName.type !== 'Identifier') return null; const variable = lookupVariable(context, current.typeName); - if (variable === null) - return enclosingParameter(current, current.typeName.name) ? true : null; + if (variable === null) return enclosingParameter(current, current.typeName.name) ? true : null; for (const definition of variable.defs) { const declaration = definition.node as ESTree.Node; if (declaration.type === 'TSTypeParameter') return true; if (declaration.type === 'TSTypeAliasDeclaration') { - return ( - options.allowDerivedTypeArguments && derivedOrGeneric(declaration.typeAnnotation, seen) - ); + return options.allowDerivedTypeArguments && derivedOrGeneric(declaration.typeAnnotation, seen); } } if (variable.defs.length > 0) return null; @@ -399,9 +384,7 @@ export const rule = defineRule({ const parameters = reference.typeArguments?.params ?? []; const first = parameters[0]; if (first === undefined) - return options.requireTypeArguments - ? null - : { annotation: printed(reference), type: member }; + return options.requireTypeArguments ? null : { annotation: printed(reference), type: member }; const argument = context.sourceCode.getText(first).trim(); if (options.ignoreTypeArguments.includes(argument)) return null; if (derivedOrGeneric(first)) return null; @@ -498,11 +481,7 @@ export const rule = defineRule({ if (match === null) return; const key = node.key; const name = - key.type === 'Identifier' - ? key.name - : key.type === 'PrivateIdentifier' - ? `#${key.name}` - : 'this schema'; + key.type === 'Identifier' ? key.name : key.type === 'PrivateIdentifier' ? `#${key.name}` : 'this schema'; annotatedOwners.add(node.start); candidates.push({ node: annotation.typeAnnotation, @@ -535,7 +514,11 @@ export const rule = defineRule({ context.report({ node: candidate.node, messageId: candidate.messageId, - data: { name: candidate.name, annotation: candidate.annotation, type: candidate.type }, + data: { + name: candidate.name, + annotation: candidate.annotation, + type: candidate.type, + }, }); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts index a492b619d..d2b8e9e71 100644 --- a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts +++ b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts @@ -68,19 +68,15 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { booleanOption as boolean, optionRecord, stringArray } from '../shared/options.ts'; import { unwrapNode } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; import { collectSchemaLocals, importedName } from '../shared/imports.ts'; -import { - constSchemaAlias as constantInitializer, - schemaIdentity, -} from '../shared/schema-identity.ts'; +import { booleanOption as boolean, optionRecord, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { constSchemaAlias as constantInitializer, schemaIdentity } from '../shared/schema-identity.ts'; const SCHEMA_NAMESPACE = 'Schema'; @@ -184,7 +180,10 @@ function readOptions(context: Context): RuleOptions { } function unwrapExpression(node: ESTree.Node): ESTree.Node { - return unwrapNode(node, { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }); + return unwrapNode(node, { + wrappers: EXPRESSION_WRAPPERS, + maxDepth: MAX_RESOLUTION_DEPTH, + }); } function recordValue(args: ESTree.CallExpression['arguments']): ESTree.Node | null { @@ -286,7 +285,10 @@ export const rule = defineRule({ const schemaReference = (node: ESTree.Node): string | null => schemaIdentity(context, node, [], 0, { templates: true, - unwrap: { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }, + unwrap: { + wrappers: EXPRESSION_WRAPPERS, + maxDepth: MAX_RESOLUTION_DEPTH, + }, }); /** `Schema.Json` / `S.Json` / `Schema["Json"]` / a bare `Json` imported from `effect/Schema`. */ @@ -299,11 +301,7 @@ export const rule = defineRule({ * Describe `node` when it is a shape-free JSON *document* schema, resolving module-scope * aliases and unwrapping transparent combinators. Returns the shape to quote in the message. */ - const jsonDocumentShape = ( - node: ESTree.Node, - depth: number, - seen: Set, - ): string | null => { + const jsonDocumentShape = (node: ESTree.Node, depth: number, seen: Set): string | null => { if (depth > MAX_RESOLUTION_DEPTH) return null; const current = unwrapExpression(node); @@ -366,8 +364,7 @@ export const rule = defineRule({ return jsonDocumentShape(declarator.init, depth + 1, seen); }; - const describe = (node: ESTree.Node): string | null => - jsonDocumentShape(node, 0, new Set()); + const describe = (node: ESTree.Node): string | null => jsonDocumentShape(node, 0, new Set()); /** * The `Schema` member a call ultimately targets, unwrapping the curried forms @@ -459,9 +456,7 @@ export const rule = defineRule({ const left = expression.left; if (left.type !== 'Identifier') return null; if (!locals.schema.has(left.name) || !resolvesToImport(context, left)) return null; - return jsonMembers.has(expression.right.name) - ? `${left.name}.${expression.right.name}` - : null; + return jsonMembers.has(expression.right.name) ? `${left.name}.${expression.right.name}` : null; } if (expression.type !== 'Identifier') return null; const imported = locals.direct.get(expression.name); @@ -496,21 +491,33 @@ export const rule = defineRule({ if (insideSchemaCall(node)) return; const shape = describe(node.init); if (shape === null) return; - context.report({ node: node.init, messageId: 'jsonDocumentSchema', data: { shape } }); + context.report({ + node: node.init, + messageId: 'jsonDocumentSchema', + data: { shape }, + }); }, PropertyDefinition(node) { if (!node.static || !node.readonly || node.value === null || insideSchemaCall(node)) return; const shape = describe(node.value); if (shape !== null) - context.report({ node: node.value, messageId: 'jsonDocumentSchema', data: { shape } }); + context.report({ + node: node.value, + messageId: 'jsonDocumentSchema', + data: { shape }, + }); }, TSTypeQuery(node) { const reference = typeQueryReference(node.exprName); if (reference === null) return; if (!inTypeContract(node)) return; - context.report({ node, messageId: 'jsonDocumentType', data: { reference } }); + context.report({ + node, + messageId: 'jsonDocumentType', + data: { reference }, + }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts b/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts index e0a5f5a30..10147cde8 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 records that "some library layers internally @@ -39,19 +38,15 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; -import { matchesGlobs, scopePath } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; -import { - EXPRESSION_WRAPPERS as TRANSPARENT_WRAPPERS, - unwrapNode as unwrap, - staticString, -} from '../shared/ast.ts'; +import { EXPRESSION_WRAPPERS as TRANSPARENT_WRAPPERS, unwrapNode as unwrap, staticString } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; +import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import { collectRootNamespaces, collectNamedImports } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { matchesGlobs, scopePath } from '../shared/paths.ts'; const LAYER_NAMESPACE = 'Layer'; const FRESH_MEMBER = 'fresh'; @@ -94,7 +89,11 @@ function readOptions(context: Context): RuleOptions { /** Static string of a property key: `x.fresh`, `x["fresh"]`, and the no-substitution template key. */ function staticKey(node: ESTree.Node, computed: boolean): string | null { if (!computed) return node.type === 'Identifier' ? node.name : null; - return staticString(node, { templates: true, singleQuasi: true, rawTemplates: false }); + return staticString(node, { + templates: true, + singleQuasi: true, + rawTemplates: false, + }); } function memberName(node: ESTree.MemberExpression): string | null { @@ -189,12 +188,7 @@ export const rule = defineRule({ new Set([FRESH_MEMBER]), ); const hasDynamicImport = DYNAMIC_EFFECT_IMPORT.test(context.sourceCode.text); - if ( - !bindings.importsEffect && - rootNamespaces.size === 0 && - directMembers.size === 0 && - !hasDynamicImport - ) { + if (!bindings.importsEffect && rootNamespaces.size === 0 && directMembers.size === 0 && !hasDynamicImport) { return {}; } @@ -208,9 +202,7 @@ export const rule = defineRule({ const typeOnlyLocals = collectTypeOnlyLocals(program); const isTypePosition = (node: ESTree.Node): boolean => { const parent = node.parent; - return ( - parent != null && parent.type.startsWith('TS') && !TRANSPARENT_WRAPPERS.has(parent.type) - ); + return parent != null && parent.type.startsWith('TS') && !TRANSPARENT_WRAPPERS.has(parent.type); }; const report = (node: ESTree.Node): void => { @@ -222,9 +214,7 @@ export const rule = defineRule({ * `true` because the import declaration already proved the binding exists; only a local shadow * (parameter, `const`, catch clause, class name, …) rejects the match. */ - const resolvesToModuleBinding = ( - identifier: Extract, - ): boolean => { + const resolvesToModuleBinding = (identifier: Extract): boolean => { if (typeOnlyLocals.has(identifier.name)) return false; const variable = lookupVariable(context, identifier); if (variable === null || variable.defs.length === 0) return true; @@ -251,17 +241,13 @@ export const rule = defineRule({ return resolvesToModuleBinding(root); }; - const isLayerModuleExpression = (node: ESTree.Node): boolean => - isLayerNamespace(node) || isRootLayerMember(node); + const isLayerModuleExpression = (node: ESTree.Node): boolean => isLayerNamespace(node) || isRootLayerMember(node); /** `const { fresh } = Layer` / `const { fresh: alias } = EffectNs.Layer` — report the binding site. */ - const reportFreshPatternProperties = ( - pattern: Extract, - ): void => { + const reportFreshPatternProperties = (pattern: Extract): void => { for (const property of pattern.properties) { if (property.type !== 'Property') continue; - if (staticKey(property.key as ESTree.Node, property.computed === true) !== FRESH_MEMBER) - continue; + if (staticKey(property.key as ESTree.Node, property.computed === true) !== FRESH_MEMBER) continue; report(property as unknown as ESTree.Node); } }; @@ -269,8 +255,7 @@ export const rule = defineRule({ const handleRootPattern = (pattern: Extract): void => { for (const property of pattern.properties) { if (property.type !== 'Property') continue; - if (staticKey(property.key as ESTree.Node, property.computed === true) !== LAYER_NAMESPACE) - continue; + if (staticKey(property.key as ESTree.Node, property.computed === true) !== LAYER_NAMESPACE) continue; const value = property.value as ESTree.Node; if (value.type === 'Identifier') { layerLocals.add(value.name); @@ -319,8 +304,7 @@ export const rule = defineRule({ const shared = effectMember(node, bindings); if (shared !== null) { if (shared.namespace !== LAYER_NAMESPACE || shared.member !== FRESH_MEMBER) return; - if (resolvesToModuleBinding(node.object as Extract)) - report(node); + if (resolvesToModuleBinding(node.object as Extract)) report(node); return; } // Computed, wrapped and root-namespace forms. diff --git a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts index af680b00f..2c2b0557b 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts @@ -1,4 +1,3 @@ -import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 counts 12 `Layer.orDie` sites while the @@ -39,14 +38,14 @@ import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts * AST-only plugin. Reports are informational only; this rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { asNode, keyName as staticKeyName, memberName } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { importedName } from '../shared/imports.ts'; -import { lookupVariable } from '../shared/bindings.ts'; -import { asNode, keyName as staticKeyName, memberName } from '../shared/ast.ts'; +import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const LAYER_NAMESPACE = 'Layer'; const EFFECT_ROOT_MODULE = 'effect'; @@ -112,7 +111,10 @@ function unwrapValue(node: unknown): ESTree.Node | null { for (let guard = 0; guard < 16; guard += 1) { if (current === null || current === undefined || typeof current.type !== 'string') return null; if (current.type === 'ParenthesizedExpression' || TS_VALUE_WRAPPERS.has(current.type)) { - current = current.expression as { type?: string; expression?: unknown } | null; + current = current.expression as { + type?: string; + expression?: unknown; + } | null; continue; } return current as unknown as ESTree.Node; @@ -154,11 +156,7 @@ function collectDeclarators(program: ESTree.Program): ESTree.VariableDeclarator[ return found; } -function collectDeclaratorChildren( - current: object, - stack: unknown[], - found: ESTree.VariableDeclarator[], -): void { +function collectDeclaratorChildren(current: object, stack: unknown[], found: ESTree.VariableDeclarator[]): void { if (Array.isArray(current)) { for (const item of current) stack.push(item); return; @@ -206,11 +204,7 @@ function collectImportBindings( } } -function isLayerNamespace( - namespaces: ReadonlyMap, - name: string, - isLayer: boolean, -): boolean { +function isLayerNamespace(namespaces: ReadonlyMap, name: string, isLayer: boolean): boolean { return namespaces.get(name) === LAYER_NAMESPACE || isLayer; } @@ -231,8 +225,7 @@ function collectImportSpecifier( const local = specifier.local; if (specifier.type === 'ImportNamespaceSpecifier') { if (isRoot) addBinding(maps.barrel, local.name, local.start); - else if (isLayerNamespace(namespaces, local.name, isLayer)) - addBinding(maps.layer, local.name, local.start); + else if (isLayerNamespace(namespaces, local.name, isLayer)) addBinding(maps.layer, local.name, local.start); return; } if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') return; @@ -245,21 +238,11 @@ function isIdentifierNamePosition(node: Extract, - ): boolean => { + const resolvesTo = (map: BindingMap, identifier: Extract): boolean => { const starts = map.get(identifier.name); if (starts === undefined) return false; const variable = lookupVariable(context, identifier); if (variable === null || variable.defs.length === 0) return true; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return false; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false; return variable.defs.some((definition) => starts.has(definition.name.start)); }; - const isDeclarationSite = ( - identifier: Extract, - ): boolean => { + const isDeclarationSite = (identifier: Extract): boolean => { const variable = lookupVariable(context, identifier); if (variable === null) return false; return variable.defs.some((definition) => definition.name.start === identifier.start); @@ -408,10 +384,7 @@ export const rule = defineRule({ const bindProperty = (property: unknown, kind: BindingKind): boolean => { const entry = asNode(property); if (entry?.type !== 'Property' || entry.key === undefined) return false; - const nextKind = propertyKind( - keyName({ computed: entry.computed === true, key: entry.key }), - kind, - ); + const nextKind = propertyKind(keyName({ computed: entry.computed === true, key: entry.key }), kind); return nextKind === null ? false : bindPattern(entry.value, nextKind); }; @@ -424,17 +397,14 @@ export const rule = defineRule({ if (typeof target.name !== 'string' || typeof target.start !== 'number') return false; return addBinding(maps[kind], target.name, target.start); } - if (target.type !== 'ObjectPattern' || !Array.isArray(target.properties) || kind === 'member') - return false; + if (target.type !== 'ObjectPattern' || !Array.isArray(target.properties) || kind === 'member') return false; return target.properties.reduce( (changed: boolean, property: unknown) => bindProperty(property, kind) || changed, false, ); }; - const identifierKind = ( - node: Extract, - ): BindingKind | null => { + const identifierKind = (node: Extract): BindingKind | null => { if (resolvesTo(layerBindings, node)) return 'layer'; if (resolvesTo(barrelBindings, node)) return 'barrel'; return resolvesTo(memberBindings, node) ? 'member' : null; @@ -544,18 +514,10 @@ export const rule = defineRule({ const found = candidates.filter((candidate) => { // Naming the startup adapter does not apply it twice. Count its uses, not the alias definition. const value = outerValue(candidate.node); - if ( - isRoot && - value.parent?.type === 'VariableDeclarator' && - value.parent.init?.start === value.start - ) + if (isRoot && value.parent?.type === 'VariableDeclarator' && value.parent.init?.start === value.start) return false; const map = - candidate.kind === 'layer' - ? layerBindings - : candidate.kind === 'barrel' - ? barrelBindings - : memberBindings; + candidate.kind === 'layer' ? layerBindings : candidate.kind === 'barrel' ? barrelBindings : memberBindings; return resolvesTo(map, candidate.identifier); }); if (found.length === 0) return; @@ -568,25 +530,16 @@ export const rule = defineRule({ const exportedComposition = (entry: Candidate): number => { let current: ESTree.Node | null | undefined = entry.node; while (current != null) { - if ( - ['FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration'].includes( - current.type, - ) - ) + if (['FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration'].includes(current.type)) return 0; - if ( - current.type === 'ExportNamedDeclaration' || - current.type === 'ExportDefaultDeclaration' - ) - return 1; + if (current.type === 'ExportNamedDeclaration' || current.type === 'ExportDefaultDeclaration') return 1; current = current.parent; } return 0; }; found.sort( (left, right) => - exportedComposition(left) - exportedComposition(right) || - applicationEnd(left) - applicationEnd(right), + exportedComposition(left) - exportedComposition(right) || applicationEnd(left) - applicationEnd(right), ); const allowed = isRoot ? Math.min(options.maxPerRoot, found.length) : 0; @@ -597,7 +550,10 @@ export const rule = defineRule({ context.report({ node: entry.node, messageId: isRoot ? 'beforeRoot' : 'outsideRoot', - data: { member: entry.member, remaining: String(found.length - index - 1) }, + data: { + member: entry.member, + remaining: String(found.length - index - 1), + }, }); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts b/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts index d17127935..60519918a 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts @@ -1,4 +1,3 @@ -import { isNonReferencePosition } from '../shared/reference-positions.ts'; /** * effect-native/no-layer-provide-in-library * @@ -48,12 +47,12 @@ import { isNonReferencePosition } from '../shared/reference-positions.ts'; * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { isScriptFile, isTestFile, matchesAny } from '../shared/paths.ts'; +import { isNonReferencePosition } from '../shared/reference-positions.ts'; const LAYER_NAMESPACE = 'Layer'; const EFFECT_ROOT_MODULE = 'effect'; @@ -132,9 +131,7 @@ function isGovernedLibraryFile(filename: string, options: RuleOptions): boolean } function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; } /** Local names bound by `import * as X from "effect"` — `X.Layer.provide` must still be caught. */ @@ -155,10 +152,7 @@ function collectEffectRootNamespaces(program: ESTree.Program): ReadonlySet { +function collectDirectMemberImports(program: ESTree.Program, members: readonly string[]): ReadonlyMap { const locals = new Map(); for (const statement of program.body) { if (statement.type !== 'ImportDeclaration') continue; @@ -175,11 +169,7 @@ function collectDirectMemberImports( /** A single-quasi template literal (`` `provide` ``) or a plain string literal. */ function constantStringName(node: ESTree.Node): string | null { if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : null; - if ( - node.type === 'TemplateLiteral' && - node.expressions.length === 0 && - node.quasis.length === 1 - ) { + if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) { const cooked = node.quasis[0]?.value.cooked; return typeof cooked === 'string' ? cooked : null; } @@ -241,10 +231,7 @@ function isTypePosition(node: ESTree.Node): boolean { return false; } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { +function lookupVariable(context: Context, identifier: Extract): Variable | null { let scope: Scope | null = context.sourceCode.getScope(identifier); while (scope !== null) { const variable = scope.set.get(identifier.name); @@ -273,7 +260,11 @@ export const rule = defineRule({ ignore: globArray, rootFiles: globArray, compositionFiles: globArray, - members: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + members: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, alsoGovern: globArray, }, additionalProperties: false, @@ -306,8 +297,7 @@ export const rule = defineRule({ for (const [local, namespace] of bindings.namespaces) { if (namespace === LAYER_NAMESPACE) importedLayerLocals.add(local); } - if (importedLayerLocals.size === 0 && effectRoots.size === 0 && directMembers.size === 0) - return {}; + if (importedLayerLocals.size === 0 && effectRoots.size === 0 && directMembers.size === 0) return {}; // ---- Resolution ------------------------------------------------------------------------- // `start` offsets of binding identifiers that alias the Effect `Layer` namespace locally @@ -328,18 +318,14 @@ export const rule = defineRule({ } /** `true` when the identifier resolves to a local rebinding of the `Layer` namespace. */ - function resolvesToLayerAlias( - identifier: Extract, - ): boolean { + function resolvesToLayerAlias(identifier: Extract): boolean { const variable = lookupVariable(context, identifier); if (variable === null) return false; return variable.defs.some((definition) => layerAliasBindings.has(definition.name.start)); } /** `true` when this identifier denotes the Effect `Layer` module in its own scope. */ - function isLayerNamespaceIdentifier( - identifier: Extract, - ): boolean { + function isLayerNamespaceIdentifier(identifier: Extract): boolean { if (resolvesToImport(identifier, importedLayerLocals)) return true; return resolvesToLayerAlias(identifier); } @@ -366,11 +352,18 @@ export const rule = defineRule({ const memberCandidates: MemberCandidate[] = []; const identifierCandidates: Extract[] = []; const declarators: ESTree.VariableDeclarator[] = []; - const reports: Array<{ node: ESTree.Node; messageId: string; data: Record }> = - []; + const reports: Array<{ + node: ESTree.Node; + messageId: string; + data: Record; + }> = []; function queue(node: ESTree.Node, member: string): void { - reports.push({ node, messageId: 'layerProvideInLibrary', data: { member } }); + reports.push({ + node, + messageId: 'layerProvideInLibrary', + data: { member }, + }); } function collectDirectReferences(): void { @@ -455,7 +448,11 @@ export const rule = defineRule({ reports.sort((left, right) => left.node.start - right.node.start); for (const report of reports) { - context.report({ node: report.node, messageId: report.messageId, data: report.data }); + context.report({ + node: report.node, + messageId: report.messageId, + data: report.data, + }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts index d5efe47ff..79a890550 100644 --- a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts +++ b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **B5** — "Adopt Effect's ADTs and temporal model consistently" ("Closed * vocabularies and timestamps are repeatedly re-declared", "Highest-value targets are service @@ -75,17 +74,13 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - collectEffectBindings, - effectMember, - type EffectBindings, -} from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { booleanOption as boolean, positiveInteger, stringArray } from '../shared/options.ts'; import { asNode as sharedAsNode } from '../shared/ast.ts'; +import { collectEffectBindings, effectMember, type EffectBindings } from '../shared/effect-imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { booleanOption as boolean, positiveInteger, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; /** A2/B5 apply everywhere first-party TypeScript is authored. */ const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -138,8 +133,7 @@ function rawTemplateText(node: AnyNode, interpolationKey: 'expressions' | 'types if (interpolations.length > 0) return null; const quasis = Array.isArray(node.quasis) ? node.quasis : []; const value = asNode(quasis[0])?.value; - const raw = - typeof value === 'object' && value !== null ? (value as { raw?: unknown }).raw : undefined; + const raw = typeof value === 'object' && value !== null ? (value as { raw?: unknown }).raw : undefined; return typeof raw === 'string' ? raw : ''; } @@ -161,10 +155,7 @@ function isNullish(node: AnyNode): boolean { // `null` also appears as `TSLiteralType { literal: NullLiteral }` in some shapes. if (node.type !== 'TSLiteralType') return false; const literal = asNode(node.literal); - return ( - literal !== null && - (literal.type === 'NullLiteral' || (literal.type === 'Literal' && literal.value === null)) - ); + return literal !== null && (literal.type === 'NullLiteral' || (literal.type === 'Literal' && literal.value === null)); } interface UnionAnalysis { @@ -244,10 +235,7 @@ function isAmbient(node: ESTree.Node): boolean { * declared. A function-local `const AuditProfile = Schema.Literals([...])` is not, so such a binding * must not claim ownership — those aliases fall back to the generic message. */ -function collectSchemaLiteralNames( - program: ESTree.Program, - bindings: EffectBindings, -): ReadonlySet { +function collectSchemaLiteralNames(program: ESTree.Program, bindings: EffectBindings): ReadonlySet { const names = new Set(); if (!bindings.importsEffect) return names; const addDeclarator = (value: unknown): void => { @@ -303,11 +291,7 @@ function isSchemaLiteralCall(value: unknown, bindings: EffectBindings): boolean const callee = asNode(init.callee); if (callee === null) return false; const member = effectMember(callee as unknown as ESTree.Node, bindings); - return ( - member !== null && - member.namespace === SCHEMA_NAMESPACE && - SCHEMA_LITERAL_MEMBERS.has(member.member) - ); + return member !== null && member.namespace === SCHEMA_NAMESPACE && SCHEMA_LITERAL_MEMBERS.has(member.member); } function eligibleName(node: ESTree.Node, options: RuleOptions): string | null { @@ -444,7 +428,11 @@ export const rule = defineRule({ context.report({ node: (asNode(raw.id) ?? raw) as unknown as ESTree.Node, messageId: 'literalEnum', - data: { name, count: String(values.length), members: preview(values) }, + data: { + name, + count: String(values.length), + members: preview(values), + }, }); }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts index 287c7f57e..dd7b7b28b 100644 --- a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts +++ b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A4** — "Rebuild the error system around typed channels and contract-owned Problem * Details" and **A6** — "Activate real observability at the runtime roots" @@ -52,17 +51,13 @@ import { optionRecord } from '../shared/options.ts'; * rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; import { lookupVariable } from '../shared/bindings.ts'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; import { effectOrigin } from '../shared/effect-identity.ts'; -import { - collectDirectMemberImports, - collectNamespaceLocals, - splitMembers, -} from '../shared/imports.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { collectDirectMemberImports, collectNamespaceLocals, splitMembers } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { isInTypePosition, isNonReferencePosition } from '../shared/reference-positions.ts'; @@ -191,12 +186,7 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); - const { namespaced, barrel } = collectNamespaceLocals( - program, - bindings, - watched, - options.reexportModules, - ); + const { namespaced, barrel } = collectNamespaceLocals(program, bindings, watched, options.reexportModules); const directMembers = collectDirectMemberImports(program, byNamespace); if (namespaced.size === 0 && barrel.size === 0 && directMembers.size === 0) return {}; @@ -218,12 +208,7 @@ export const rule = defineRule({ Identifier(node) { if (isNonReferencePosition(node)) return; const variable = lookupVariable(context, node); - if ( - !variable?.references.some( - (reference) => reference.identifier === node && reference.isRead(), - ) - ) - return; + if (!variable?.references.some((reference) => reference.identifier === node && reference.isRead())) return; // Type queries and type-member names are not runtime seam references. if (isInTypePosition(node, RUNTIME_TS_EXPRESSIONS)) return; inspect(node); diff --git a/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts index 4d2a16af9..bceef6921 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A8** — "Fix the generators before generating more code" — and **A3** — "Replace * ambient configuration with Config, ConfigProvider, and Redacted" @@ -58,9 +57,9 @@ import { optionRecord } from '../shared/options.ts'; * helper-returned source and dynamic interpolation values are not reconstructed. Report-only. */ import { defineRule } from '@oxlint/plugins'; - import type { Context } from '@oxlint/plugins'; +import { optionRecord } from '../shared/options.ts'; import { booleanOption, compilePatterns, stringArray } from '../shared/options.ts'; import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { snippet } from '../shared/reporting.ts'; @@ -82,12 +81,7 @@ const DEFAULT_TEMPLATE_PATHS: readonly string[] = [ ]; /** Generated or vendored output that is never hand-edited. */ -const DEFAULT_EXCLUDE: readonly string[] = [ - '**/dist/**', - '**/.output/**', - '**/node_modules/**', - '**/*.d.ts', -]; +const DEFAULT_EXCLUDE: readonly string[] = ['**/dist/**', '**/.output/**', '**/node_modules/**', '**/*.d.ts']; /** * Configuration-plumbing shapes no generator may emit. Sources (not `RegExp`s) so the whole list is @@ -171,7 +165,11 @@ function collectMatches(patterns: readonly RegExp[], source: TemplateSource): Ma continue; } if (isConfigurationMatch(match, source)) - found.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); + found.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + }); } } return found.sort((a, b) => a.start - b.start || b.end - a.end); diff --git a/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts b/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts index f2fbc637a..6b447bd17 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **C1** — "Remove remaining hand-owned serialization" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). C1 names cookie construction explicitly and @@ -50,14 +49,14 @@ import { optionRecord } from '../shared/options.ts'; * the producer. Dynamic header names and cross-file ownership are not inferred. No fixer/suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { unwrap as unwrapExpression, staticString, keyName } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const DEFAULT_PATHS = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -65,25 +64,12 @@ const DEFAULT_PATHS = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_EXCLUDE = ['**/dist/**', '**/.output/**', '**/node_modules/**', '**/*.d.ts']; /** Effect HTTP namespaces that own cookie serialization. A call into these is the target shape. */ -const DEFAULT_COOKIE_NAMESPACES = [ - 'Cookies', - 'Cookie', - 'HttpServerResponse', - 'HttpServerRespondable', -]; +const DEFAULT_COOKIE_NAMESPACES = ['Cookies', 'Cookie', 'HttpServerResponse', 'HttpServerRespondable']; const DEFAULT_CONTRACT_NAMES = ['setCookieHeaders', 'setCookieHeader', 'cookieHeaders']; /** Methods that write a header value (`Headers`, node `ServerResponse`, Effect response builders). */ -const HEADER_WRITERS = new Set([ - 'append', - 'set', - 'setHeader', - 'setHeaders', - 'add', - 'put', - 'writeHead', -]); +const HEADER_WRITERS = new Set(['append', 'set', 'setHeader', 'setHeaders', 'add', 'put', 'writeHead']); const SET_COOKIE_HEADER = 'set-cookie'; @@ -209,16 +195,11 @@ function isCookieOwnedValue( isCookieOwnedValue(context, target.object, bindings, namespaces, depth + 1) ); default: - return isOwnedComposite(target, (child) => - isCookieOwnedValue(context, child, bindings, namespaces, depth + 1), - ); + return isOwnedComposite(target, (child) => isCookieOwnedValue(context, child, bindings, namespaces, depth + 1)); } } -function isOwnedComposite( - target: ESTree.Node, - owned: (node: ESTree.Node | null) => boolean, -): boolean { +function isOwnedComposite(target: ESTree.Node, owned: (node: ESTree.Node | null) => boolean): boolean { switch (target.type) { case 'LogicalExpression': return owned(target.left) && (literalString(target.right) === '' || owned(target.right)); @@ -230,9 +211,7 @@ function isOwnedComposite( case 'ArrayExpression': return ( target.elements.length > 0 && - target.elements.every( - (element) => element !== null && element.type !== 'SpreadElement' && owned(element), - ) + target.elements.every((element) => element !== null && element.type !== 'SpreadElement' && owned(element)) ); default: return false; @@ -251,16 +230,10 @@ function isHandBuiltValue(node: ESTree.Node | null, depth = 0): boolean { case 'BinaryExpression': return isHandBuiltConcatenation(target, depth); case 'ConditionalExpression': - return ( - isHandBuiltValue(target.consequent, depth + 1) || - isHandBuiltValue(target.alternate, depth + 1) - ); + return isHandBuiltValue(target.consequent, depth + 1) || isHandBuiltValue(target.alternate, depth + 1); case 'ArrayExpression': return target.elements.some( - (element) => - element !== null && - element.type !== 'SpreadElement' && - isHandBuiltValue(element, depth + 1), + (element) => element !== null && element.type !== 'SpreadElement' && isHandBuiltValue(element, depth + 1), ); default: return false; @@ -272,17 +245,12 @@ function isHandBuiltConcatenation( depth: number, ): boolean { return ( - target.operator === '+' && - (isHandBuiltValue(target.left, depth + 1) || isHandBuiltValue(target.right, depth + 1)) + target.operator === '+' && (isHandBuiltValue(target.left, depth + 1) || isHandBuiltValue(target.right, depth + 1)) ); } function propertyKeyName(node: ESTree.Node): string | null { - if ( - node.type !== 'Property' && - node.type !== 'TSPropertySignature' && - node.type !== 'PropertyDefinition' - ) + if (node.type !== 'Property' && node.type !== 'TSPropertySignature' && node.type !== 'PropertyDefinition') return null; return keyName( node.key, @@ -335,8 +303,7 @@ function constantString(context: Context, input: ESTree.Node, depth = 0): string function constantInitializer(def: Variable['defs'][number]): ESTree.Node | null { if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - if (def.node.parent?.type !== 'VariableDeclaration' || def.node.parent.kind !== 'const') - return null; + if (def.node.parent?.type !== 'VariableDeclaration' || def.node.parent.kind !== 'const') return null; return def.node.init ?? null; } @@ -433,11 +400,19 @@ export const rule = defineRule({ if (isCoveredByOuterReport(node)) return; const prefix = COOKIE_NAME_PREFIX.exec(text)?.[0]; if (prefix !== undefined) { - context.report({ node, messageId: 'cookieNamePrefix', data: { fragment: prefix } }); + context.report({ + node, + messageId: 'cookieNamePrefix', + data: { fragment: prefix }, + }); return; } const fragment = COOKIE_ATTRIBUTE.exec(text)?.[0].replace(/^;\s*/u, '').trim() ?? text; - context.report({ node, messageId: 'cookieAttributeString', data: { fragment } }); + context.report({ + node, + messageId: 'cookieAttributeString', + data: { fragment }, + }); }; return { @@ -456,8 +431,7 @@ export const rule = defineRule({ if (write === null) return; if (isCookieOwnedValue(context, write.value, bindings, options.cookieNamespaces)) return; const callee = unwrap(node.callee); - const method = - callee !== null && callee.type === 'MemberExpression' ? memberName(callee) : null; + const method = callee !== null && callee.type === 'MemberExpression' ? memberName(callee) : null; reported.add(node); context.report({ node, @@ -471,7 +445,11 @@ export const rule = defineRule({ if (isCookieOwnedValue(context, node.value, bindings, options.cookieNamespaces)) return; if (!isHandBuiltValue(node.value)) return; reported.add(node); - context.report({ node, messageId: 'setCookieHeaderProperty', data: { header: name } }); + context.report({ + node, + messageId: 'setCookieHeaderProperty', + data: { header: name }, + }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts index f5e836077..533f44b62 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts @@ -1,4 +1,3 @@ -import { maskText, driverText, emittedText, reportNode } from '../shared/scaffold-text.ts'; /** * effect-native/no-manual-error-handling-in-scaffold-templates * @@ -78,11 +77,11 @@ import { maskText, driverText, emittedText, reportNode } from '../shared/scaffol * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { optionRecord, stringArray } from '../shared/options.ts'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { optionRecord, stringArray } from '../shared/options.ts'; import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { maskText, driverText, emittedText, reportNode } from '../shared/scaffold-text.ts'; /** * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. @@ -157,9 +156,7 @@ function snippetOf(text: string): string { } /** Which `messageId` names the right Effect-native replacement for this shape. */ -function messageIdFor( - text: string, -): 'tagSwitch' | 'instanceofError' | 'promiseCatchBranch' | 'tagComparison' { +function messageIdFor(text: string): 'tagSwitch' | 'instanceofError' | 'promiseCatchBranch' | 'tagComparison' { if (SWITCH_SHAPE.test(text.trimStart())) return 'tagSwitch'; if (CATCH_SHAPE.test(text.trimStart())) return 'promiseCatchBranch'; if (INSTANCEOF_SHAPE.test(text)) return 'instanceofError'; @@ -176,11 +173,12 @@ function collectMatches(text: string, patterns: readonly RegExp[]): readonly Mat pattern.lastIndex = 0; let match = pattern.exec(text); while (match !== null) { - if ( - match[0].length > 0 && - !(CATCH_SHAPE.test(match[0]) && /\bEffect\s*$/u.test(text.slice(0, match.index))) - ) { - found.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); + if (match[0].length > 0 && !(CATCH_SHAPE.test(match[0]) && /\bEffect\s*$/u.test(text.slice(0, match.index)))) { + found.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + }); } // Guard against zero-length matches from a user-supplied pattern. if (match[0].length === 0) pattern.lastIndex += 1; @@ -260,7 +258,11 @@ export const rule = defineRule({ }, ], defaultOptions: [ - { templatePaths: [...DEFAULT_TEMPLATE_PATHS], patterns: [...DEFAULT_PATTERNS], ignore: [] }, + { + templatePaths: [...DEFAULT_TEMPLATE_PATHS], + patterns: [...DEFAULT_PATTERNS], + ignore: [], + }, ], }, create(context) { diff --git a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts index 792cd10f8..198901c6d 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts @@ -1,4 +1,5 @@ -import { optionRecord } from '../shared/options.ts'; +import { fileURLToPath } from 'node:url'; + /** * Audit A6 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) calls for one outer * instrumentation seam and ambient identity annotations, replacing copied per-handler records. @@ -17,24 +18,15 @@ import { optionRecord } from '../shared/options.ts'; * Report-only, with no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { fileURLToPath } from 'node:url'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import { unwrapNode as unwrap, staticString, memberName as staticMemberName } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; +import { splitMembers, collectNamespaceLocals, collectDirectMemberImports } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; -import { - unwrapNode as unwrap, - staticString, - memberName as staticMemberName, -} from '../shared/ast.ts'; -import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; -import { - splitMembers, - collectNamespaceLocals, - collectDirectMemberImports, -} from '../shared/imports.ts'; +import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -158,32 +150,19 @@ function qualifiedMember(base: string | null, key: string | null): string | null return base === '$root' ? key : `${base}.${key}`; } -function destructuredMember( - pattern: ESTree.Node, - name: string, - base: string | null, -): string | null { +function destructuredMember(pattern: ESTree.Node, name: string, base: string | null): string | null { if (pattern.type === 'Identifier') return base; if (pattern.type !== 'ObjectPattern' || base === null) return null; for (const property of pattern.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; + if (property.type !== 'Property' || property.value.type !== 'Identifier' || property.value.name !== name) continue; const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : literalString(property.key); + !property.computed && property.key.type === 'Identifier' ? property.key.name : literalString(property.key); return qualifiedMember(base, key); } return null; } -function objectArgument( - args: ESTree.CallExpression['arguments'], -): ESTree.ObjectExpression | undefined { +function objectArgument(args: ESTree.CallExpression['arguments']): ESTree.ObjectExpression | undefined { for (const raw of args) { const argument = unwrap(raw); if (argument.type === 'ObjectExpression') return argument; @@ -266,10 +245,7 @@ export const rule = defineRule({ const annotationByNamespace = splitMembers(options.annotationMembers).byNamespace; const spanByNamespace = splitMembers(options.spanMembers).byNamespace; - const allByNamespace = splitMembers([ - ...options.annotationMembers, - ...options.spanMembers, - ]).byNamespace; + const allByNamespace = splitMembers([...options.annotationMembers, ...options.spanMembers]).byNamespace; if (allByNamespace.size === 0) return {}; const identities = new Map(); @@ -279,32 +255,21 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); const watched = new Set(allByNamespace.keys()); - const { namespaced, barrel } = collectNamespaceLocals( - program, - bindings, - watched, - options.reexportModules, - ); + const { namespaced, barrel } = collectNamespaceLocals(program, bindings, watched, options.reexportModules); const directMembers = new Map( - [...collectDirectMemberImports(program, allByNamespace)].map( - ([local, { namespace, member }]) => [local, `${namespace}.${member}`], - ), + [...collectDirectMemberImports(program, allByNamespace)].map(([local, { namespace, member }]) => [ + local, + `${namespace}.${member}`, + ]), ); if (namespaced.size === 0 && barrel.size === 0 && directMembers.size === 0) return {}; const resolveAlias = (variable: Variable, name: string, seen: Set): string | null => { - if ( - seen.has(variable) || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) + if (seen.has(variable) || variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; seen.add(variable); const definition = variable.defs[0]; - if ( - definition?.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - !definition.node.init - ) + if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator' || !definition.node.init) return null; const declaration = definition.node; return destructuredMember(declaration.id, name, resolveCallee(declaration.init!, seen)); @@ -315,12 +280,9 @@ export const rule = defineRule({ seen: Set, ): string | null => { const variable = lookupVariable(context, callee); - if (variable && !resolvesToImport(context, callee, true)) - return resolveAlias(variable, callee.name, seen); + if (variable && !resolvesToImport(context, callee, true)) return resolveAlias(variable, callee.name, seen); return ( - directMembers.get(callee.name) ?? - namespaced.get(callee.name) ?? - (barrel.has(callee.name) ? '$root' : null) + directMembers.get(callee.name) ?? namespaced.get(callee.name) ?? (barrel.has(callee.name) ? '$root' : null) ); }; @@ -335,12 +297,20 @@ export const rule = defineRule({ }; const reportIdentity = (node: ESTree.Node, key: string, member: string): void => { - context.report({ node, messageId: 'manualIdentity', data: { key, member } }); + context.report({ + node, + messageId: 'manualIdentity', + data: { key, member }, + }); }; const reportOpaque = (node: ESTree.Node, member: string): void => { if (!options.flagSpreadHelpers) return; - context.report({ node, messageId: 'opaqueAnnotations', data: { member } }); + context.report({ + node, + messageId: 'opaqueAnnotations', + data: { member }, + }); }; /** Report every identity-named property of a flat annotation/attributes record. */ @@ -351,9 +321,7 @@ export const rule = defineRule({ continue; } const key = - property.computed || property.key.type !== 'Identifier' - ? literalString(property.key) - : property.key.name; + property.computed || property.key.type !== 'Identifier' ? literalString(property.key) : property.key.name; if (key === null) continue; const identity = identityKeyFor(key, identities); if (identity !== null) reportIdentity(property, identity, member); @@ -391,9 +359,7 @@ export const rule = defineRule({ for (const property of argument.properties) { if (property.type === 'SpreadElement') continue; const key = - property.computed || property.key.type !== 'Identifier' - ? literalString(property.key) - : property.key.name; + property.computed || property.key.type !== 'Identifier' ? literalString(property.key) : property.key.name; if (key !== 'attributes') continue; const value = unwrap(property.value); if (value.type === 'ObjectExpression') inspectRecord(value, member); @@ -408,8 +374,7 @@ export const rule = defineRule({ const dot = qualified.indexOf('.'); const namespace = qualified.slice(0, dot); const member = qualified.slice(dot + 1); - if (annotationByNamespace.get(namespace)?.has(member)) - inspectAnnotationCall(node, qualified); + if (annotationByNamespace.get(namespace)?.has(member)) inspectAnnotationCall(node, qualified); else if (spanByNamespace.get(namespace)?.has(member)) inspectSpanCall(node, qualified); }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts index 4054c64bc..b5096e04e 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A9** — "Preserve typed Effects through the frontend" (target: "Schema-driven * route/search parameters through `Schema.standardSchemaV1`" and "Form codecs derived from payload @@ -45,14 +44,14 @@ import { optionRecord } from '../shared/options.ts'; * Report-only; no fixer or suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { unwrap as unwrapAst, memberName as astMemberName, keyName } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { importedName } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; /** Route modules: the frontend seam A9 names. Nested `routes/` directories are covered too. */ const DEFAULT_ROUTE_GLOBS = [ @@ -113,7 +112,10 @@ function unwrap(node: ESTree.Node | null | undefined): ESTree.Node | null { } function memberName(node: ESTree.MemberExpression): string | null { - return astMemberName(node, { templates: false, unwrap: { wrappers: ROUTE_WRAPPERS } }); + return astMemberName(node, { + templates: false, + unwrap: { wrappers: ROUTE_WRAPPERS }, + }); } function propertyKeyName(property: Extract): string | null { @@ -129,11 +131,7 @@ function collectWrites(variable: Variable): readonly ESTree.Node[] { for (const definition of variable.defs) { if (definition.type !== 'Variable') continue; const declarator = definition.node; - if ( - declarator.type === 'VariableDeclarator' && - declarator.init !== null && - declarator.init !== undefined - ) { + if (declarator.type === 'VariableDeclarator' && declarator.init !== null && declarator.init !== undefined) { writes.push(declarator.init); } } @@ -144,10 +142,7 @@ function collectWrites(variable: Variable): readonly ESTree.Node[] { return writes; } -function writesOf( - context: Context, - identifier: Extract, -): readonly ESTree.Node[] { +function writesOf(context: Context, identifier: Extract): readonly ESTree.Node[] { const variable = lookupVariable(context, identifier); if (variable === null) return []; // Options/constructor aliases must have a stable value; an earlier false/global assignment @@ -161,10 +156,7 @@ function writesOf( * no declaration (the implicit global scope). Any real declaration — `const`, class, function, * parameter, catch clause or `import` — means this is not the browser API and must not be reported. */ -function isUnshadowedGlobal( - context: Context, - identifier: Extract, -): boolean { +function isUnshadowedGlobal(context: Context, identifier: Extract): boolean { const variable = lookupVariable(context, identifier); if (variable === null) return true; return variable.defs.length === 0; @@ -214,20 +206,15 @@ function globalConstructorName(context: Context, node: ESTree.NewExpression): st function isUrlExpression(context: Context, node: ESTree.Node | null, depth = 0): boolean { const target = unwrap(node); if (target === null || depth >= MAX_ALIAS_DEPTH) return false; - if (target.type === 'NewExpression') - return globalConstructorName(context, target) === URL_CONSTRUCTOR; + if (target.type === 'NewExpression') return globalConstructorName(context, target) === URL_CONSTRUCTOR; if (target.type === 'CallExpression') return isUrlFactoryCall(context, target); if (target.type === 'ConditionalExpression') { return ( - isUrlExpression(context, target.consequent, depth + 1) || - isUrlExpression(context, target.alternate, depth + 1) + isUrlExpression(context, target.consequent, depth + 1) || isUrlExpression(context, target.alternate, depth + 1) ); } if (target.type === 'LogicalExpression') { - return ( - isUrlExpression(context, target.left, depth + 1) || - isUrlExpression(context, target.right, depth + 1) - ); + return isUrlExpression(context, target.left, depth + 1) || isUrlExpression(context, target.right, depth + 1); } return false; } @@ -253,8 +240,7 @@ function isUrlBinding( for (const write of collectWrites(variable)) { if (isUrlExpression(context, write)) return true; const target = unwrap(write); - if (target !== null && target.type === 'Identifier' && isUrlBinding(context, target, seen)) - return true; + if (target !== null && target.type === 'Identifier' && isUrlBinding(context, target, seen)) return true; } return false; } @@ -268,11 +254,7 @@ function isUrlSource(context: Context, node: ESTree.Node | null): boolean { } /** Resolve an expression to the object literal it denotes, following local `const` bindings. */ -function resolveObject( - context: Context, - node: ESTree.Node | null, - depth = 0, -): ESTree.ObjectExpression | null { +function resolveObject(context: Context, node: ESTree.Node | null, depth = 0): ESTree.ObjectExpression | null { const target = unwrap(node); if (target === null || depth >= MAX_ALIAS_DEPTH) return null; if (target.type === 'ObjectExpression') return target; @@ -298,10 +280,7 @@ function isFalseValue(context: Context, node: ESTree.Node | null, depth = 0): bo * (`const untyped = { strict: false } as const`) or spread in from one. */ function spreadMaySetStrict(entry: ESTree.ObjectExpression['properties'][number]): boolean { - return ( - entry.type === 'SpreadElement' || - (entry.type === 'Property' && propertyKeyName(entry) === 'strict') - ); + return entry.type === 'SpreadElement' || (entry.type === 'Property' && propertyKeyName(entry) === 'strict'); } function nextStrictValue( @@ -314,9 +293,7 @@ function nextStrictValue( // Unknown later spreads may overwrite strict; never infer false through them. const spread = resolveObject(context, property.argument, depth + 1); if (spread === null) return undefined; - return spread.properties.some(spreadMaySetStrict) - ? strictValue(context, spread, depth + 1) - : value; + return spread.properties.some(spreadMaySetStrict) ? strictValue(context, spread, depth + 1) : value; } if (property.type !== 'Property') return value; if (property.computed) return undefined; @@ -328,8 +305,7 @@ function strictValue(context: Context, node: ESTree.Node | null, depth = 0): boo const object = resolveObject(context, node, depth); if (object === null || depth >= MAX_ALIAS_DEPTH) return undefined; let value: boolean | undefined; - for (const property of object.properties) - value = nextStrictValue(context, property, value, depth); + for (const property of object.properties) value = nextStrictValue(context, property, value, depth); return value; } @@ -518,21 +494,14 @@ export const rule = defineRule({ if (!matchesGlobs(path, options.routeGlobs)) return {}; if (!options.allowTestFiles && isTestFile(path)) return {}; - const bindings = collectHookBindings( - context.sourceCode.ast, - options.untypedHooks, - options.routerModules, - ); + const bindings = collectHookBindings(context.sourceCode.ast, options.untypedHooks, options.routerModules); /** `const { searchParams } = ` / `const { searchParams: alias } = `. */ - const reportDestructuredSearchParams = ( - pattern: Extract, - ): void => { + const reportDestructuredSearchParams = (pattern: Extract): void => { for (const property of pattern.properties) { if (property.type !== 'Property') continue; if (propertyKeyName(property) !== SEARCH_PARAMS) continue; - if (property.value.type === 'Identifier' && isOutputBinding(context, property.value)) - continue; + if (property.value.type === 'Identifier' && isOutputBinding(context, property.value)) continue; context.report({ node: property, messageId: 'rawUrlSearchParams' }); } }; @@ -544,10 +513,7 @@ export const rule = defineRule({ // Empty multipart/search containers carry outgoing data; they parse no route input. if (node.arguments.length === 0) return; const input = unwrap(node.arguments[0]); - if ( - name === 'URLSearchParams' && - (input?.type === 'ObjectExpression' || input?.type === 'ArrayExpression') - ) + if (name === 'URLSearchParams' && (input?.type === 'ObjectExpression' || input?.type === 'ArrayExpression')) return; context.report({ node, @@ -569,8 +535,7 @@ export const rule = defineRule({ MemberExpression(node) { if (!options.flagUrlSearchParams) return; if (memberName(node) !== SEARCH_PARAMS || isOutputUse(context, node)) return; - if (isUrlSource(context, node.object)) - context.report({ node, messageId: 'rawUrlSearchParams' }); + if (isUrlSource(context, node.object)) context.report({ node, messageId: 'rawUrlSearchParams' }); }, VariableDeclarator(node) { if (!options.flagUrlSearchParams) return; diff --git a/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts b/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts index 1a6f8294e..ead635bce 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts @@ -204,10 +204,8 @@ function unwrap(node: ESTree.Node): ESTree.Node { /** A statically known string operand (`'X'`, `"X"`, `` `X` ``), or null. */ function asStringLiteral(node: ESTree.Node): string | null { const expression = unwrap(node); - if (expression.type === 'Literal') - return typeof expression.value === 'string' ? expression.value : null; - if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) - return templateText(expression); + if (expression.type === 'Literal') return typeof expression.value === 'string' ? expression.value : null; + if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) return templateText(expression); return null; } @@ -263,10 +261,7 @@ function assertionPropertyName( } /** Trace a simple immutable destructured method without losing the source binding's scope. */ -function destructuredMethod( - context: Context, - node: ESTree.Node, -): { source: ESTree.Node; method: string } | null { +function destructuredMethod(context: Context, node: ESTree.Node): { source: ESTree.Node; method: string } | null { if (node.type !== 'Identifier') return null; const declarator = immutableDeclarator(context, node); if ( @@ -292,10 +287,7 @@ interface AssertionCall { } /** Node assertions allow strict/default namespace prefixes but never an expect subject. */ -function nodeAssertion( - members: string[], - subject: ESTree.CallExpression | null, -): AssertionCall | null { +function nodeAssertion(members: string[], subject: ESTree.CallExpression | null): AssertionCall | null { if (subject !== null) return null; while (members[0] === 'strict' || members[0] === 'default') members.shift(); const method = members[0]; @@ -320,16 +312,11 @@ function importedAssertion( subject: ESTree.CallExpression | null, ): AssertionCall | null { if (/^(?:node:)?assert(?:\/strict)?$/u.test(source)) return nodeAssertion(members, subject); - if (!['@rstest/core', 'effect-rstest', 'vitest', '@jest/globals', 'expect'].includes(source)) - return null; + if (!['@rstest/core', 'effect-rstest', 'vitest', '@jest/globals', 'expect'].includes(source)) return null; if (subject === null) return staticAssertion(members); if (members.shift() !== 'expect') return null; const method = members.pop(); - if ( - method === undefined || - !members.every((member) => ['not', 'resolves', 'rejects'].includes(member)) - ) - return null; + if (method === undefined || !members.every((member) => ['not', 'resolves', 'rejects'].includes(member))) return null; return { method, subject }; } @@ -345,26 +332,18 @@ function assertionImport( const specifier = definition?.node as ESTree.Node | undefined; if (specifier === undefined) return null; const declaration = context.sourceCode.ast.body.find( - (statement) => - statement.type === 'ImportDeclaration' && - statement.specifiers.some((entry) => entry === specifier), + (statement) => statement.type === 'ImportDeclaration' && statement.specifiers.some((entry) => entry === specifier), ); if (declaration?.type !== 'ImportDeclaration') return null; const path = [...members]; if (specifier.type === 'ImportSpecifier') - members.unshift( - specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value, - ); + members.unshift(specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value); const source = declaration.source.value; if (specifier.type === 'ImportDefaultSpecifier' && source === 'expect') members.unshift('expect'); return stableAssertion(context, expression, path, importedAssertion(source, members, subject)); } -function assertionAlias( - context: Context, - expression: ESTree.Node, - members: string[], -): ESTree.Node | null { +function assertionAlias(context: Context, expression: ESTree.Node, members: string[]): ESTree.Node | null { const destructured = destructuredMethod(context, expression); if (destructured === null) return constInitialiser(context, expression); members.unshift(destructured.method); @@ -397,18 +376,12 @@ function assertionAliasTarget( const property = pattern.properties.find( (entry) => entry.type === 'Property' && assertionPropertyName(context, entry) === members[0], ); - return property?.type === 'Property' - ? assertionAliasTarget(context, property.value, members.slice(1)) - : null; + return property?.type === 'Property' ? assertionAliasTarget(context, property.value, members.slice(1)) : null; } -function assertionMemberTail( - node: ESTree.Node, - members: readonly string[], -): readonly string[] | null { +function assertionMemberTail(node: ESTree.Node, members: readonly string[]): readonly string[] | null { const parent = node.parent; - if (members.length === 0 || parent?.type !== 'MemberExpression' || parent.object !== node) - return null; + if (members.length === 0 || parent?.type !== 'MemberExpression' || parent.object !== node) return null; const member = memberPropertyName(parent); return member === null || member === members[0] ? members.slice(1) : null; } @@ -457,8 +430,7 @@ function assertionBindingWrite( seen.set(variable, paths.add(path)); return variable.references.some( (reference) => - (reference.isWrite() && !reference.init) || - assertionReferenceWrite(context, reference.identifier, members, seen), + (reference.isWrite() && !reference.init) || assertionReferenceWrite(context, reference.identifier, members, seen), ); } @@ -469,9 +441,7 @@ function stableAssertion( members: readonly string[], assertion: AssertionCall | null, ): AssertionCall | null { - return assertion?.expectedWrapper === true && assertionBindingWrite(context, expression, members) - ? null - : assertion; + return assertion?.expectedWrapper === true && assertionBindingWrite(context, expression, members) ? null : assertion; } /** Resolve assertion imports through lexical bindings, aliases, and matcher modifiers. */ @@ -494,8 +464,7 @@ function assertionCall(context: Context, call: ESTree.CallExpression): Assertion expression = unwrap(expression.object as ESTree.Node); continue; } - if (expression.type !== 'CallExpression') - return assertionImport(context, expression, members, subject); + if (expression.type !== 'CallExpression') return assertionImport(context, expression, members, subject); if (subject !== null) return null; subject = expression; expression = unwrap(expression.callee); @@ -562,10 +531,7 @@ function describe(context: Context, node: ESTree.Node): string { return text.length > MAX_TEXT_LENGTH ? `${text.slice(0, MAX_TEXT_LENGTH - 1)}…` : text; } -function importsEffectOrBarrel( - program: ESTree.Program, - reexportModules: readonly string[], -): boolean { +function importsEffectOrBarrel(program: ESTree.Program, reexportModules: readonly string[]): boolean { if (collectEffectBindings(program).importsEffect) return true; for (const statement of program.body) { if (statement.type !== 'ImportDeclaration') continue; @@ -579,11 +545,7 @@ function importsEffectOrBarrel( * through the file's real Effect import bindings. A conventional name or a shadow is not evidence. * Direct-member imports and unknown re-export barrels are not resolved for this optional exclusion. */ -function combinatorName( - context: Context, - node: ESTree.CallExpression, - bindings: EffectBindings, -): string | null { +function combinatorName(context: Context, node: ESTree.CallExpression, bindings: EffectBindings): string | null { const callee = unwrap(node.callee); if (callee.type !== 'MemberExpression') return null; const object = unwrap(callee.object); @@ -591,8 +553,7 @@ function combinatorName( const member = memberPropertyName(callee); if (member === null) return null; const variable = resolveVariable(context, object.name, object); - if (variable === null || !variable.defs.some((definition) => definition.type === 'ImportBinding')) - return null; + if (variable === null || !variable.defs.some((definition) => definition.type === 'ImportBinding')) return null; const namespace = bindings.namespaces.get(object.name); if (namespace === undefined) return null; const members = new Map([ @@ -603,30 +564,20 @@ function combinatorName( return members?.has(member) ? `${namespace}.${member}` : null; } -const FUNCTION_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionExpression', - 'FunctionDeclaration', -]); +const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration']); /** * True when `node` sits inside a function passed *directly* as an argument to one of the error / * predicate combinators above. Walks parent links, so `pipe(x, Effect.catch((e) => e._tag === 'A'))` * and `x.pipe(Effect.mapError(fn))` are both recognised. */ -function insideErrorCombinator( - context: Context, - node: ESTree.Node, - bindings: EffectBindings, -): boolean { +function insideErrorCombinator(context: Context, node: ESTree.Node, bindings: EffectBindings): boolean { let current: ESTree.Node | null = node; for (let depth = 0; current !== null && depth < 512; depth += 1) { const candidate: ESTree.Node = current; const parent: ESTree.Node | null = candidate.parent ?? null; if (FUNCTION_TYPES.has(candidate.type) && parent !== null && parent.type === 'CallExpression') { - const isArgument = parent.arguments.some( - (argument) => (argument as ESTree.Node) === candidate, - ); + const isArgument = parent.arguments.some((argument) => (argument as ESTree.Node) === candidate); if (isArgument && combinatorName(context, parent, bindings) !== null) return true; } if (parent === candidate) return false; @@ -655,11 +606,7 @@ function isTagKey(property: ESTree.Node & { key?: ESTree.Node; computed?: boolea * (`{ _tag }`), renamed (`{ _tag: classification }`), defaulted (`{ _tag = 'none' }`) or nested * (`{ reason: { _tag } }`, `[{ _tag }]`). */ -function patternBindsTag( - pattern: ESTree.Node | null | undefined, - name: string, - depth = 0, -): boolean { +function patternBindsTag(pattern: ESTree.Node | null | undefined, name: string, depth = 0): boolean { if (pattern === null || pattern === undefined || depth > MAX_DEPTH) return false; switch (pattern.type) { case 'ObjectPattern': @@ -682,17 +629,14 @@ function propertyBindsTag(property: ESTree.Node, name: string, depth: number): b computed?: boolean; value: ESTree.Node; }; - return isTagKey(entry) - ? bindsName(entry.value, name, depth + 1) - : patternBindsTag(entry.value, name, depth + 1); + return isTagKey(entry) ? bindsName(entry.value, name, depth + 1) : patternBindsTag(entry.value, name, depth + 1); } /** `true` when a (possibly defaulted) binding target is exactly the identifier `name`. */ function bindsName(target: ESTree.Node | null | undefined, name: string, depth = 0): boolean { if (target === null || target === undefined || depth > MAX_DEPTH) return false; if (target.type === 'Identifier') return target.name === name; - if (target.type === 'AssignmentPattern') - return bindsName(target.left as ESTree.Node, name, depth + 1); + if (target.type === 'AssignmentPattern') return bindsName(target.left as ESTree.Node, name, depth + 1); return false; } @@ -717,8 +661,7 @@ function tagAliasOrigin(context: Context, node: ESTree.Node): ESTree.Node | null if (expression.type !== 'Identifier') return undefined; const variable = resolveVariable(context, expression.name, expression); if (variable === null) return undefined; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return undefined; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return undefined; for (const def of variable.defs) { const declaration = def.node as ESTree.Node | undefined; if (declaration === undefined) continue; @@ -734,9 +677,7 @@ function declarationTagOrigin( name: string, ): ESTree.Node | null | undefined { if (declaration.type !== 'VariableDeclarator') { - return definitionPatterns(declaration).some((pattern) => patternBindsTag(pattern, name)) - ? null - : undefined; + return definitionPatterns(declaration).some((pattern) => patternBindsTag(pattern, name)) ? null : undefined; } const init = declaration.init ?? null; if (isNamedIdentifier(declaration.id, name) && init !== null) { @@ -755,12 +696,7 @@ interface TagReference { * Resolve every spelling of "this expression is the `_tag` discriminant": a direct member access, a * local binding that holds one, `String(tag)` laundering and tag-derived string surgery. */ -function tagReference( - context: Context, - node: ESTree.Node, - options: RuleOptions, - depth = 0, -): TagReference | null { +function tagReference(context: Context, node: ESTree.Node, options: RuleOptions, depth = 0): TagReference | null { if (depth > MAX_DEPTH) return null; const expression = unwrap(node); @@ -778,9 +714,7 @@ function tagReference( return { origin, fallback: expression.name }; } - return expression.type === 'CallExpression' - ? callTagReference(context, expression, options, depth) - : null; + return expression.type === 'CallExpression' ? callTagReference(context, expression, options, depth) : null; } function callTagReference( @@ -833,8 +767,7 @@ function globalNamespaceCall( function isRegexReceiver(context: Context, node: ESTree.Node, depth = 0): boolean { if (depth > 2) return false; const expression = unwrap(node); - if (expression.type === 'Literal' && (expression as { regex?: unknown }).regex !== undefined) - return true; + if (expression.type === 'Literal' && (expression as { regex?: unknown }).regex !== undefined) return true; if (expression.type === 'NewExpression') { const callee = unwrap(expression.callee as ESTree.Node); if (callee.type !== 'Identifier' || callee.name !== 'RegExp') return false; @@ -853,17 +786,13 @@ function containerElements(expression: ESTree.Node): ESTree.ArrayExpression['ele } /** Only map callback results propagate tag values; arbitrary callback reads do not. */ -function mappedTagValues( - context: Context, - expression: ESTree.CallExpression, -): readonly ESTree.Node[] { +function mappedTagValues(context: Context, expression: ESTree.CallExpression): readonly ESTree.Node[] { const callee = unwrap(expression.callee); if (callee.type !== 'MemberExpression' || memberPropertyName(callee) !== 'map') return []; const first = firstArgument(expression); if (first === null) return []; const callback = unwrap(constInitialiser(context, first) ?? first); - if (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression') - return []; + if (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression') return []; return callback.body === null ? [] : [callback.body]; } @@ -895,9 +824,7 @@ function returnedTagValues(expression: ESTree.Node): readonly ESTree.Node[] { case 'ReturnStatement': return expression.argument === null ? [] : [expression.argument]; case 'IfStatement': - return expression.alternate === null - ? [expression.consequent] - : [expression.consequent, expression.alternate]; + return expression.alternate === null ? [expression.consequent] : [expression.consequent, expression.alternate]; default: return []; } @@ -942,8 +869,7 @@ type ExpectedShapeVisits = readonly [Set, Set]; function expectedObjectTags(context: Context, shape: ESTree.Node): readonly ESTree.Node[] { if (shape.type !== 'ObjectExpression') return []; const tag = shape.properties.find( - (property) => - property.type === 'Property' && assertionPropertyName(context, property) === TAG_PROPERTY, + (property) => property.type === 'Property' && assertionPropertyName(context, property) === TAG_PROPERTY, ); return tag?.type === 'Property' ? [tag.value] : []; } @@ -974,8 +900,7 @@ function expectedShapeTags( if (depth > MAX_DEPTH || visited.has(shape)) return []; visited.add(shape); const initialiser = constInitialiser(context, shape); - if (initialiser !== null) - return expectedShapeTags(context, initialiser, contained, depth + 1, seen); + if (initialiser !== null) return expectedShapeTags(context, initialiser, contained, depth + 1, seen); if (contained) return expectedContainedTags(context, shape, depth, seen); if (shape.type !== 'CallExpression') return expectedObjectTags(context, shape); const wrapper = assertionCall(context, shape); @@ -1017,8 +942,7 @@ export const rule = defineRule({ '`Schema.is(TaggedError)`, or `Effect.catchTag(s)`.', }, messages: { - tagSwitch: - 'Manual `_tag` switching must use Effect Match.tag/Match.tags or typed error handlers.', + tagSwitch: 'Manual `_tag` switching must use Effect Match.tag/Match.tags or typed error handlers.', tagEquality: "Manual `_tag` comparison on `{{text}}` (`{{operator}} '{{tag}}'`) re-implements pattern matching by " + 'hand and silently stops matching when the tag vocabulary moves (audit A4 / C2). Use ' + @@ -1083,10 +1007,7 @@ export const rule = defineRule({ if (!matchesGlobs(path, options.include)) return {}; if (options.ignoreTests && isTestFile(path)) return {}; const bindings = collectEffectBindings(context.sourceCode.ast); - if ( - options.requireEffectImport && - !importsEffectOrBarrel(context.sourceCode.ast, options.reexportModules) - ) { + if (options.requireEffectImport && !importsEffectOrBarrel(context.sourceCode.ast, options.reexportModules)) { return {}; } @@ -1195,8 +1116,7 @@ export const rule = defineRule({ function checkShape(node: ESTree.CallExpression): boolean { // `Object.hasOwn(error, '_tag')` / `Reflect.has(error, '_tag')` — `'_tag' in error` by another name. const shapeProbe = - globalNamespaceCall(context, node, 'Object', 'hasOwn') || - globalNamespaceCall(context, node, 'Reflect', 'has'); + globalNamespaceCall(context, node, 'Object', 'hasOwn') || globalNamespaceCall(context, node, 'Reflect', 'has'); if (shapeProbe && node.arguments.length >= 2) { const target = node.arguments[0] as ESTree.Node; const key = node.arguments[1] as ESTree.Node; @@ -1236,11 +1156,7 @@ export const rule = defineRule({ return false; } - function checkStringProbe( - node: ESTree.CallExpression, - receiver: ESTree.Node, - method: string, - ): boolean { + function checkStringProbe(node: ESTree.CallExpression, receiver: ESTree.Node, method: string): boolean { // `error._tag.startsWith('Contacts')`, `String(error._tag).endsWith('Problem')`. if (STRING_PROBES.has(method)) { const reference = tagOf(receiver); @@ -1260,11 +1176,7 @@ export const rule = defineRule({ return false; } - function checkRegexProbe( - node: ESTree.CallExpression, - receiver: ESTree.Node, - method: string, - ): boolean { + function checkRegexProbe(node: ESTree.CallExpression, receiver: ESTree.Node, method: string): boolean { // `/^Contacts/u.test(error._tag)` — the mirrored spelling of the same naming-convention probe. if (REGEX_PROBES.has(method) && isRegexReceiver(context, receiver)) { const argument = node.arguments[0] as ESTree.Node | undefined; @@ -1327,12 +1239,7 @@ export const rule = defineRule({ function checkPropertyAssertion(node: ESTree.CallExpression, subject: ESTree.CallExpression) { const assertedPath = firstArgument(node); const target = firstArgument(subject); - if ( - assertedPath === null || - target === null || - !tagPropertyPath(context, assertedPath) || - suppressed(node) - ) + if (assertedPath === null || target === null || !tagPropertyPath(context, assertedPath) || suppressed(node)) return; const expected = node.arguments[1]; if (expected?.type === 'SpreadElement') return; @@ -1340,7 +1247,10 @@ export const rule = defineRule({ context.report({ node, messageId: expected === undefined ? 'tagPresenceCheck' : 'tagEqualityCall', - data: { callee: describe(context, node.callee), text: describe(context, target) }, + data: { + callee: describe(context, node.callee), + text: describe(context, target), + }, }); } @@ -1352,10 +1262,7 @@ export const rule = defineRule({ const tags = expectedShapeTags(context, expected); if (!tags.some((tag) => !exemptStaticTag(tag))) return false; if (suppressed(node)) return false; - reportAssertion( - node, - describe(context, assertion.subject?.arguments[0] ?? node.arguments[0] ?? node), - ); + reportAssertion(node, describe(context, assertion.subject?.arguments[0] ?? node.arguments[0] ?? node)); return true; } @@ -1391,11 +1298,7 @@ export const rule = defineRule({ if (!ASSERTION_METHODS.has(assertion.method)) return false; return checkObjectAssertion(node, assertion) || checkAssertionValues(node, assertion); } - function checkReceiverProbes( - node: ESTree.CallExpression, - receiver: ESTree.Node, - method: string, - ) { + function checkReceiverProbes(node: ESTree.CallExpression, receiver: ESTree.Node, method: string) { if (checkStringProbe(node, receiver, method)) return; if (checkRegexProbe(node, receiver, method)) return; checkMembership(node, receiver, method); @@ -1405,8 +1308,7 @@ export const rule = defineRule({ if (checkShape(node) || checkEqualityCall(node)) return; const callee = assertionCallee(context, node.callee); const assertion = assertionCall(context, node); - const method = - callee.type === 'MemberExpression' ? memberPropertyName(callee) : assertion?.method; + const method = callee.type === 'MemberExpression' ? memberPropertyName(callee) : assertion?.method; const receiver = callee.type === 'MemberExpression' ? callee.object : null; if (method === null || method === undefined) return; if (checkAssertion(node, assertion) || receiver === null) return; diff --git a/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts b/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts index 51e9fd4b6..09c7d940c 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts @@ -106,13 +106,12 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import { keyName, memberName, unwrapNode } from '../shared/ast.ts'; import { bindingsFor } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { booleanOption as boolean, stringList } from '../shared/options.ts'; -import { keyName, memberName, unwrapNode } from '../shared/ast.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; @@ -198,10 +197,7 @@ function resolveVariable(context: Context, name: string, from: AnyNode): Variabl if ( variable !== undefined && variable.defs.some( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), + (def) => !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes(def.node.type), ) ) return variable; @@ -311,9 +307,7 @@ export const rule = defineRule({ * (`globalThis.Error`, `window["TypeError"]`), through parens, `as` casts and optional chains. */ const isContainer = (node: AnyNode): boolean => - node.type === 'Identifier' && - CONTAINER_GLOBALS.has(node.name) && - isUnshadowedGlobal(context, node, node.name); + node.type === 'Identifier' && CONTAINER_GLOBALS.has(node.name) && isUnshadowedGlobal(context, node, node.name); const immutableDeclaration = (identifier: Extract) => { const variable = resolveVariable(context, identifier.name, identifier); @@ -336,13 +330,11 @@ export const rule = defineRule({ const destructuredErrorName = (pattern: ESTree.ObjectPattern, name: string): string | null => { for (const property of pattern.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) + if (property.type !== 'Property' || property.value.type !== 'Identifier' || property.value.name !== name) continue; - const key = keyName(property.key, property.computed, { templates: true }); + const key = keyName(property.key, property.computed, { + templates: true, + }); if (key !== null && constructors.has(key)) return key; } return null; @@ -357,8 +349,7 @@ export const rule = defineRule({ const declaration = immutableDeclaration(identifier); if (!declaration?.init) return null; if (declaration.id.type === 'Identifier') return nativeErrorName(declaration.init, depth + 1); - if (declaration.id.type !== 'ObjectPattern' || !isContainer(unwrap(declaration.init))) - return null; + if (declaration.id.type !== 'ObjectPattern' || !isContainer(unwrap(declaration.init))) return null; return destructuredErrorName(declaration.id, name); }; diff --git a/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts b/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts index 0d2f2a633..18511d6fb 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts @@ -69,17 +69,9 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; -import { - EXPRESSION_WRAPPERS, - keyName, - memberName, - parentOf, - skipWrappers, - unwrapNode, -} from '../shared/ast.ts'; +import { EXPRESSION_WRAPPERS, keyName, memberName, parentOf, skipWrappers, unwrapNode } from '../shared/ast.ts'; import { isJsonHost, jsonExpressionSnippet } from '../shared/json-globals.ts'; import { booleanOption, stringList } from '../shared/options.ts'; import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; @@ -89,12 +81,7 @@ type AnyNode = ESTree.Node; /** Globals that expose the ambient `JSON` object as a property (`globalThis.JSON.parse`). */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self', 'frames']); -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; interface RuleOptions { readonly allowPaths: readonly string[]; @@ -118,7 +105,11 @@ function readOptions(raw: unknown): RuleOptions { }; } -const UNWRAP_OPTIONS = { wrappers: EXPRESSION_WRAPPERS, maxDepth: 8, sequence: true }; +const UNWRAP_OPTIONS = { + wrappers: EXPRESSION_WRAPPERS, + maxDepth: 8, + sequence: true, +}; const STRING_OPTIONS = { templates: true, rawTemplates: false, @@ -165,8 +156,7 @@ export const rule = defineRule({ allowPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs of files allowed to call `JSON.parse` — a ratified carve-out only (default: none).', + description: 'Globs of files allowed to call `JSON.parse` — a ratified carve-out only (default: none).', }, ignoreTestFiles: { type: 'boolean', @@ -176,14 +166,17 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, }, }, ], defaultOptions: [ - { allowPaths: [], ignoreTestFiles: true, includePaths: [...DEFAULT_INCLUDE_PATHS] }, + { + allowPaths: [], + ignoreTestFiles: true, + includePaths: [...DEFAULT_INCLUDE_PATHS], + }, ], }, create(context) { @@ -197,7 +190,9 @@ export const rule = defineRule({ context.report({ node, messageId, - data: { expression: jsonExpressionSnippet(context.sourceCode.getText(node)) }, + data: { + expression: jsonExpressionSnippet(context.sourceCode.getText(node)), + }, }); }; diff --git a/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts b/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts index c5211a6ac..5b54aa99e 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts @@ -64,11 +64,8 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { jsonExpressionSnippet } from '../shared/json-globals.ts'; -import { inJsonRuleScope } from '../shared/json-rule-scope.ts'; import { EXPRESSION_WRAPPERS, identityUnwrap as unwrap, @@ -78,6 +75,8 @@ import { staticString, } from '../shared/ast.ts'; import { isUnshadowedGlobal } from '../shared/bindings.ts'; +import { jsonExpressionSnippet } from '../shared/json-globals.ts'; +import { inJsonRuleScope } from '../shared/json-rule-scope.ts'; type AnyNode = ESTree.Node; @@ -109,7 +108,9 @@ function staticPropertyName(node: ESTree.MemberExpression): string | null { } function keyName(key: AnyNode): string | null { - return sharedKeyName(key, false, { unwrap: { wrappers: EXPRESSION_WRAPPERS, sequence: true } }); + return sharedKeyName(key, false, { + unwrap: { wrappers: EXPRESSION_WRAPPERS, sequence: true }, + }); } const OWNER_WRAPPERS = new Set([ @@ -162,9 +163,7 @@ function isJsonHost(context: Context, node: AnyNode): boolean { const host = unwrap(node); if (host.type === 'Identifier') return isUnshadowedGlobal(context, host, 'JSON', true); return ( - host.type === 'MemberExpression' && - staticPropertyName(host) === 'JSON' && - isGlobalContainer(context, host.object) + host.type === 'MemberExpression' && staticPropertyName(host) === 'JSON' && isGlobalContainer(context, host.object) ); } @@ -185,13 +184,14 @@ function callMessage(call: AnyNode): string { return 'jsonStringifyEquality'; if (isKeyConsumer(consumer, result)) return 'jsonStringifyIdentityKey'; const owner = ownerName(call); - return owner !== null && IDENTITY_NAME.test(owner) - ? 'jsonStringifyIdentityKey' - : 'nativeJsonStringify'; + return owner !== null && IDENTITY_NAME.test(owner) ? 'jsonStringifyIdentityKey' : 'nativeJsonStringify'; } /** Called references anchor at their call; point-free references anchor at capture. */ -function classify(reference: AnyNode): { readonly node: AnyNode; readonly messageId: string } { +function classify(reference: AnyNode): { + readonly node: AnyNode; + readonly messageId: string; +} { const { node: callee, parent } = skipWrappers(reference); if (parent?.type !== 'CallExpression' || parent.callee !== callee) return { node: reference, messageId: 'jsonStringifyReference' }; @@ -243,7 +243,11 @@ export const rule = defineRule({ }, ], defaultOptions: [ - { allowPaths: [], ignoreTestFiles: true, includePaths: [...DEFAULT_INCLUDE_PATHS] }, + { + allowPaths: [], + ignoreTestFiles: true, + includePaths: [...DEFAULT_INCLUDE_PATHS], + }, ], }, create(context) { @@ -253,7 +257,9 @@ export const rule = defineRule({ context.report({ node, messageId, - data: { expression: jsonExpressionSnippet(context.sourceCode.getText(node)) }, + data: { + expression: jsonExpressionSnippet(context.sourceCode.getText(node)), + }, }); }; diff --git a/app/tools/oxlint/effect-native/rules/no-native-timers.ts b/app/tools/oxlint/effect-native/rules/no-native-timers.ts index 45eb81d00..9069f4d98 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-timers.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-timers.ts @@ -58,7 +58,6 @@ * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; @@ -82,12 +81,7 @@ const DEFAULT_TIMER_GLOBALS: readonly string[] = [ const DEFAULT_GLOBAL_OBJECTS: readonly string[] = ['globalThis', 'window', 'global', 'self']; /** Node timer modules; importing any of them is already the anti-pattern. */ -const DEFAULT_TIMER_MODULES: readonly string[] = [ - 'node:timers', - 'timers', - 'node:timers/promises', - 'timers/promises', -]; +const DEFAULT_TIMER_MODULES: readonly string[] = ['node:timers', 'timers', 'node:timers/promises', 'timers/promises']; /** `Effect.*` operators whose behaviour is defined by the ambient `Clock`. */ const DEFAULT_EFFECT_TIME_MEMBERS: readonly string[] = [ @@ -113,19 +107,10 @@ const DEFAULT_DATE_TIME_MEMBERS: readonly string[] = ['now', 'unsafeNow', 'nowUn /** Identifier names that prove the file drives virtual time. */ const DEFAULT_TEST_CLOCK_INDICATORS: readonly string[] = ['TestClock']; -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; /** D tier: Promise/timer adapters forced by Playwright and other browser drivers. */ -const DEFAULT_IGNORE_PATHS: readonly string[] = [ - '**/tests/e2e/**', - '**/*.e2e.*', - '**/playwright/**', -]; +const DEFAULT_IGNORE_PATHS: readonly string[] = ['**/tests/e2e/**', '**/*.e2e.*', '**/playwright/**']; /** `effect/Effect`-style submodules whose named exports are the time operators themselves. */ const SUBMODULE_SOURCE = /^effect\/(Effect|Schedule|Clock|DateTime)$/u; @@ -187,8 +172,7 @@ interface Site { function staticKey(node: AnyNode, computed: boolean): string | null { if (!computed && node.type === 'Identifier') return node.name; if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? null; + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? null; return null; } @@ -224,8 +208,7 @@ function resolve(context: Context, node: AnyNode, name: string): Resolution { if (variable !== undefined) { if (variable.defs.length === 0) return 'global'; if (variable.defs.some((definition) => definition.type === 'ImportBinding')) return 'import'; - if (variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable')) - return 'global'; + if (variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable')) return 'global'; return 'shadowed'; } scope = scope.upper; @@ -309,8 +292,7 @@ export const rule = defineRule({ allowNodeTestMockTimers: { type: 'boolean', description: - 'Allow native timers in a file that calls `mock.timers.enable(...)` from node:test ' + - '(default: true).', + 'Allow native timers in a file that calls `mock.timers.enable(...)` from node:test ' + '(default: true).', }, adapterFiles: { type: 'array', @@ -322,26 +304,22 @@ export const rule = defineRule({ ignore: { type: 'array', items: { type: 'string' }, - description: - 'Globs exempted from this rule (default: **/tests/e2e/**, **/*.e2e.*, **/playwright/**).', + description: 'Globs exempted from this rule (default: **/tests/e2e/**, **/*.e2e.*, **/playwright/**).', }, includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, testPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs force-treated as test files, overriding the built-in test-file detection.', + description: 'Globs force-treated as test files, overriding the built-in test-file detection.', }, productionPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs force-treated as production files even when the built-in test-file detection matches.', + description: 'Globs force-treated as production files even when the built-in test-file detection matches.', }, timerGlobals: { type: 'array', @@ -353,8 +331,7 @@ export const rule = defineRule({ globalObjects: { type: 'array', items: { type: 'string' }, - description: - 'Objects that expose the timer globals (default: globalThis, window, global, self).', + description: 'Objects that expose the timer globals (default: globalThis, window, global, self).', }, timerModules: { type: 'array', @@ -378,8 +355,7 @@ export const rule = defineRule({ dateTimeMembers: { type: 'array', items: { type: 'string' }, - description: - 'DateTime members treated as real-time reads (default: now, unsafeNow, nowUnsafe).', + description: 'DateTime members treated as real-time reads (default: now, unsafeNow, nowUnsafe).', }, testClockIndicators: { type: 'array', @@ -431,7 +407,10 @@ export const rule = defineRule({ /** local name → imported timer member, or `*` for a namespace import. */ const timerBindings = new Map(); - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; + let bindings: EffectBindings = { + namespaces: new Map(), + importsEffect: false, + }; let hasTestClock = false; let hasMockTimers = false; const testingNamespaces = new Set(); @@ -449,12 +428,9 @@ export const rule = defineRule({ }; /** `Effect.sleep` / `E["sleep"]` / `Schedule?.spaced` → the effect namespace + member. */ - function timeMemberOf( - node: ESTree.MemberExpression, - ): { namespace: string; member: string } | null { + function timeMemberOf(node: ESTree.MemberExpression): { namespace: string; member: string } | null { const object = unwrap(node.object); - if (object.type !== 'Identifier' || resolve(context, object, object.name) !== 'import') - return null; + if (object.type !== 'Identifier' || resolve(context, object, object.name) !== 'import') return null; const namespace = bindings.namespaces.get(object.name); if (namespace === undefined) return null; const member = staticKey(node.property, node.computed); @@ -479,10 +455,7 @@ export const rule = defineRule({ if (specifier.type === 'ImportNamespaceSpecifier' && source === 'effect/testing') testingNamespaces.add(specifier.local.name); if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + const imported = specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; if (isClockImport(source, imported)) hasTestClock = true; if (source === 'node:test' && imported === 'mock') nodeTestMocks.add(specifier.local.name); } @@ -490,19 +463,13 @@ export const rule = defineRule({ function collectTimerBindings(node: ESTree.ImportDeclaration) { for (const specifier of node.specifiers) { - if ( - specifier.type === 'ImportNamespaceSpecifier' || - specifier.type === 'ImportDefaultSpecifier' - ) { + if (specifier.type === 'ImportNamespaceSpecifier' || specifier.type === 'ImportDefaultSpecifier') { timerBindings.set(specifier.local.name, NAMESPACE_BINDING); continue; } if (specifier.type !== 'ImportSpecifier') continue; if (specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + const imported = specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; timerBindings.set(specifier.local.name, imported); } } @@ -514,19 +481,13 @@ export const rule = defineRule({ for (const specifier of node.specifiers) { if (specifier.type !== 'ImportSpecifier') continue; if (specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + const imported = specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; if (!isRealTimeMember(submodule, imported)) continue; timeSites.push({ node: specifier, callee: `${submodule}.${imported}` }); } } - function collectIdentifierCall( - node: ESTree.CallExpression, - callee: Extract, - ) { + function collectIdentifierCall(node: ESTree.CallExpression, callee: Extract) { const name = callee.name; const resolution = resolve(context, callee, name); if (resolution === 'import') { @@ -543,9 +504,7 @@ export const rule = defineRule({ if (staticKey(object.property, object.computed) !== 'timers') return false; const root = unwrap(object.object); return ( - root.type === 'Identifier' && - nodeTestMocks.has(root.name) && - resolve(context, root, root.name) === 'import' + root.type === 'Identifier' && nodeTestMocks.has(root.name) && resolve(context, root, root.name) === 'import' ); } @@ -578,12 +537,7 @@ export const rule = defineRule({ if (!timerGlobals.has(node.name) || resolve(context, node, node.name) !== 'global') return; let parent: ESTree.Node | null = node.parent; while (parent !== null && parent !== undefined) { - if ( - ['TSTypeQuery', 'TSTypeReference', 'TSQualifiedName', 'TSTypeAnnotation'].includes( - parent.type, - ) - ) - return; + if (['TSTypeQuery', 'TSTypeReference', 'TSQualifiedName', 'TSTypeAnnotation'].includes(parent.type)) return; parent = parent.parent; } // Scope references exclude declarations, labels and noncomputed keys; type queries above @@ -599,11 +553,7 @@ export const rule = defineRule({ }, ExportNamedDeclaration(node) { if (node.exportKind === 'type' || node.source === null) return; - if ( - node.specifiers.length > 0 && - node.specifiers.every((specifier) => specifier.exportKind === 'type') - ) - return; + if (node.specifiers.length > 0 && node.specifiers.every((specifier) => specifier.exportKind === 'type')) return; const source = node.source.value; if (timerModules.has(source)) importSites.push({ node, callee: source }); }, @@ -651,10 +601,7 @@ export const rule = defineRule({ const resolved = timeMemberOf(node); if (resolved === null) return; if (!isRealTimeMember(resolved.namespace, resolved.member)) return; - if ( - node.object.type === 'Identifier' && - resolve(context, node.object, node.object.name) === 'shadowed' - ) { + if (node.object.type === 'Identifier' && resolve(context, node.object, node.object.name) === 'shadowed') { return; } timeSites.push({ node, callee: printed(node) }); diff --git a/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts index 6abcef41c..9761d267b 100644 --- a/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts @@ -59,26 +59,18 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { bindingsFor, effectMember } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; import { asNode as sharedAsNode, keyName as sharedKeyName } from '../shared/ast.ts'; import { resolveVariable as sharedResolveVariable } from '../shared/bindings.ts'; +import { bindingsFor, effectMember } from '../shared/effect-imports.ts'; +import type { EffectBindings } from '../shared/effect-imports.ts'; import { importedName } from '../shared/imports.ts'; -import { sameNode as sharedSameNode, nodeKey as sharedNodeKey } from '../shared/reporting.ts'; import { matchesAny } from '../shared/paths.ts'; +import { sameNode as sharedSameNode, nodeKey as sharedNodeKey } from '../shared/reporting.ts'; /** Root-fiber entry points. Every one of these starts a fresh runtime with no inherited context. */ -const RUN_MEMBERS = new Set([ - 'runCallback', - 'runFork', - 'runPromise', - 'runPromiseExit', - 'runSync', - 'runSyncExit', -]); +const RUN_MEMBERS = new Set(['runCallback', 'runFork', 'runPromise', 'runPromiseExit', 'runSync', 'runSyncExit']); /** Context-capturing variants — the S1 target shape at the single unavoidable Promise boundary. */ const WITH_MEMBERS = new Set([ @@ -93,10 +85,7 @@ const WITH_MEMBERS = new Set([ /** Namespaces whose call arguments are Effect-owned code: callbacks there run inside a fiber. */ const OWNING_NAMESPACES = new Set(['Effect', 'Fiber', 'Layer', 'Schedule', 'Scope', 'Stream']); -const DEFAULT_EFFECT_MODULES = [ - '@modern-js/plugin-bff/effect-client', - '@modern-js/plugin-bff/effect-edge', -]; +const DEFAULT_EFFECT_MODULES = ['@modern-js/plugin-bff/effect-client', '@modern-js/plugin-bff/effect-edge']; const EFFECT_MODULE = /^effect(?:\/.*)?$/u; @@ -169,7 +158,10 @@ function isRunMemberName(name: string): boolean { } function keyName(key: AnyNode | null, computed: boolean): string | null { - return sharedKeyName(key, computed, { templates: computed, singleQuasi: true }); + return sharedKeyName(key, computed, { + templates: computed, + singleQuasi: true, + }); } /** Property name of a `MemberExpression`, honouring computed static access. */ @@ -238,11 +230,15 @@ function collectImports(context: Context, modules: readonly string[]): FileImpor if (!isEffectPackage && !isExtraModule) continue; importsEffect = true; const submodule = isEffectPackage ? (source.split('/').at(-1) ?? '') : ''; - for (const specifier of statement.specifiers) - collectSpecifier(specifier, submodule, isExtraModule); + for (const specifier of statement.specifiers) collectSpecifier(specifier, submodule, isExtraModule); } - return { barrelLocals, bindings: { importsEffect, namespaces }, flatOwners, flatRuns }; + return { + barrelLocals, + bindings: { importsEffect, namespaces }, + flatOwners, + flatRuns, + }; } function resolveVariable(context: Context, identifier: AnyNode): Variable | null { @@ -277,8 +273,7 @@ export const rule = defineRule({ type: 'boolean', }, effectModules: { - description: - 'Extra modules whose named imports bind Effect namespaces (re-export barrels).', + description: 'Extra modules whose named imports bind Effect namespaces (re-export barrels).', items: { type: 'string' }, type: 'array', }, @@ -323,10 +318,7 @@ export const rule = defineRule({ return variable.defs.some((definition) => definition.type === 'ImportBinding'); }; - const importedNamespace = ( - identifier: AnyNode, - namespaces: ReadonlyMap, - ): string | null => { + const importedNamespace = (identifier: AnyNode, namespaces: ReadonlyMap): string | null => { if (typeof identifier.name !== 'string') return null; const namespace = namespaces.get(identifier.name); if (namespace === undefined) return null; @@ -339,8 +331,7 @@ export const rule = defineRule({ */ const namespaceOfObject = (object: AnyNode | null): string | null => { if (object === null) return null; - if (object.type === 'Identifier') - return importedNamespace(object, imports.bindings.namespaces); + if (object.type === 'Identifier') return importedNamespace(object, imports.bindings.namespaces); if (object.type !== 'MemberExpression') return null; const base = unwrap(object.object); if (base === null || base.type !== 'Identifier' || typeof base.name !== 'string') return null; @@ -379,11 +370,7 @@ export const rule = defineRule({ /** `Effect.fn("name")(body)` — peel curried calls to reach the Effect-family member. */ const calleeOwner = (callee: AnyNode | null): string | null => { let current = callee; - for ( - let guard = 0; - current !== null && current.type === 'CallExpression' && guard < 8; - guard += 1 - ) { + for (let guard = 0; current !== null && current.type === 'CallExpression' && guard < 8; guard += 1) { current = unwrap(current.callee); } return owningNamespaceOf(current); @@ -435,22 +422,13 @@ export const rule = defineRule({ }; const callbackBinding = (parent: AnyNode, child: AnyNode): AnyNode | null => { - if ( - parent.type === 'VariableDeclarator' && - isFunctionNode(child) && - sameNode(unwrap(parent.init), child) - ) - return asNode(parent.id); - if (parent.type === 'FunctionDeclaration' && sameNode(asNode(parent.body), child)) + if (parent.type === 'VariableDeclarator' && isFunctionNode(child) && sameNode(unwrap(parent.init), child)) return asNode(parent.id); + if (parent.type === 'FunctionDeclaration' && sameNode(asNode(parent.body), child)) return asNode(parent.id); return null; }; - const enqueueCallbackReferences = ( - identifier: AnyNode | null, - queue: AnyNode[], - seen: Set, - ): void => { + const enqueueCallbackReferences = (identifier: AnyNode | null, queue: AnyNode[], seen: Set): void => { for (const next of referenceStartsFor(identifier)) { const key = nodeKey(next); if (seen.has(key)) continue; @@ -462,11 +440,7 @@ export const rule = defineRule({ const walkOwners = (from: AnyNode, queue: AnyNode[], seen: Set): boolean => { let child = from; let parent = asNode(child.parent); - for ( - let guard = 0; - parent !== null && parent.type !== 'Program' && guard < MAX_WALK_STEPS; - guard += 1 - ) { + for (let guard = 0; parent !== null && parent.type !== 'Program' && guard < MAX_WALK_STEPS; guard += 1) { if (isOwnedArgument(parent, child)) return true; enqueueCallbackReferences(callbackBinding(parent, child), queue, seen); child = parent; @@ -498,15 +472,7 @@ export const rule = defineRule({ const report = (node: AnyNode, member: string): void => { // Type queries nested in an Effect callback are erased, not re-entry sites. for (let at = asNode(node.parent); at !== null; at = asNode(at.parent)) { - if ( - [ - 'TSTypeQuery', - 'TSTypeAnnotation', - 'TSTypeReference', - 'TSImportType', - 'TSQualifiedName', - ].includes(at.type) - ) + if (['TSTypeQuery', 'TSTypeAnnotation', 'TSTypeReference', 'TSImportType', 'TSQualifiedName'].includes(at.type)) return; } if (options.allowRuntimeCapturedRuns && WITH_MEMBERS.has(member)) return; @@ -552,10 +518,7 @@ export const rule = defineRule({ return null; }; - const definitionRunMember = ( - definition: Variable['defs'][number], - name: string, - ): string | null => { + const definitionRunMember = (definition: Variable['defs'][number], name: string): string | null => { if (definition.type === 'ImportBinding') return imports.flatRuns.get(name) ?? null; if (definition.type !== 'Variable') return null; return declaratorRunMember(definition.node, name); @@ -587,8 +550,7 @@ export const rule = defineRule({ if (!reference.isRead()) continue; const identifier = asNode(reference.identifier); if (identifier === null) continue; - if (variable.identifiers.some((declared) => sameNode(asNode(declared), identifier))) - continue; + if (variable.identifiers.some((declared) => sameNode(asNode(declared), identifier))) continue; report(identifier, member); } }; diff --git a/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts b/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts index 1d321d1f1..243b5d1aa 100644 --- a/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts +++ b/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" and * **B5** — "Adopt Effect's ADTs and temporal model consistently" @@ -76,16 +75,16 @@ import { optionRecord } from '../shared/options.ts'; * never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { matchesGlobs } from '../shared/paths.ts'; -import { acceptsRuleFile, ruleFilePolicyProperties } from '../shared/rule-file-policy.ts'; import { keyName, memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { importDeclarations, importedName } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; +import { matchesGlobs } from '../shared/paths.ts'; +import { acceptsRuleFile, ruleFilePolicyProperties } from '../shared/rule-file-policy.ts'; const SCHEMA_NAMESPACE = 'Schema'; const EFFECT_ROOT_MODULE = 'effect'; @@ -241,9 +240,10 @@ function collectSchemaLocals( reexportModules: readonly string[], ): SchemaLocals { const locals = emptyLocals(); - const accepts = (source: string): boolean => - EFFECT_SOURCE.test(source) || matchesGlobs(source, reexportModules); - for (const statement of importDeclarations(program, accepts, { valueOnly: true })) { + const accepts = (source: string): boolean => EFFECT_SOURCE.test(source) || matchesGlobs(source, reexportModules); + for (const statement of importDeclarations(program, accepts, { + valueOnly: true, + })) { const source = statement.source.value; const isBarrel = matchesGlobs(source, reexportModules) || source === EFFECT_ROOT_MODULE; for (const specifier of statement.specifiers) { @@ -295,7 +295,10 @@ const METHOD_PRESERVERS = new Set(['check', 'annotate', 'annotateKey']); /** `const S = Schema` / `const { NullOr } = Schema`: resolved after the whole file is known. */ interface AliasCandidate { - readonly local: ESTree.Node & { readonly name: string; readonly start: number }; + readonly local: ESTree.Node & { + readonly name: string; + readonly start: number; + }; readonly source: ESTree.Node; /** Property name for a destructuring candidate; `null` for a whole-namespace alias. */ readonly key: string | null; @@ -356,15 +359,10 @@ export const rule = defineRule({ const pending: PendingReport[] = []; /** `true` when this use of `name` resolves to one of the recorded declarations (no shadow). */ - const resolvesTo = ( - node: ESTree.Node, - name: string, - declarations: ReadonlySet, - ): boolean => { + const resolvesTo = (node: ESTree.Node, name: string, declarations: ReadonlySet): boolean => { const variable = resolveVariable(context, name, node); if (variable === null || variable.defs.length === 0) return true; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return false; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false; return variable.defs.some((definition) => declarations.has(definition.name.start)); }; @@ -405,8 +403,7 @@ export const rule = defineRule({ }; /** The Schema export a node refers to, however it is spelled. */ - const combinatorMember = (node: ESTree.Node): string | null => - schemaMember(node) ?? directMember(node); + const combinatorMember = (node: ESTree.Node): string | null => schemaMember(node) ?? directMember(node); /** The Schema export a call expression invokes, or `null`. */ const calledMember = (node: ESTree.Node | undefined): string | null => { @@ -429,8 +426,7 @@ export const rule = defineRule({ if (parent.type !== 'CallExpression') return null; const inner = current; const isArgument = parent.arguments.some( - (argument) => - argument === inner || (argument.start === inner.start && argument.end === inner.end), + (argument) => argument === inner || (argument.start === inner.start && argument.end === inner.end), ); return isArgument ? parent : null; } @@ -464,8 +460,7 @@ export const rule = defineRule({ let current: ESTree.Node = node; for (let guard = 0; guard < 16; guard += 1) { const parent = current.parent; - if (parent === null || parent === undefined || !UNWRAPPABLE.has(parent.type)) - return current; + if (parent === null || parent === undefined || !UNWRAPPABLE.has(parent.type)) return current; current = parent; } return current; @@ -491,12 +486,7 @@ export const rule = defineRule({ } }; - const reportCombinator = ( - node: ESTree.Node, - member: string, - replacement: string, - messageId: string, - ): void => { + const reportCombinator = (node: ESTree.Node, member: string, replacement: string, messageId: string): void => { context.report({ data: { member, replacement }, messageId, node }); }; @@ -509,8 +499,7 @@ export const rule = defineRule({ if (expression.type !== 'Identifier') return false; const variable = resolveVariable(context, expression.name, expression); const definition = soleDefinition(variable); - if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') - return false; + if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return false; const declaration = definition.node; if (!isConstDeclaration(declaration) || declaration.init === null) return false; return optionTarget(declaration.init, depth + 1); @@ -525,8 +514,7 @@ export const rule = defineRule({ if (callee.type !== 'Identifier') return false; const definition = soleDefinition(resolveVariable(context, callee.name, callee)); if (definition?.type !== 'ImportBinding') return false; - if (definition.node.type !== 'ImportSpecifier' || importedName(definition.node) !== 'pipe') - return false; + if (definition.node.type !== 'ImportSpecifier' || importedName(definition.node) !== 'pipe') return false; return ( definition.parent?.type === 'ImportDeclaration' && ['effect', 'effect/Function'].includes(definition.parent.source.value) @@ -534,10 +522,7 @@ export const rule = defineRule({ }; /** Undefined means all steps preserve the source, with no destination encountered. */ - const encodedPipeline = ( - steps: readonly ESTree.Node[], - unwrapSteps: boolean, - ): boolean | undefined => { + const encodedPipeline = (steps: readonly ESTree.Node[], unwrapSteps: boolean): boolean | undefined => { for (const argument of steps) { const step = unwrapSteps ? unwrap(argument) : argument; if (step.type !== 'CallExpression') return false; @@ -559,17 +544,14 @@ export const rule = defineRule({ const wrapper = combinatorMember(parent.callee); // encodeTo's argument is the encoded side, not the decoded destination. if (wrapper === 'encodeTo') return true; - return ( - wrapper !== null && OPTIONAL_REPLACEMENTS.has(wrapper) && isEncodedSide(parent, depth + 1) - ); + return wrapper !== null && OPTIONAL_REPLACEMENTS.has(wrapper) && isEncodedSide(parent, depth + 1); }; const encodedMethodReceiver = (parent: ESTree.MemberExpression, depth: number): boolean => { const call = parent.parent; if (call?.type !== 'CallExpression' || call.callee !== parent) return false; const method = memberName(parent); - if (method === 'pipe') - return encodedPipeline(call.arguments, true) ?? isEncodedSide(call, depth + 1); + if (method === 'pipe') return encodedPipeline(call.arguments, true) ?? isEncodedSide(call, depth + 1); return method !== null && METHOD_PRESERVERS.has(method) && isEncodedSide(call, depth + 1); }; @@ -578,10 +560,7 @@ export const rule = defineRule({ if (declaration.parent?.parent?.type === 'ExportNamedDeclaration') return false; const variable = resolveVariable(context, declaration.id.name, declaration.id); const reads = variable?.references.filter((reference) => reference.isRead()) ?? []; - return ( - reads.length > 0 && - reads.every((reference) => isEncodedSide(reference.identifier, depth + 1)) - ); + return reads.length > 0 && reads.every((reference) => isEncodedSide(reference.identifier, depth + 1)); }; /** Follow only source schemas, never Array/Struct payload boundaries. */ @@ -592,10 +571,8 @@ export const rule = defineRule({ if (!parent) return false; if (parent.type === 'CallExpression' && parent.arguments[0] === current) return encodedCallArgument(parent, depth); - if (parent.type === 'MemberExpression' && parent.object === current) - return encodedMethodReceiver(parent, depth); - if (parent.type === 'VariableDeclarator' && parent.init === current) - return encodedAlias(parent, depth); + if (parent.type === 'MemberExpression' && parent.object === current) return encodedMethodReceiver(parent, depth); + if (parent.type === 'VariableDeclarator' && parent.init === current) return encodedAlias(parent, depth); return false; }; @@ -603,10 +580,7 @@ export const rule = defineRule({ if (!options.includeOptionalKeys) return true; const innerMember = calledMember(firstArgument(node)); // Presence flags and optional(nullable) report no separate optional diagnostic. - return ( - innerMember === 'Literal' || - (innerMember !== null && NULLABLE_REPLACEMENTS.has(innerMember)) - ); + return innerMember === 'Literal' || (innerMember !== null && NULLABLE_REPLACEMENTS.has(innerMember)); }; const callReplacement = (node: ESTree.CallExpression, member: string): string => { @@ -683,10 +657,7 @@ export const rule = defineRule({ } }; - const collectPropertyAlias = ( - property: ESTree.ObjectPattern['properties'][number], - source: ESTree.Node, - ): void => { + const collectPropertyAlias = (property: ESTree.ObjectPattern['properties'][number], source: ESTree.Node): void => { if (property.type !== 'Property' || property.computed) return; if (property.value.type !== 'Identifier') return; const key = keyName(property.key, false, { templates: false }); diff --git a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts index 7899f486a..f935f291b 100644 --- a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts +++ b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" * ("Model absence and outcomes with `Option`, `Result`, `Schema.OptionFromNullOr`, or typed @@ -83,15 +82,15 @@ import { optionRecord } from '../shared/options.ts'; * Reports are informational only; this rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { typeNameSegments } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { collectRootNamespaces } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { booleanOption as boolean, positiveInteger, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -105,12 +104,7 @@ const EFFECT_TYPE = 'Effect'; const ABSENCE_TYPES = new Set(['TSNullKeyword', 'TSUndefinedKeyword']); /** Members that carry no value worth wrapping in an `Option`. */ -const VOID_LIKE_TYPES = new Set([ - 'TSVoidKeyword', - 'TSNeverKeyword', - 'TSAnyKeyword', - 'TSUnknownKeyword', -]); +const VOID_LIKE_TYPES = new Set(['TSVoidKeyword', 'TSNeverKeyword', 'TSAnyKeyword', 'TSUnknownKeyword']); function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); @@ -218,11 +212,7 @@ export const rule = defineRule({ if (reference.typeName.type !== 'Identifier' || reference.typeArguments != null) return null; const variable = rootVariable(reference); const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if ( - definition?.node.type !== 'TSTypeAliasDeclaration' || - definition.node.typeParameters != null - ) - return null; + if (definition?.node.type !== 'TSTypeAliasDeclaration' || definition.node.typeParameters != null) return null; return definition.node.typeAnnotation; }; @@ -278,18 +268,14 @@ export const rule = defineRule({ if (values.length === 0) return null; if (values.every((member) => VOID_LIKE_TYPES.has(member.type))) return null; // any/unknown absorb the union; a value member does not make their absence meaningful. - if ( - values.some( - (member) => member.type === 'TSAnyKeyword' || member.type === 'TSUnknownKeyword', - ) - ) - return null; + if (values.some((member) => member.type === 'TSAnyKeyword' || member.type === 'TSUnknownKeyword')) return null; const absenceNames = [ - ...new Set( - absent.map((member) => (member.type === 'TSNullKeyword' ? 'null' : 'undefined')), - ), + ...new Set(absent.map((member) => (member.type === 'TSNullKeyword' ? 'null' : 'undefined'))), ]; - return { absence: absenceNames.join(' | '), value: values.map(printed).join(' | ') }; + return { + absence: absenceNames.join(' | '), + value: values.map(printed).join(' | '), + }; }; const nextAlias = (current: ESTree.TSTypeReference, seen: Set) => { @@ -427,21 +413,18 @@ export const rule = defineRule({ const visitors: Record void> = { TSMethodSignature: (node: ESTree.TSMethodSignature) => checkReturnType(node.returnType), TSFunctionType: (node: ESTree.TSFunctionType) => checkReturnType(node.returnType), - TSCallSignatureDeclaration: (node: ESTree.TSCallSignatureDeclaration) => - checkReturnType(node.returnType), + TSCallSignatureDeclaration: (node: ESTree.TSCallSignatureDeclaration) => checkReturnType(node.returnType), TSConstructSignatureDeclaration: (node: ESTree.TSConstructSignatureDeclaration) => checkReturnType(node.returnType), // A bodyless class member: `abstract load(): ...`, a `declare class` method, or an overload // signature. Type-level port declarations, so never gated behind `includeAsyncFunctions`. - TSEmptyBodyFunctionExpression: (node: { - readonly returnType?: ESTree.TSTypeAnnotation | null; - }) => checkReturnType(node.returnType), + TSEmptyBodyFunctionExpression: (node: { readonly returnType?: ESTree.TSTypeAnnotation | null }) => + checkReturnType(node.returnType), } as Record void>; if (options.includeAsyncFunctions) { - const checkFunction = (node: { - readonly returnType?: ESTree.TSTypeAnnotation | null; - }): void => checkReturnType(node.returnType); + const checkFunction = (node: { readonly returnType?: ESTree.TSTypeAnnotation | null }): void => + checkReturnType(node.returnType); visitors.FunctionDeclaration = checkFunction as (node: never) => void; visitors.FunctionExpression = checkFunction as (node: never) => void; visitors.ArrowFunctionExpression = checkFunction as (node: never) => void; diff --git a/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts b/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts index d920a0b22..7b44265d6 100644 --- a/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts +++ b/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts @@ -66,31 +66,24 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; import { parentOf, isFunctionNode, unwrapNode, keyName } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; -import { importedName } from '../shared/imports.ts'; -import { sameNode } from '../shared/reporting.ts'; -import { stringArray as readStringArray } from '../shared/options.ts'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; +import { importedName } from '../shared/imports.ts'; +import { stringArray as readStringArray } from '../shared/options.ts'; import { isScriptFile, isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { sameNode } from '../shared/reporting.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; /** No browser runtime module exists yet (audit A9 lists it as a target); configure when it lands. */ const DEFAULT_CLIENT_MODULE_FILES: readonly string[] = []; const DEFAULT_CONSTRUCTOR_NAMES: readonly string[] = ['makeEffectHttpApiClient']; -const DEFAULT_CONSTRUCTOR_MEMBERS: readonly string[] = [ - 'HttpApiClient.make', - 'HttpApiClient.makeWith', -]; +const DEFAULT_CONSTRUCTOR_MEMBERS: readonly string[] = ['HttpApiClient.make', 'HttpApiClient.makeWith']; /** Derive a typed accessor from an already-injected `httpClient`: no transport is rebuilt. */ -const DEFAULT_ACCESSOR_MEMBERS: readonly string[] = [ - 'HttpApiClient.group', - 'HttpApiClient.endpoint', -]; +const DEFAULT_ACCESSOR_MEMBERS: readonly string[] = ['HttpApiClient.group', 'HttpApiClient.endpoint']; const DEFAULT_LAYER_CONSTRUCTOR_MEMBERS: readonly string[] = [ 'Layer.effect', 'Layer.sync', @@ -105,11 +98,7 @@ const DEFAULT_TRANSPARENT_MEMBERS: readonly string[] = ['Effect.gen']; /** Modules that re-export the `effect` namespaces verbatim (Modern.js BFF client). */ const DEFAULT_EFFECT_REEXPORT_MODULES: readonly string[] = ['@modern-js/plugin-bff/effect-client']; /** Call wrappers that do not change which binding an effect/function is stored under. */ -const NAME_WRAPPER_MEMBERS: ReadonlySet = new Set([ - 'Effect.gen', - 'Effect.fn', - 'Effect.fnUntraced', -]); +const NAME_WRAPPER_MEMBERS: ReadonlySet = new Set(['Effect.gen', 'Effect.fn', 'Effect.fnUntraced']); const EFFECT_MODULE = /^effect(?:\/.*)?$/u; const EXPRESSION_WRAPPERS = new Set([ @@ -143,28 +132,19 @@ interface ResolvedOptions { function resolveOptions(context: Context): ResolvedOptions { const raw = context.options?.[0]; const option: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { accessorMembers: new Set(readStringArray(option.accessorMembers, DEFAULT_ACCESSOR_MEMBERS)), clientModuleFiles: readStringArray(option.clientModuleFiles, DEFAULT_CLIENT_MODULE_FILES), - constructorMembers: new Set( - readStringArray(option.constructorMembers, DEFAULT_CONSTRUCTOR_MEMBERS), - ), + constructorMembers: new Set(readStringArray(option.constructorMembers, DEFAULT_CONSTRUCTOR_MEMBERS)), constructorNames: new Set(readStringArray(option.constructorNames, DEFAULT_CONSTRUCTOR_NAMES)), - effectReexportModules: readStringArray( - option.effectReexportModules, - DEFAULT_EFFECT_REEXPORT_MODULES, - ), + effectReexportModules: readStringArray(option.effectReexportModules, DEFAULT_EFFECT_REEXPORT_MODULES), include: readStringArray(option.include, DEFAULT_INCLUDE), includeTests: option.includeTests === true, layerConstructorMembers: new Set( readStringArray(option.layerConstructorMembers, DEFAULT_LAYER_CONSTRUCTOR_MEMBERS), ), - transparentMembers: new Set( - readStringArray(option.transparentMembers, DEFAULT_TRANSPARENT_MEMBERS), - ), + transparentMembers: new Set(readStringArray(option.transparentMembers, DEFAULT_TRANSPARENT_MEMBERS)), }; } @@ -198,7 +178,10 @@ function collectModuleImports(program: ESTree.Program): ModuleImports { for (const specifier of statement.specifiers) { if (specifier.type === 'ImportSpecifier') { if (specifier.importKind === 'type') continue; - named.set(specifier.local.name, { imported: importedName(specifier), source }); + named.set(specifier.local.name, { + imported: importedName(specifier), + source, + }); } else if (specifier.type === 'ImportNamespaceSpecifier') { namespaces.set(specifier.local.name, source); } @@ -239,7 +222,10 @@ function memberParts(node: ESTree.Node): { object: string; property: string } | if (member.object.type !== 'Identifier') return null; const object = (member.object as unknown as { name: string }).name; if (!member.computed && member.property.type === 'Identifier') { - return { object, property: (member.property as unknown as { name: string }).name }; + return { + object, + property: (member.property as unknown as { name: string }).name, + }; } if (member.computed && member.property.type === 'Literal') { const value = (member.property as unknown as { value: unknown }).value; @@ -330,8 +316,16 @@ export const rule = defineRule({ let bindings: EffectBindings | null = null; let imports: ModuleImports | null = null; let aliases: Map = new Map(); - let constructorSites: Array<{ node: ESTree.Node; text: string; kind: SiteKind }> = []; - let identifierCalls: Array<{ node: ESTree.Node; identifier: ESTree.Node; name: string }> = []; + let constructorSites: Array<{ + node: ESTree.Node; + text: string; + kind: SiteKind; + }> = []; + let identifierCalls: Array<{ + node: ESTree.Node; + identifier: ESTree.Node; + name: string; + }> = []; let layerCalls: Array<{ node: ESTree.Node; args: readonly ESTree.Node[] }> = []; /** `"Layer.effect"` for a callee, resolved through real import bindings. */ @@ -339,12 +333,7 @@ export const rule = defineRule({ const effects = bindings; if (effects === null) return null; const parts = memberParts(node); - if ( - parts !== null && - node.type === 'MemberExpression' && - bindingKind(node.object) !== 'import' - ) - return null; + if (parts !== null && node.type === 'MemberExpression' && bindingKind(node.object) !== 'import') return null; return namespaceMemberString(node, effects); } @@ -354,9 +343,7 @@ export const rule = defineRule({ const direct = memberOf(callee); if (direct !== null) return members.has(direct); if (callee.type !== 'CallExpression') return false; - const curried = memberOf( - unwrap((callee as unknown as ESTree.CallExpression).callee as unknown as ESTree.Node), - ); + const curried = memberOf(unwrap((callee as unknown as ESTree.CallExpression).callee as unknown as ESTree.Node)); return curried !== null && members.has(curried); } @@ -431,11 +418,7 @@ export const rule = defineRule({ const call = parent as unknown as ESTree.CallExpression; if (!sameNode(unwrap(call.callee as unknown as ESTree.Node), current)) { const member = memberOf(unwrap(call.callee as unknown as ESTree.Node)); - if ( - member !== null && - resolved.layerConstructorMembers.has(member) && - resultEscapesToModuleLevel(parent) - ) { + if (member !== null && resolved.layerConstructorMembers.has(member) && resultEscapesToModuleLevel(parent)) { return true; } } @@ -449,9 +432,7 @@ export const rule = defineRule({ function isNameWrapper(parent: ESTree.Node, current: ESTree.Node): boolean { if (EXPRESSION_WRAPPERS.has(parent.type)) return true; if (parent.type !== 'CallExpression') return false; - return ( - !sameNode(unwrap(parent.callee), current) && isWrapperCall(parent, NAME_WRAPPER_MEMBERS) - ); + return !sameNode(unwrap(parent.callee), current) && isWrapperCall(parent, NAME_WRAPPER_MEMBERS); } /** The binding this function is stored under, when that is a module-level name. */ @@ -526,10 +507,7 @@ export const rule = defineRule({ function classifyNamedImport(entry: NamedImport): SiteKind | null { const resolved = options; if (resolved === null) return null; - if ( - resolved.constructorNames.has(entry.imported) && - resolved.effectReexportModules.includes(entry.source) - ) + if (resolved.constructorNames.has(entry.imported) && resolved.effectReexportModules.includes(entry.source)) return 'constructor'; if (!EFFECT_MODULE.test(entry.source)) return null; return classifyMember(`${lastSegment(entry.source)}.${entry.imported}`); @@ -541,8 +519,7 @@ export const rule = defineRule({ if (parts === null) return null; const source = imports.namespaces.get(parts.object); if (source === undefined || !options.effectReexportModules.includes(source)) return null; - if (callee.type !== 'MemberExpression' || bindingKind(callee.object) !== 'import') - return null; + if (callee.type !== 'MemberExpression' || bindingKind(callee.object) !== 'import') return null; if (!options.constructorNames.has(parts.property)) return null; return { kind: 'constructor', text: `${parts.object}.${parts.property}` }; } @@ -567,8 +544,7 @@ export const rule = defineRule({ namespace: string | undefined, found: Map, ): void { - if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') - return; + if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') return; const key = keyName(property.key); if (key === null) return; const kind = @@ -602,21 +578,17 @@ export const rule = defineRule({ if (pattern.type !== 'ObjectPattern' || init.type !== 'Identifier') return; const namespace = bindings?.namespaces.get(init.name); const source = imports?.namespaces.get(init.name); - const isImportedNamespace = - source !== undefined && options?.effectReexportModules.includes(source); + const isImportedNamespace = source !== undefined && options?.effectReexportModules.includes(source); if (namespace === undefined && !isImportedNamespace) return; for (const property of pattern.properties) collectPatternProperty(property, namespace, found); } /** Collect module-level constructor rebindings without propagating operation-local aliases. */ - function collectAliases( - program: ESTree.Program, - ): Map { + function collectAliases(program: ESTree.Program): Map { const found = new Map(); if (options === null || bindings === null || imports === null) return found; for (const statement of program.body) { - const declaration = - statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; if (declaration?.type !== 'VariableDeclaration') continue; for (const entry of declaration.declarations) collectDeclaratorAlias(entry, found); } @@ -631,21 +603,15 @@ export const rule = defineRule({ if (named !== undefined) { const kind = classifyNamedImport(named); if (kind === null) return null; - return kindOfBinding === 'import' || kindOfBinding === 'unresolved' - ? { kind, text: callee.name } - : null; + return kindOfBinding === 'import' || kindOfBinding === 'unresolved' ? { kind, text: callee.name } : null; } - return kindOfBinding === 'module' || kindOfBinding === 'unresolved' - ? (aliases.get(callee.name) ?? null) - : null; + return kindOfBinding === 'module' || kindOfBinding === 'unresolved' ? (aliases.get(callee.name) ?? null) : null; } /** Recognize direct constructors and module-level aliases at their use site. */ function constructorSite(callee: ESTree.Node): { text: string; kind: SiteKind } | null { if (options === null || bindings === null || imports === null) return null; - return callee.type === 'Identifier' - ? identifierConstructorSite(callee) - : classifyMemberCallee(callee); + return callee.type === 'Identifier' ? identifierConstructorSite(callee) : classifyMemberCallee(callee); } function collectLayerConsumed(): Set { @@ -654,7 +620,10 @@ export const rule = defineRule({ for (const layerCall of layerCalls) { if (!resultEscapesToModuleLevel(layerCall.node)) continue; for (const argument of layerCall.args) { - blessedArgumentRanges.push({ end: argument.end, start: argument.start }); + blessedArgumentRanges.push({ + end: argument.end, + start: argument.start, + }); const value = unwrap(argument); if (value.type === 'Identifier' && resolvesToModuleFunction(value)) { layerConsumed.add((value as unknown as { name: string }).name); @@ -680,9 +649,7 @@ export const rule = defineRule({ constructorSpans.add(`${site.node.start}:${site.node.end}`); if (!hasFunctionAncestor(site.node)) continue; const owner = outermostModuleFunctionName(site.node); - const blessed = - isInsideBlessedLayerConstruction(site.node) || - (owner !== null && layerConsumed.has(owner)); + const blessed = isInsideBlessedLayerConstruction(site.node) || (owner !== null && layerConsumed.has(owner)); // A blessed construction still marks its function as a client factory: calling it from an // operation elsewhere in the file is a fresh client per call. if (owner !== null) factories.add(owner); diff --git a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts index bd26ad35f..083438d38 100644 --- a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts +++ b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * ("Move Bearer/JWK verification and imported key material into long-lived services rather than @@ -84,15 +83,15 @@ import { optionRecord } from '../shared/options.ts'; * Add the barrel specifier to `joseModules` if a repo ever grows one. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { memberName, keyName } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { importedName, collectNamespaceLocals } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; import { isNonReferencePosition } from '../shared/reference-positions.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -123,13 +122,7 @@ const DEFAULT_LAYER_WRAPPERS = ['effect', 'scoped', 'sync', 'unwrap', 'unwrapSco */ const DEFAULT_LAYER_BUILDER_WRAPPERS = ['effect', 'scoped', 'sync', 'unwrap', 'unwrapScoped']; /** Effect combinators that memoise the produced value instead of recomputing it per call. */ -const DEFAULT_EFFECT_WRAPPERS = [ - 'cached', - 'cachedWithTTL', - 'cachedFunction', - 'cachedInvalidateWithTTL', - 'once', -]; +const DEFAULT_EFFECT_WRAPPERS = ['cached', 'cachedWithTTL', 'cachedFunction', 'cachedInvalidateWithTTL', 'once']; /** Barrels that re-export Effect namespaces verbatim; `Layer` from them is Effect's `Layer`. */ const DEFAULT_REEXPORT_MODULES = ['@modern-js/plugin-bff/effect-edge']; @@ -147,11 +140,7 @@ const DEFAULT_GENERATOR_FILES = [ const LAYER_NAMESPACE = 'Layer'; const EFFECT_NAMESPACE = 'Effect'; -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); +const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); /** Lexical markers that prove an emitted snippet already builds the key once. */ const TEMPLATE_WRAPPER_MARKER = @@ -211,10 +200,7 @@ function recordKeyImport( if (specifier.importKind === 'type') return; const imported = importedName(specifier); if (members.includes(imported)) direct.set(specifier.local.name, imported); - } else if ( - specifier.type === 'ImportNamespaceSpecifier' || - specifier.type === 'ImportDefaultSpecifier' - ) { + } else if (specifier.type === 'ImportNamespaceSpecifier' || specifier.type === 'ImportDefaultSpecifier') { const existing = namespaces.get(specifier.local.name) ?? []; namespaces.set(specifier.local.name, [...existing, ...members]); } @@ -231,8 +217,7 @@ function collectKeyBindings(program: ESTree.Program, options: RuleOptions): KeyB const isNodeCrypto = matchesGlobs(source, options.nodeCryptoModules); if (!isJose && !isNodeCrypto) continue; const members = isJose ? options.joseMembers : options.nodeCryptoMembers; - for (const specifier of statement.specifiers) - recordKeyImport(specifier, members, direct, namespaces); + for (const specifier of statement.specifiers) recordKeyImport(specifier, members, direct, namespaces); } return { direct, namespaces }; } @@ -252,10 +237,7 @@ function collectModuleBindingNames(program: ESTree.Program): ReadonlySet } }; for (const statement of program.body) { - if ( - statement.type === 'ExportNamedDeclaration' || - statement.type === 'ExportDefaultDeclaration' - ) { + if (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') { record(statement.declaration); } else record(statement); } @@ -267,7 +249,11 @@ function collectWrapperLocals( program: ESTree.Program, bindings: EffectBindings, options: RuleOptions, -): { layer: ReadonlySet; effect: ReadonlySet; barrel: ReadonlySet } { +): { + layer: ReadonlySet; + effect: ReadonlySet; + barrel: ReadonlySet; +} { const { namespaced, barrel } = collectNamespaceLocals( program, bindings, @@ -359,8 +345,7 @@ export const rule = defineRule({ const effectBindings = collectEffectBindings(program); const wrappers = collectWrapperLocals(program, effectBindings, options); const moduleNames = collectModuleBindingNames(program); - const scanTemplates = - options.scanGeneratorTemplates && matchesGlobs(path, options.generatorFiles); + const scanTemplates = options.scanGeneratorTemplates && matchesGlobs(path, options.generatorFiles); /** * Locals rebound one hop from a tracked import (`const load = importJWK`, `const { importJWK } = jose`) @@ -374,11 +359,7 @@ export const rule = defineRule({ /** Identifier nodes that merely *declare* an alias — never reported themselves. */ const bindingNodes = new Set(); /** Names worth collecting as candidates (imports plus every alias discovered so far). */ - const watched = new Set([ - ...keys.direct.keys(), - ...keys.namespaces.keys(), - ...options.subtleMembers, - ]); + const watched = new Set([...keys.direct.keys(), ...keys.namespaces.keys(), ...options.subtleMembers]); /** Member names that can ever denote key material — the cheap pre-filter for candidates. */ const interestingMembers = new Set([ ...options.joseMembers, @@ -399,42 +380,29 @@ export const rule = defineRule({ return isWrapperMember(object, member, layerMembers); }; - const isDirectWrapper = ( - name: string, - member: string, - layerMembers: readonly string[], - ): boolean => + const isDirectWrapper = (name: string, member: string, layerMembers: readonly string[]): boolean => (wrappers.layer.has(name) && layerMembers.includes(member)) || (wrappers.effect.has(name) && options.effectWrappers.includes(member)); - const isWrapperMember = ( - object: ESTree.Node, - member: string, - layerMembers: readonly string[], - ): boolean => { + const isWrapperMember = (object: ESTree.Node, member: string, layerMembers: readonly string[]): boolean => { if (object.type === 'Identifier') return isDirectWrapper(object.name, member, layerMembers); // `E.Layer.effect(…)` through `import * as E from "effect"`. if (object.type !== 'MemberExpression') return false; const namespace = memberName(object); if (namespace === null) return false; - if (object.object.type !== 'Identifier' || !wrappers.barrel.has(object.object.name)) - return false; + if (object.object.type !== 'Identifier' || !wrappers.barrel.has(object.object.name)) return false; if (namespace === LAYER_NAMESPACE) return layerMembers.includes(member); if (namespace === EFFECT_NAMESPACE) return options.effectWrappers.includes(member); return false; }; - const isEnclosingWrapper = (node: ESTree.Node): boolean => - isWrapperCall(node, options.layerWrappers); - const isBuilderWrapper = (node: ESTree.Node): boolean => - isWrapperCall(node, options.layerBuilderWrappers); + const isEnclosingWrapper = (node: ESTree.Node): boolean => isWrapperCall(node, options.layerWrappers); + const isBuilderWrapper = (node: ESTree.Node): boolean => isWrapperCall(node, options.layerBuilderWrappers); /** * The `crypto` segment of a `<…>.subtle.` chain must be a global or an import; a * parameter, local `const` or DI port named `crypto` is not WebCrypto. */ - const isImportedCryptoIdentifier = ( - node: Extract, - ): boolean => { + const isImportedCryptoIdentifier = (node: Extract): boolean => { const variable = lookupVariable(context, node); if (variable === null || variable.defs.length === 0) return node.name === 'crypto'; return variable.defs.some((definition) => { @@ -475,9 +443,7 @@ export const rule = defineRule({ ); }; const cryptoRootIsAmbient = (node: ESTree.Node): boolean => - node.type === 'MemberExpression' && - memberName(node) === 'subtle' && - isCryptoObject(node.object); + node.type === 'MemberExpression' && memberName(node) === 'subtle' && isCryptoObject(node.object); /** Stable identity for a resolved variable: the offset of its declaring identifier. */ const variableKey = (variable: Variable): number | null => { @@ -497,9 +463,7 @@ export const rule = defineRule({ }; /** Members reachable through a dynamically required/imported key module bound to `identifier`. */ - const dynamicMembers = ( - identifier: Extract, - ): readonly string[] | null => { + const dynamicMembers = (identifier: Extract): readonly string[] | null => { if (dynamicNamespaces.size === 0) return null; const variable = lookupVariable(context, identifier); if (variable === null) return null; @@ -589,10 +553,7 @@ export const rule = defineRule({ const registerSubtle = (identifier: ESTree.Node): void => register(identifier, subtleVariables); /** `const { a, b } = ` → run `onMember(key, valueIdentifier)` for each static property. */ - const eachPatternProperty = ( - pattern: ESTree.Node, - onMember: (key: string, value: ESTree.Node) => void, - ): void => { + const eachPatternProperty = (pattern: ESTree.Node, onMember: (key: string, value: ESTree.Node) => void): void => { if (pattern.type !== 'ObjectPattern') return; for (const property of pattern.properties) { const key = propertyKeyName(property); @@ -634,26 +595,25 @@ export const rule = defineRule({ // The repo's `Context.Service` idiom: the build effect lives in a named module-level factory // that is handed to `Layer.effect`/`Effect.cached*` by reference. Built once per Layer build. if (isReferencedBuilder(outermostName)) return; - context.report({ node: candidate.report, messageId: candidate.messageId, data: { callee } }); + context.report({ + node: candidate.report, + messageId: candidate.messageId, + data: { callee }, + }); }; const isCalleePosition = (node: ESTree.Node): boolean => { const parent = node.parent; if (parent === null || parent === undefined) return false; - if (parent.type === 'CallExpression' || parent.type === 'NewExpression') - return parent.callee === node; + if (parent.type === 'CallExpression' || parent.type === 'NewExpression') return parent.callee === node; return false; }; - const registerIdentifierBinding = ( - id: ESTree.Node, - init: Extract, - ): void => { + const registerIdentifierBinding = (id: ESTree.Node, init: Extract): void => { const namespaceMembers = keys.namespaces.get(init.name); const isTrackedNamespace = namespaceMembers !== undefined && resolvesToImport(context, init); const isTrackedDirect = - (keys.direct.has(init.name) && resolvesToImport(context, init)) || - bindsTo(init, aliasVariables); + (keys.direct.has(init.name) && resolvesToImport(context, init)) || bindsTo(init, aliasVariables); const isTrackedSubtle = bindsTo(init, subtleVariables) || isCryptoObject(init); if (isTrackedDirect && id.type === 'Identifier') { @@ -712,9 +672,7 @@ export const rule = defineRule({ const reportTemplateMatch = (match: RegExpExecArray, text: string, seen: Set): void => { const member = match[1] ?? ''; const index = match.index + match[0].lastIndexOf(member); - const inTemplate = templateElements.some( - (entry) => index >= entry.start && index < entry.end, - ); + const inTemplate = templateElements.some((entry) => index >= entry.start && index < entry.end); if (inTemplate && !seen.has(index)) { // Scan back to the start of the *whole* template literal, not a character window, so a // realistically sized emitted `Layer.effect(…)` body still counts as already fixed. @@ -762,14 +720,14 @@ export const rule = defineRule({ CallExpression(node) { const callee = node.callee; const name = - callee.type === 'Identifier' - ? callee.name - : callee.type === 'MemberExpression' - ? memberName(callee) - : null; + callee.type === 'Identifier' ? callee.name : callee.type === 'MemberExpression' ? memberName(callee) : null; if (name === null) return; if (!watched.has(name) && !interestingMembers.has(name)) return; - candidates.push({ report: node, target: callee, messageId: 'perRequest' }); + candidates.push({ + report: node, + target: callee, + messageId: 'perRequest', + }); }, Identifier(node) { // One-hop Layer factory detection: a module-level name used inside a Layer/cached builder. @@ -793,24 +751,27 @@ export const rule = defineRule({ ) return; if (!watched.has(node.name)) return; - candidates.push({ report: node, target: node, messageId: 'perRequestReference' }); + candidates.push({ + report: node, + target: node, + messageId: 'perRequestReference', + }); }, MemberExpression(node) { if (isCalleePosition(node)) return; const parent = node.parent; // Intermediate link of a longer chain (`crypto.subtle` inside `crypto.subtle.importKey`). - if ( - parent !== null && - parent !== undefined && - parent.type === 'MemberExpression' && - parent.object === node - ) { + if (parent !== null && parent !== undefined && parent.type === 'MemberExpression' && parent.object === node) { return; } const member = memberName(node); if (member === null) return; if (!watched.has(member) && !interestingMembers.has(member)) return; - candidates.push({ report: node, target: node, messageId: 'perRequestReference' }); + candidates.push({ + report: node, + target: node, + messageId: 'perRequestReference', + }); }, TemplateElement(node) { if (!scanTemplates) return; @@ -830,15 +791,11 @@ export const rule = defineRule({ const named = [...options.joseMembers, ...options.nodeCryptoMembers]; const patterns: RegExp[] = []; // `createLocalJWKSet(`, `importJWK(` … — names distinctive enough to match bare. - if (named.length > 0) - patterns.push(new RegExp(`\\b(${named.map(escapeMember).join('|')})\\s*\\(`, 'gu')); + if (named.length > 0) patterns.push(new RegExp(`\\b(${named.map(escapeMember).join('|')})\\s*\\(`, 'gu')); // `importKey`/`generateKey` are ordinary app identifiers; only match real WebCrypto access. if (options.subtleMembers.length > 0) { patterns.push( - new RegExp( - `\\bsubtle\\s*\\??\\.\\s*(${options.subtleMembers.map(escapeMember).join('|')})\\s*\\(`, - 'gu', - ), + new RegExp(`\\bsubtle\\s*\\??\\.\\s*(${options.subtleMembers.map(escapeMember).join('|')})\\s*\\(`, 'gu'), ); } const seen = new Set(); diff --git a/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts b/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts index f9bf6feb2..412ee372f 100644 --- a/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts +++ b/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts @@ -73,31 +73,16 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - parentOf, - skipWrappers, - syntax, - propertyText, - unwrapNode as skipTransparent, -} from '../shared/ast.ts'; -import { provenance, valueReference } from '../shared/provenance.ts'; -import { - isEntryPosition as isBasicEntryPosition, - nearestFunction, -} from '../shared/script-entry.ts'; -import { scriptScope, inScriptScope, matchesGlobs } from '../shared/paths.ts'; + +import { parentOf, skipWrappers, syntax, propertyText, unwrapNode as skipTransparent } from '../shared/ast.ts'; import { stringList, positiveInteger, booleanOption } from '../shared/options.ts'; +import { scriptScope, inScriptScope, matchesGlobs } from '../shared/paths.ts'; +import { provenance, valueReference } from '../shared/provenance.ts'; +import { isEntryPosition as isBasicEntryPosition, nearestFunction } from '../shared/script-entry.ts'; /** Emitter registration methods whose callback argument is a signal/exit handler. */ -const LISTENER_METHODS = new Set([ - 'on', - 'once', - 'addListener', - 'prependListener', - 'prependOnceListener', -]); +const LISTENER_METHODS = new Set(['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']); type AnyNode = ESTree.Node; @@ -137,9 +122,7 @@ function entryContinuation(context: Context, fn: AnyNode): ESTree.CallExpression function isEntryPosition(context: Context, site: AnyNode): boolean { const fn = nearestFunction(site); const continuation = fn === null ? null : entryContinuation(context, fn); - return continuation === null - ? isBasicEntryPosition(context, site) - : isEntryPosition(context, continuation); + return continuation === null ? isBasicEntryPosition(context, site) : isEntryPosition(context, continuation); } function processObjectText(context: Context, node: AnyNode): string | null { @@ -189,8 +172,7 @@ function listenerEvent(context: Context, call: ESTree.CallExpression) { if (method === null || !LISTENER_METHODS.has(method)) return null; if (processObjectText(context, skipTransparent(callee.object)) === null) return null; const first = call.arguments[0]; - const event = - first?.type === 'Literal' && typeof first.value === 'string' ? first.value : 'signal'; + const event = first?.type === 'Literal' && typeof first.value === 'string' ? first.value : 'signal'; return { method, event }; } @@ -290,9 +272,7 @@ export const rule = defineRule({ name = (node as ESTree.IdentifierReference).name; if (identity === 'process.exit') { const { node: reference, parent: outer } = skipWrappers(self); - const isCallee = - outer?.type === 'CallExpression' && - (outer as ESTree.CallExpression).callee === reference; + const isCallee = outer?.type === 'CallExpression' && (outer as ESTree.CallExpression).callee === reference; push(isCallee ? outer : self, 'exit', isCallee ? `${name}(…)` : name); } // Destructured exitCode is a copied value, not a write to process.exitCode. @@ -306,16 +286,10 @@ export const rule = defineRule({ const self = node as unknown as AnyNode; const { node: reference, parent } = skipWrappers(self); const isCallee = - parent !== null && - parent.type === 'CallExpression' && - (parent as ESTree.CallExpression).callee === reference; + parent !== null && parent.type === 'CallExpression' && (parent as ESTree.CallExpression).callee === reference; if (property === 'exit') { - push( - isCallee ? parent : self, - 'exit', - isCallee ? `${objectText}.exit(…)` : `${objectText}.exit`, - ); + push(isCallee ? parent : self, 'exit', isCallee ? `${objectText}.exit(…)` : `${objectText}.exit`); } else if (property === 'kill') { if (isCallee) collectSelfKill(parent as ESTree.CallExpression, objectText); } else { @@ -328,10 +302,7 @@ export const rule = defineRule({ // Drop sites nested inside another site's expression (`process.exitCode = exit(1)`). const outer = ordered.filter( (site) => - !ordered.some( - (other) => - other.node !== site.node && other.start <= site.start && site.end <= other.end, - ), + !ordered.some((other) => other.node !== site.node && other.start <= site.start && site.end <= other.end), ); let allowance = options.maxExitSites; for (const site of outer) { @@ -340,7 +311,11 @@ export const rule = defineRule({ context.report({ node: site.node, messageId: 'exitInSignalHandler', - data: { site: site.site, event: handler.event, method: handler.method }, + data: { + site: site.site, + event: handler.event, + method: handler.method, + }, }); continue; } diff --git a/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts index a222690f5..65617bf27 100644 --- a/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts @@ -99,17 +99,12 @@ * `scripts/` is edited to satisfy it. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { booleanOption, compilePatterns, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { snippet } from '../shared/reporting.ts'; -import { - driverText as sharedDriverText, - emittedText, - reportNode, -} from '../shared/scaffold-text.ts'; +import { driverText as sharedDriverText, emittedText, reportNode } from '../shared/scaffold-text.ts'; import type { StringNode } from '../shared/scaffold-text.ts'; /** Files whose template literals are emitted as source code into someone else's module. */ @@ -127,12 +122,7 @@ const DEFAULT_TEMPLATE_PATHS: readonly string[] = [ ]; /** Generated or vendored output that is never hand-edited. */ -const DEFAULT_EXCLUDE: readonly string[] = [ - '**/dist/**', - '**/.output/**', - '**/node_modules/**', - '**/*.d.ts', -]; +const DEFAULT_EXCLUDE: readonly string[] = ['**/dist/**', '**/.output/**', '**/node_modules/**', '**/*.d.ts']; /** * A8 "Promise-first browser code" / A9 "~40 scattered browser `runPromise` calls". Sources, not @@ -185,9 +175,7 @@ interface Match { function readOptions(raw: unknown): RuleOptions { const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { templatePaths: stringArray(record.templatePaths, DEFAULT_TEMPLATE_PATHS), promiseFirstPatterns: stringArray(record.promiseFirstPatterns, DEFAULT_PROMISE_FIRST), @@ -211,21 +199,13 @@ function driverText(node: ESTree.Node): boolean { let parent = node.parent; if ( parent && - [ - 'ImportDeclaration', - 'ImportExpression', - 'ExportNamedDeclaration', - 'ExportAllDeclaration', - ].includes(parent.type) + ['ImportDeclaration', 'ImportExpression', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(parent.type) ) return sharedDriverText(node); while (parent) { if (/Function/u.test(parent.type) || parent.type === 'ClassBody') return false; if (parent.type === 'CallExpression' || parent.type === 'NewExpression') break; - if ( - ['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program'].includes(parent.type) - ) - break; + if (['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program'].includes(parent.type)) break; parent = parent.parent; } return sharedDriverText(node); @@ -291,28 +271,21 @@ function operationSpans(text: string): { functions: Span[]; layers: Span[] } { return { functions, layers }; } /** Collect named emitted imports without treating arbitrary receivers as Effect. */ -function collectRunnerImports( - entries: string, - source: string, - names: string[], - namespace: string[], -): void { +function collectRunnerImports(entries: string, source: string, names: string[], namespace: string[]): void { for (const entry of entries.split(',')) { const binding = /^\s*([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/u.exec(entry); if (binding === null) continue; const imported = binding[1]!; const local = binding[2] ?? imported; if (source === 'effect' && imported === 'Effect') namespace.push(local); - if (source === 'effect/Effect' && /^run(?:Promise|Sync|Fork)(?:Exit)?$/u.test(imported)) - names.push(local); + if (source === 'effect/Effect' && /^run(?:Promise|Sync|Fork)(?:Exit)?$/u.test(imported)) names.push(local); } } /** Resolve only explicit emitted Effect import aliases, never arbitrary *.runPromise receivers. */ function runnerPatterns(text: string): readonly RegExp[] { const names: string[] = []; const namespace: string[] = []; - const imports = - /\bimport\s+(?:\*\s+as\s+([\w$]+)|\{([^}]+)\})\s+from\s+['"](effect(?:\/Effect)?)['"]/gu; + const imports = /\bimport\s+(?:\*\s+as\s+([\w$]+)|\{([^}]+)\})\s+from\s+['"](effect(?:\/Effect)?)['"]/gu; for (const match of text.matchAll(imports)) { if (match[1] !== undefined && match[3] === 'effect/Effect') namespace.push(match[1]); collectRunnerImports(match[2] ?? '', match[3]!, names, namespace); @@ -321,11 +294,7 @@ function runnerPatterns(text: string): readonly RegExp[] { return [ ...names.map((name) => new RegExp(String.raw`(? - new RegExp( - String.raw`\b${escape(name)}\s*\.\s*run(?:Promise|Sync|Fork)(?:Exit)?\s*\(`, - 'gu', - ), + (name) => new RegExp(String.raw`\b${escape(name)}\s*\.\s*run(?:Promise|Sync|Fork)(?:Exit)?\s*\(`, 'gu'), ), ]; } @@ -338,7 +307,12 @@ function scan(text: string, patterns: readonly RegExp[], group: Group, found: Ma while (match !== null) { const value = match[0]; if (value.length > 0) - found.push({ start: match.index, end: match.index + value.length, text: value, group }); + found.push({ + start: match.index, + end: match.index + value.length, + text: value, + group, + }); // A user-supplied pattern may match the empty string; step past it rather than spin. if (value.length === 0) pattern.lastIndex += 1; match = pattern.exec(text); @@ -460,22 +434,15 @@ export const rule = defineRule({ const syntax = maskText(text, true); const found: Match[] = []; scan(syntax, promiseFirst, 'promiseFirst', found); - if ( - options.promiseFirstPatterns.includes( - String.raw`\bEffect\.run(?:Promise|Sync|Fork)(?:Exit)?\b`, - ) - ) + if (options.promiseFirstPatterns.includes(String.raw`\bEffect\.run(?:Promise|Sync|Fork)(?:Exit)?\b`)) scan(syntax, runnerPatterns(maskText(text, false)), 'promiseFirst', found); const spans = operationSpans(syntax); const clients: Match[] = []; scan(syntax, perCallClient, 'perCallClient', clients); for (const candidate of clients) { - const enclosing = spans.functions.filter( - (span) => span.start < candidate.start && span.end > candidate.start, - ); + const enclosing = spans.functions.filter((span) => span.start < candidate.start && span.end > candidate.start); const perOperation = enclosing.some( - (fn) => - !spans.layers.some((layer) => layer.start < fn.start && layer.end > candidate.start), + (fn) => !spans.layers.some((layer) => layer.start < fn.start && layer.end > candidate.start), ); if (options.strictPerCallClient || perOperation) found.push(candidate); } @@ -488,7 +455,9 @@ export const rule = defineRule({ context.report({ node: reportNode(node, match.start, match.end), messageId: match.group, - data: { snippet: snippet(text.slice(match.start, match.end), SNIPPET_LIMIT) }, + data: { + snippet: snippet(text.slice(match.start, match.end), SNIPPET_LIMIT), + }, }); } } diff --git a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts index 46f41f403..977951c75 100644 --- a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts +++ b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts @@ -17,19 +17,12 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; import { parentOf } from '../shared/ast.ts'; +import { createPromisePortTypeResolver } from '../shared/no-promise-port-types.ts'; import { optionRecord } from '../shared/options.ts'; import { stringArray, booleanOption as boolean } from '../shared/options.ts'; import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { createPromisePortTypeResolver } from '../shared/no-promise-port-types.ts'; - -const DEFAULT_INCLUDE = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', - 'tools/**/tests/**', -]; +const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**', 'tools/**/tests/**']; const DEFAULT_IGNORE = [ '**/dist/**', '**/build/**', @@ -85,8 +78,7 @@ function memberSegments(node: ESTree.Node): readonly string[] | null { while (current.type === 'MemberExpression') { const member = current as ESTree.MemberExpression; if (member.computed) { - if (member.property.type !== 'Literal' || typeof member.property.value !== 'string') - return null; + if (member.property.type !== 'Literal' || typeof member.property.value !== 'string') return null; segments.unshift(member.property.value); } else { if (member.property.type !== 'Identifier') return null; @@ -99,11 +91,7 @@ function memberSegments(node: ESTree.Node): readonly string[] | null { return segments; } -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); +const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); export const rule = defineRule({ meta: { @@ -195,17 +183,14 @@ export const rule = defineRule({ }; const computedKey = (key: any): unknown => { if (key.type === 'Literal') return key.value; - if (key.type === 'TemplateLiteral' && !key.expressions.length) - return key.quasis[0]?.value.cooked; + if (key.type === 'TemplateLiteral' && !key.expressions.length) return key.quasis[0]?.value.cooked; return null; }; - const importedExportName = (def: any): string | undefined => - def.node.imported?.name ?? def.node.imported?.value; + const importedExportName = (def: any): string | undefined => def.node.imported?.name ?? def.node.imported?.value; const importBindingPath = (def: any): string => { const source = def.parent?.source?.value; const name = importedExportName(def); - if (source === 'effect' || matchesGlobs(source ?? '', options.effectModules)) - return `effect:${name ?? 'root'}`; + if (source === 'effect' || matchesGlobs(source ?? '', options.effectModules)) return `effect:${name ?? 'root'}`; if (source === 'effect/Effect') return `effect:Effect${name ? `.${name}` : ''}`; return `${source}:${name ?? '*'}`; }; @@ -231,9 +216,7 @@ export const rule = defineRule({ if (!registration) return null; let factory = unwrap(registration.callee); while (factory?.type === 'CallExpression') factory = unwrap(factory.callee); - return /^effect-rstest:(?:\*\.)?(?:describeWrapped|(?:(?:it|test)\.)?layer)$/u.test( - imported(factory, seen) ?? '', - ) + return /^effect-rstest:(?:\*\.)?(?:describeWrapped|(?:(?:it|test)\.)?layer)$/u.test(imported(factory, seen) ?? '') ? 'effect-rstest:it' : null; }; @@ -259,9 +242,7 @@ export const rule = defineRule({ } // Playwright's extend factory preserves test identity, unlike arbitrary factories. if (node.type === 'CallExpression') - return imported(node.callee, seen) === '@playwright/test:test.extend' - ? '@playwright/test:test' - : null; + return imported(node.callee, seen) === '@playwright/test:test.extend' ? '@playwright/test:test' : null; return importedIdentifier(node, seen); }; const walk = (node: any, visit: (node: any) => void): void => { @@ -329,25 +310,19 @@ export const rule = defineRule({ if (def) mirrorTypes.add(def.node); }; walk(program, (node) => { - if (node.type === 'VariableDeclarator' && externalValue(node.init)) - markType(node.id.typeAnnotation); - if (node.type === 'AssignmentPattern' && externalValue(node.right)) - markType(node.left.typeAnnotation); + if (node.type === 'VariableDeclarator' && externalValue(node.init)) markType(node.id.typeAnnotation); + if (node.type === 'AssignmentPattern' && externalValue(node.right)) markType(node.left.typeAnnotation); if (FUNCTION_TYPES.has(node.type) && externalValue(node)) markType(node.returnType); - if (node.type === 'TSSatisfiesExpression' && externalValue(node.expression)) - markType(node.typeAnnotation); + if (node.type === 'TSSatisfiesExpression' && externalValue(node.expression)) markType(node.typeAnnotation); }); const withinMirror = (node: any): boolean => { - for (let current = node; current; current = current.parent) - if (mirrorTypes.has(current)) return true; + for (let current = node; current; current = current.parent) if (mirrorTypes.has(current)) return true; return false; }; /** `Effect.tryPromise` / `Eff.promise` / `E.Effect.tryPromise` / bare `tryPromise`. */ const isPromiseBoundaryCall = (call: ESTree.CallExpression): boolean => { - return /^effect:(?:root\.)?Effect\.(?:promise|tryPromise|tryMapPromise)$/u.test( - imported(call.callee) ?? '', - ); + return /^effect:(?:root\.)?Effect\.(?:promise|tryPromise|tryMapPromise)$/u.test(imported(call.callee) ?? ''); }; const isEffectPromiseRunner = (node: any): boolean => @@ -391,13 +366,10 @@ export const rule = defineRule({ if (parent === null) return false; if (parent.type === 'CallExpression') { const call = parent as unknown as ESTree.CallExpression; - const isArgument = (call.arguments as readonly ESTree.Node[]).includes( - current as ESTree.Node, - ); + const isArgument = (call.arguments as readonly ESTree.Node[]).includes(current as ESTree.Node); if (isArgument && isPromiseBoundaryCall(call)) return true; // A driver callback is forced, not every service constructed inside its body. - if (isArgument && unwrap(current) === unwrap(node) && isDriverCallbackCall(call)) - return true; + if (isArgument && unwrap(current) === unwrap(node) && isDriverCallbackCall(call)) return true; } current = parent; } @@ -436,15 +408,9 @@ export const rule = defineRule({ }; const invokedAtDriverEdge = (value: any): boolean => - value.parent?.type === 'CallExpression' && - value.parent.callee === value && - atDriverEdge(value.parent); + value.parent?.type === 'CallExpression' && value.parent.callee === value && atDriverEdge(value.parent); const foreignThunkValue = (value: any): any => { - if ( - value.parent?.type === 'Property' && - value.parent.value === value && - value.parent.key.name === 'try' - ) + if (value.parent?.type === 'Property' && value.parent.value === value && value.parent.key.name === 'try') return value.parent.parent; return value; }; @@ -452,11 +418,7 @@ export const rule = defineRule({ if (invokedAtDriverEdge(ref.identifier)) return true; const value = foreignThunkValue(ref.identifier); const call = value.parent; - return ( - call?.type === 'CallExpression' && - call.arguments.includes(value) && - isPromiseBoundaryCall(call) - ); + return call?.type === 'CallExpression' && call.arguments.includes(value) && isPromiseBoundaryCall(call); }; const annotatedFunctionParameter = (node: any): any => { let current = node; @@ -483,9 +445,7 @@ export const rule = defineRule({ ['create', 'update', 'delete'].includes(keys[2]!) && ['before', 'after'].includes(keys[3]!); const isObjectValue = (parent: any, current: any): boolean => - parent.type === 'Property' && - parent.value === current && - parent.parent?.type === 'ObjectExpression'; + parent.type === 'Property' && parent.value === current && parent.parent?.type === 'ObjectExpression'; const authPropertyKey = (parent: any): unknown => parent.computed ? computedKey(parent.key) : (parent.key.name ?? parent.key.value); const atAuthHook = (node: any): boolean => { @@ -542,8 +502,7 @@ export const rule = defineRule({ if (!statement || typeof statement !== 'object') return false; if (Array.isArray(statement)) return statement.some(visit); if (FUNCTION_TYPES.has(statement.type)) return false; - if (statement.type === 'ReturnStatement') - return returnsKnownPromise(statement.argument, new Set(seen)); + if (statement.type === 'ReturnStatement') return returnsKnownPromise(statement.argument, new Set(seen)); return Object.entries(statement).some(([key, value]) => key !== 'parent' && visit(value)); }; return visit(node); @@ -577,9 +536,7 @@ export const rule = defineRule({ return isEffectPromiseRunner(node.callee) || localCallReturnsPromise(node.callee, seen); }; const functionReturnsPromise = (node: any, seen: Set): boolean => - node.async === true || - promiseReference(node.returnType) !== null || - returnsKnownPromise(node.body, seen); + node.async === true || promiseReference(node.returnType) !== null || returnsKnownPromise(node.body, seen); /** Bounded same-file Promise provenance for owned test registrations, not general type inference. */ const returnsKnownPromise = (raw: any, seen = new Set()): boolean => { @@ -591,11 +548,9 @@ export const rule = defineRule({ if (node.type === 'BlockStatement') return bodyReturnsPromise(node, seen); if (node.type === 'ConditionalExpression') return ( - returnsKnownPromise(node.consequent, new Set(seen)) || - returnsKnownPromise(node.alternate, new Set(seen)) + returnsKnownPromise(node.consequent, new Set(seen)) || returnsKnownPromise(node.alternate, new Set(seen)) ); - if (node.type === 'CallExpression' || node.type === 'NewExpression') - return callReturnsPromise(node, seen); + if (node.type === 'CallExpression' || node.type === 'NewExpression') return callReturnsPromise(node, seen); return false; }; @@ -645,8 +600,7 @@ export const rule = defineRule({ const id = (owner as unknown as ESTree.VariableDeclarator).id; return id.type === 'Identifier' ? id.name : 'this binding'; } - if (owner.type === 'TSTypeAliasDeclaration') - return (owner as unknown as ESTree.TSTypeAliasDeclaration).id.name; + if (owner.type === 'TSTypeAliasDeclaration') return (owner as unknown as ESTree.TSTypeAliasDeclaration).id.name; return memberName(owner); }; @@ -667,10 +621,7 @@ export const rule = defineRule({ const isPortBindingOwner = (owner: AnyNode): boolean => { let declaration = parentOf(owner); - if ( - declaration && - ['AssignmentPattern', 'TSParameterProperty', 'RestElement'].includes(declaration.type) - ) + if (declaration && ['AssignmentPattern', 'TSParameterProperty', 'RestElement'].includes(declaration.type)) declaration = parentOf(declaration); if (!declaration) return false; return ( @@ -687,14 +638,10 @@ export const rule = defineRule({ }; const isPortAnnotationOwner = (owner: AnyNode | null): boolean => { if (!owner) return false; - if (owner.type === 'Identifier' || owner.type === 'RestElement') - return isPortBindingOwner(owner); - return [ - 'TSPropertySignature', - 'TSIndexSignature', - 'PropertyDefinition', - 'TSAbstractPropertyDefinition', - ].includes(owner.type); + if (owner.type === 'Identifier' || owner.type === 'RestElement') return isPortBindingOwner(owner); + return ['TSPropertySignature', 'TSIndexSignature', 'PropertyDefinition', 'TSAbstractPropertyDefinition'].includes( + owner.type, + ); }; /** `TSFunctionType` positions that declare a port member rather than a callback parameter. */ @@ -740,8 +687,7 @@ export const rule = defineRule({ const id = declarator.id; if (id.type !== 'Identifier') return false; return variableFor(id, id.name)?.references.some( - (r: any) => - r.isRead() && r.identifier.parent && isObjectValue(r.identifier.parent, r.identifier), + (r: any) => r.isRead() && r.identifier.parent && isObjectValue(r.identifier.parent, r.identifier), ); }; const isImplementationPosition = (node: AnyNode): boolean => { @@ -757,8 +703,7 @@ export const rule = defineRule({ if (['PropertyDefinition', 'TSAbstractPropertyDefinition'].includes(parent.type)) return true; if (parent.type === 'VariableDeclarator') { return ( - (parent as unknown as ESTree.VariableDeclarator).init === - (current as unknown as ESTree.Expression) && + (parent as unknown as ESTree.VariableDeclarator).init === (current as unknown as ESTree.Expression) && (isModuleScopeDeclarator(parent) || referencedAsObjectValue(parent)) ); } @@ -784,8 +729,7 @@ export const rule = defineRule({ ); }; const exportedOwner = (owner: any): boolean => - owner.parent?.type === 'ExportNamedDeclaration' || - owner.parent?.parent?.type === 'ExportNamedDeclaration'; + owner.parent?.type === 'ExportNamedDeclaration' || owner.parent?.parent?.type === 'ExportNamedDeclaration'; const exemptReference = (ref: any, fn: any, seen: Set): boolean => { if (atDriverEdge(ref.identifier) || atTestBoundary(ref.identifier)) return true; for (let current = ref.identifier.parent; current; current = current.parent) { @@ -811,8 +755,8 @@ export const rule = defineRule({ const exemptNamedHelper = (owner: any, fn: any, seen: Set): boolean => { if (exportedOwner(owner)) return false; const variable = variableFor(owner.id, owner.id.name); - const refs = (variable?.references.filter((ref: any) => ref.isRead()) ?? []).filter( - (ref: any) => outsideFunction(ref, fn), + const refs = (variable?.references.filter((ref: any) => ref.isRead()) ?? []).filter((ref: any) => + outsideFunction(ref, fn), ); return refs.length > 0 && refs.every((ref: any) => exemptReference(ref, fn, seen)); }; @@ -837,23 +781,14 @@ export const rule = defineRule({ ['VariableDeclarator', 'FunctionDeclaration'].includes(namedOwner(node).type); const atImplementationBoundary = (node: AnyNode): boolean => atDriverEdge(node) || atAuthHook(node) || atTestBoundary(node) || exemptHelper(node); - const reportImplementation = ( - node: AnyNode, - member: string, - isAsync: boolean, - wrapper: string | null, - ): void => { + const reportImplementation = (node: AnyNode, member: string, isAsync: boolean, wrapper: string | null): void => { reportedFunctions.add(node.start); report(node as ESTree.Node, isAsync ? 'asyncPort' : 'promiseReturningImplementation', { member, wrapper: wrapper ?? 'Promise', }); }; - const reportOwnedImplementation = ( - node: AnyNode, - isAsync: boolean, - wrapper: string | null, - ): void => { + const reportOwnedImplementation = (node: AnyNode, isAsync: boolean, wrapper: string | null): void => { if (!ownsImplementation(node)) return; if (atImplementationBoundary(node)) return; if (insideReportedFunction(node)) return; @@ -891,8 +826,7 @@ export const rule = defineRule({ const wrapper = promiseReference(node); if (wrapper === null) return; report(node, 'promisePort', { - member: - node.parent.type === 'TSInterfaceDeclaration' ? node.parent.id.name : 'this interface', + member: node.parent.type === 'TSInterfaceDeclaration' ? node.parent.id.name : 'this interface', wrapper, }); }, @@ -955,16 +889,13 @@ export const rule = defineRule({ wrapper, }); }, - TSEmptyBodyFunctionExpression: (node: ESTree.Function) => - checkImplementation(node as unknown as AnyNode), + TSEmptyBodyFunctionExpression: (node: ESTree.Function) => checkImplementation(node as unknown as AnyNode), TSDeclareFunction: (node: any) => { const wrapper = promiseReference(node.returnType); if (wrapper) report(node, 'promisePort', { member: nameOf(node), wrapper }); }, - FunctionDeclaration: (node: ESTree.Function) => - checkImplementation(node as unknown as AnyNode), - FunctionExpression: (node: ESTree.Function) => - checkImplementation(node as unknown as AnyNode), + FunctionDeclaration: (node: ESTree.Function) => checkImplementation(node as unknown as AnyNode), + FunctionExpression: (node: ESTree.Function) => checkImplementation(node as unknown as AnyNode), ArrowFunctionExpression: (node: ESTree.ArrowFunctionExpression) => checkImplementation(node as unknown as AnyNode), } as never; diff --git a/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts b/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts index efb7da5ff..80f34dea4 100644 --- a/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts +++ b/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-raw-effect-adt-tag-check * @@ -54,14 +53,14 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; import { asNamedMember, staticString, unwrapNode } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -110,7 +109,9 @@ function unwrap(node: ESTree.Node): ESTree.Node { } function asTagMember(node: ESTree.Node): ESTree.MemberExpression | null { - return asNamedMember(node, TAG_PROPERTY, asStringLiteral, { maxDepth: MAX_UNWRAP_DEPTH }); + return asNamedMember(node, TAG_PROPERTY, asStringLiteral, { + maxDepth: MAX_UNWRAP_DEPTH, + }); } function asStringLiteral(node: ESTree.Node): string | null { @@ -208,10 +209,8 @@ const OPTION_COMBINATORS = const EXIT_COMBINATORS = '`Exit.match(exit, { onFailure, onSuccess })`, `Exit.isFailure`/`Exit.isSuccess`, or ' + '`Cause.findErrorOption(exit.cause).pipe(Option.match({ onNone, onSome }))` when the failure matters'; -const RESULT_COMBINATORS = - '`Result.match(result, { onFailure, onSuccess })` or `Result.isSuccess`/`Result.isFailure`'; -const EITHER_COMBINATORS = - '`Either.match(value, { onLeft, onRight })` or `Either.isLeft`/`Either.isRight`'; +const RESULT_COMBINATORS = '`Result.match(result, { onFailure, onSuccess })` or `Result.isSuccess`/`Result.isFailure`'; +const EITHER_COMBINATORS = '`Either.match(value, { onLeft, onRight })` or `Either.isLeft`/`Either.isRight`'; /** Name the ADT from the tag, disambiguating the shared `Success`/`Failure` vocabulary by receiver. */ function describeAdt(tag: string, receiver: string | null): { adt: string; combinators: string } { @@ -220,7 +219,10 @@ function describeAdt(tag: string, receiver: string | null): { adt: string; combi const name = receiver?.toLowerCase() ?? ''; if (name.includes('exit')) return { adt: 'Exit', combinators: EXIT_COMBINATORS }; if (name.includes('result')) return { adt: 'Result', combinators: RESULT_COMBINATORS }; - return { adt: 'Exit/Result', combinators: `${EXIT_COMBINATORS}, or ${RESULT_COMBINATORS}` }; + return { + adt: 'Exit/Result', + combinators: `${EXIT_COMBINATORS}, or ${RESULT_COMBINATORS}`, + }; } function isEffectSource(source: string, reexportModules: readonly string[]): boolean { @@ -233,10 +235,7 @@ function isEffectSource(source: string, reexportModules: readonly string[]): boo * Dynamic `import('effect')` is picked up by the `ImportExpression` visitor, because it can appear * anywhere in the file rather than only in the module body. */ -function hasStaticEffectLinkage( - program: ESTree.Program, - reexportModules: readonly string[], -): boolean { +function hasStaticEffectLinkage(program: ESTree.Program, reexportModules: readonly string[]): boolean { if (collectEffectBindings(program).importsEffect) return true; return program.body.some((statement) => statementLinksEffect(statement, reexportModules)); } @@ -315,8 +314,7 @@ export const rule = defineRule({ const tags = new Set(options.adtTags); let hasEffectLinkage = - !options.requireEffectImport || - hasStaticEffectLinkage(context.sourceCode.ast, options.reexportModules); + !options.requireEffectImport || hasStaticEffectLinkage(context.sourceCode.ast, options.reexportModules); // Reports are buffered so a dynamic `import('effect')` appearing *after* a comparison still // counts as Effect linkage; the buffer is flushed in source order at `Program:exit`. const pending: PendingReport[] = []; @@ -325,8 +323,7 @@ export const rule = defineRule({ ImportExpression(node) { if (hasEffectLinkage) return; const source = asStringLiteral(node.source); - if (source !== null && isEffectSource(source, options.reexportModules)) - hasEffectLinkage = true; + if (source !== null && isEffectSource(source, options.reexportModules)) hasEffectLinkage = true; }, BinaryExpression(node) { @@ -381,7 +378,11 @@ export const rule = defineRule({ 'Program:exit'() { if (!hasEffectLinkage) return; for (const report of pending) { - context.report({ node: report.node, messageId: report.messageId, data: report.data }); + context.report({ + node: report.node, + messageId: report.messageId, + data: report.data, + }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts b/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts index 139e35bc4..9c8a8ceb5 100644 --- a/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-refinement-outside-schema * @@ -85,28 +84,21 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { parentOf, unwrapNode } from '../shared/ast.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray } from '../shared/options.ts'; -import { parentOf, unwrapNode } from '../shared/ast.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; /** Workspace-internal module specifiers: first-party code can never be the "existing authority". */ -const DEFAULT_INTERNAL_MODULES: readonly string[] = [ - '@app/**', - '@ontos/**', - '@akros/**', - '~/**', - '#*', - '#*/**', -]; +const DEFAULT_INTERNAL_MODULES: readonly string[] = ['@app/**', '@ontos/**', '@akros/**', '~/**', '#*', '#*/**']; const DEFAULT_ALLOW_DELEGATED_GUARDS: readonly string[] = []; @@ -213,14 +205,24 @@ function collectStatementBindings( if ((specifier as { importKind?: string }).importKind === 'type') continue; if (specifier.type === 'ImportSpecifier') { const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : String(specifier.imported.value); - bindings.set(specifier.local.name, { module, imported, namespace: false }); + specifier.imported.type === 'Identifier' ? specifier.imported.name : String(specifier.imported.value); + bindings.set(specifier.local.name, { + module, + imported, + namespace: false, + }); } else if (specifier.type === 'ImportDefaultSpecifier') { - bindings.set(specifier.local.name, { module, imported: 'default', namespace: false }); + bindings.set(specifier.local.name, { + module, + imported: 'default', + namespace: false, + }); } else if (specifier.type === 'ImportNamespaceSpecifier') { - bindings.set(specifier.local.name, { module, imported: '*', namespace: true }); + bindings.set(specifier.local.name, { + module, + imported: '*', + namespace: true, + }); } } } @@ -273,11 +275,7 @@ function resolveNamespaceMember( function isArrayIsArrayCall(callee: ESTree.Node): boolean { if (callee.type !== 'MemberExpression') return false; const object = unwrap(callee.object); - return ( - object.type === 'Identifier' && - object.name === 'Array' && - staticPropertyName(callee) === 'isArray' - ); + return object.type === 'Identifier' && object.name === 'Array' && staticPropertyName(callee) === 'isArray'; } /** Everything the file has learned about which local names really are existing authorities. */ @@ -299,27 +297,17 @@ function collectAuthorities(program: ESTree.Program, context: Context): Authorit return { context, bindings: collectEffectBindings(program), - barrelLocals: localsFrom( - imports, - (binding) => binding.namespace && binding.module === 'effect', - ), - predicateLocals: localsFrom( - imports, - (binding) => !binding.namespace && PREDICATE_MODULES.has(binding.module), - ), + barrelLocals: localsFrom(imports, (binding) => binding.namespace && binding.module === 'effect'), + predicateLocals: localsFrom(imports, (binding) => !binding.namespace && PREDICATE_MODULES.has(binding.module)), schemaNarrowingLocals: localsFrom( imports, (binding) => - !binding.namespace && - SCHEMA_MODULES.has(binding.module) && - SCHEMA_NARROWING_MEMBERS.has(binding.imported), + !binding.namespace && SCHEMA_MODULES.has(binding.module) && SCHEMA_NARROWING_MEMBERS.has(binding.imported), ), collectionLocals: localsFrom( imports, (binding) => - !binding.namespace && - EFFECT_MODULE.test(binding.module) && - ARRAY_CALLBACK_METHODS.has(binding.imported), + !binding.namespace && EFFECT_MODULE.test(binding.module) && ARRAY_CALLBACK_METHODS.has(binding.imported), ), imports, }; @@ -330,12 +318,10 @@ function hasImportedRoot(node: ESTree.Node, authorities: Authorities): boolean { let root = unwrap(node); while (root.type === 'MemberExpression') root = unwrap(root.object); if (root.type !== 'Identifier' || !authorities.imports.has(root.name)) return false; - let scope: ReturnType | null = - authorities.context.sourceCode.getScope(root); + let scope: ReturnType | null = authorities.context.sourceCode.getScope(root); while (scope !== null) { const variable = scope.set.get(root.name); - if (variable !== undefined) - return variable.defs.some((definition) => definition.type === 'ImportBinding'); + if (variable !== undefined) return variable.defs.some((definition) => definition.type === 'ImportBinding'); scope = scope.upper; } return false; @@ -345,8 +331,7 @@ function hasImportedRoot(node: ESTree.Node, authorities: Authorities): boolean { function isNativeArray(callee: ESTree.Node, authorities: Authorities): boolean { if (!isArrayIsArrayCall(callee) || callee.type !== 'MemberExpression') return false; const root = unwrap(callee.object); - let scope: ReturnType | null = - authorities.context.sourceCode.getScope(root); + let scope: ReturnType | null = authorities.context.sourceCode.getScope(root); while (scope !== null) { const variable = scope.set.get('Array'); if (variable !== undefined) return variable.defs.length === 0; @@ -385,11 +370,7 @@ function moduleIsExternal(module: string, internalModules: readonly string[]): b } /** `"drizzle-orm#isTable"` (module-qualified) or `"isTable"` (bare) entries of `allowDelegatedGuards`. */ -function matchesDelegateAllowlist( - name: string, - binding: ImportBinding, - allowlist: readonly string[], -): boolean { +function matchesDelegateAllowlist(name: string, binding: ImportBinding, allowlist: readonly string[]): boolean { return allowlist.some((entry) => { const hash = entry.indexOf('#'); if (hash === -1) return entry === name; @@ -402,11 +383,7 @@ function matchesDelegateAllowlist( * such as Drizzle's `isTable`. Locally declared names — and first-party workspace packages — are never * authorities: those are the hand-written refinements A2 is about, merely renamed. */ -function isExternalGuardDelegate( - name: string, - authorities: Authorities, - options: RuleOptions, -): boolean { +function isExternalGuardDelegate(name: string, authorities: Authorities, options: RuleOptions): boolean { const binding = authorities.imports.get(name); if (binding === undefined || binding.namespace) return false; if (matchesDelegateAllowlist(name, binding, options.allowDelegatedGuards)) return true; @@ -450,11 +427,7 @@ function delegatesToAuthority( return isAuthorityCallee(unwrap(node.callee), authorities, options); } -function isAuthorityCallee( - callee: ESTree.Node, - authorities: Authorities, - options: RuleOptions, -): boolean { +function isAuthorityCallee(callee: ESTree.Node, authorities: Authorities, options: RuleOptions): boolean { if (isNativeArray(callee, authorities)) return true; if (isPredicateAuthority(callee, authorities)) return true; if (isSchemaNarrowingApplication(callee, authorities)) return true; @@ -471,11 +444,7 @@ function isAuthorityCallee( * Point-free: the annotated value *is* the narrowing function — `= Schema.is(S)`, `= is(S)`, * `= isString`, `= Predicate.isString`. There is no argument to check; the delegate is the guard. */ -function isAuthorityFunction( - expression: ESTree.Node, - authorities: Authorities, - options: RuleOptions, -): boolean { +function isAuthorityFunction(expression: ESTree.Node, authorities: Authorities, options: RuleOptions): boolean { const node = unwrap(expression); if (isSchemaNarrowingApplication(node, authorities)) return true; if (node.type === 'Identifier') { @@ -524,8 +493,7 @@ function annotatedInitialiser(owner: ESTree.Node): ESTree.Node | null { } function initialiserAt(node: ESTree.Node): ESTree.Node | null | undefined { - if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') - return node.expression; + if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') return node.expression; if (node.type === 'VariableDeclarator') return node.init ?? null; if (node.type === 'PropertyDefinition') return node.value ?? null; return undefined; @@ -610,8 +578,7 @@ function isStructuralNarrowingOnly(expression: ESTree.Node): boolean { if (node.type === 'LogicalExpression') { return isStructuralNarrowingOnly(node.left) && isStructuralNarrowingOnly(node.right); } - if (node.type === 'UnaryExpression' && node.operator === '!') - return isStructuralNarrowingOnly(node.argument); + if (node.type === 'UnaryExpression' && node.operator === '!') return isStructuralNarrowingOnly(node.argument); return false; } @@ -636,16 +603,13 @@ function guardedParameterType(owner: ESTree.Node, parameterName: string | null): for (const param of params) { const identifier = parameterIdentifier(param); if (identifier === null || identifier.name !== parameterName) continue; - const annotation = - (identifier as { typeAnnotation?: ESTree.TSTypeAnnotation | null }).typeAnnotation ?? null; + const annotation = (identifier as { typeAnnotation?: ESTree.TSTypeAnnotation | null }).typeAnnotation ?? null; return annotation?.typeAnnotation.type ?? null; } return null; } -function parameterIdentifier( - param: ESTree.Node, -): Extract | null { +function parameterIdentifier(param: ESTree.Node): Extract | null { let target = param; if (target.type === 'RestElement') target = target.argument; if (target.type === 'AssignmentPattern') target = target.left; @@ -696,26 +660,18 @@ const NAME_TRANSPARENT_PARENTS = new Set([ ]); /** Best-effort declaration name for the diagnostic (`isNonEmptyString`, `#isReady`, `(anonymous)`). */ -const NAMED_PROPERTY_TYPES = new Set([ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', -]); +const NAMED_PROPERTY_TYPES = new Set(['Property', 'PropertyDefinition', 'MethodDefinition', 'TSPropertySignature']); function assignmentName(left: ESTree.Node): string { const node = unwrap(left); if (node.type === 'Identifier') return node.name; - return node.type === 'MemberExpression' - ? (staticPropertyName(node) ?? '(anonymous)') - : '(anonymous)'; + return node.type === 'MemberExpression' ? (staticPropertyName(node) ?? '(anonymous)') : '(anonymous)'; } function declarationName(node: ESTree.Node): string | null { if (node.type === 'VariableDeclarator' || node.type === 'TSTypeAliasDeclaration') return keyName(node.id) ?? '(anonymous)'; - if (NAMED_PROPERTY_TYPES.has(node.type)) - return keyName((node as { key?: ESTree.Node }).key ?? null) ?? '(anonymous)'; + if (NAMED_PROPERTY_TYPES.has(node.type)) return keyName((node as { key?: ESTree.Node }).key ?? null) ?? '(anonymous)'; if (node.type === 'AssignmentExpression') return assignmentName(node.left); return null; } @@ -746,18 +702,9 @@ function condense(text: string, limit: number): string { return collapsed.length > limit ? `${collapsed.slice(0, limit - 1)}…` : collapsed; } -function allowsStructuralBody( - owner: ESTree.Node, - body: ESTree.Node, - parameterName: string | null, -): boolean { +function allowsStructuralBody(owner: ESTree.Node, body: ESTree.Node, parameterName: string | null): boolean { const parameterType = guardedParameterType(owner, parameterName); - if ( - parameterType !== null && - OPAQUE_INPUT_TYPES.has(parameterType) && - isStructuralNarrowingOnly(body) - ) - return true; + if (parameterType !== null && OPAQUE_INPUT_TYPES.has(parameterType) && isStructuralNarrowingOnly(body)) return true; return isInstanceofAnchored(body, parameterName); } diff --git a/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts b/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts index 8622472f4..8993f206b 100644 --- a/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts +++ b/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A9** — "Preserve typed Effects through the frontend" ("ten route-specific error * classifiers", "Exhaustive `Match` against a shared frontend failure vocabulary") and **A4** — @@ -60,14 +59,14 @@ import { optionRecord } from '../shared/options.ts'; * names and annotations. This rule only reports; it never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { compile, stringArray } from '../shared/options.ts'; import { isNode, memberName, type Syntax } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; import { importedName } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { compile, stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_ROUTE_GLOBS = ['apps/*/src/routes/**', 'verticals/*/src/routes/**']; @@ -80,11 +79,7 @@ const DEFAULT_ERROR_PARAMETER_PATTERN = 'error|failure|problem|defect|cause'; const TAG_PROPERTY = '_tag'; -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); +const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); /** Expression wrappers that keep the same runtime value (type assertions, parens, chains). */ const TRANSPARENT_EXPRESSIONS = new Set([ @@ -116,11 +111,7 @@ function readOptions(context: Context): RuleOptions { routeGlobs: stringArray(record.routeGlobs, DEFAULT_ROUTE_GLOBS), namePattern: compile(record.namePattern, DEFAULT_NAME_PATTERN, 'u'), classifierInputTypes: stringArray(record.classifierInputTypes, DEFAULT_CLASSIFIER_INPUT_TYPES), - errorParameterPattern: compile( - record.errorParameterPattern, - DEFAULT_ERROR_PARAMETER_PATTERN, - 'iu', - ), + errorParameterPattern: compile(record.errorParameterPattern, DEFAULT_ERROR_PARAMETER_PATTERN, 'iu'), detectTagDiscrimination: record.detectTagDiscrimination !== false, includeInlineHandlers: record.includeInlineHandlers !== false, allowedNames: stringArray(record.allowedNames, []), @@ -133,11 +124,7 @@ function readOptions(context: Context): RuleOptions { * visited set guards against any other shared node reference, and `skip` prunes whole subtrees * (used to honour shadowing: a nested function that re-binds the tracked name). */ -function forEachNode( - root: unknown, - visit: (node: AnyNode) => void, - skip?: (node: AnyNode) => boolean, -): void { +function forEachNode(root: unknown, visit: (node: AnyNode) => void, skip?: (node: AnyNode) => boolean): void { const stack: unknown[] = [root]; const seen = new Set(); while (stack.length > 0) { @@ -265,7 +252,12 @@ interface ParameterShape { function parameterShape(parameter: unknown): ParameterShape { const target = unwrapParameter(parameter); if (!isNode(target)) - return { name: null, bindings: [], typeNames: new Set(), destructuresTag: false }; + return { + name: null, + bindings: [], + typeNames: new Set(), + destructuresTag: false, + }; const typeNames = referencedTypeNames(target.typeAnnotation); const bindings = new Set(); patternBindingNames(target, bindings); @@ -291,43 +283,25 @@ function variableAt(context: Context, node: AnyNode): Variable | null { function computedTagKey(context: Context, node: AnyNode): boolean { if (node.computed !== true || !isNode(node.property)) return false; const variable = variableAt(context, node.property); - if ( - variable === null || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) + if (variable === null || variable.references.some((reference) => reference.isWrite() && !reference.init)) return false; return variable.defs.some((definition) => { - if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') - return false; + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return false; const init = unwrapExpression(definition.node.init); return init?.type === 'Literal' && init.value === TAG_PROPERTY; }); } -function readsParameterTag( - context: Context, - node: AnyNode, - fromParameter: (value: unknown) => boolean, -): boolean { +function readsParameterTag(context: Context, node: AnyNode, fromParameter: (value: unknown) => boolean): boolean { if (node.type === 'MemberExpression') { const key = memberName(node); const isTag = key === TAG_PROPERTY || (key === null && computedTagKey(context, node)); return isTag && fromParameter(node.object); } - return ( - node.type === 'VariableDeclarator' && - isNode(node.id) && - patternHasTagKey(node.id) && - fromParameter(node.init) - ); + return node.type === 'VariableDeclarator' && isNode(node.id) && patternHasTagKey(node.id) && fromParameter(node.init); } -function discriminatesTag( - context: Context, - body: unknown, - binding: string, - parameter: AnyNode, -): boolean { +function discriminatesTag(context: Context, body: unknown, binding: string, parameter: AnyNode): boolean { const fromParameter = (value: unknown, depth = 0): boolean => { if (depth > 8) return false; const node = unwrapExpression(value); @@ -342,11 +316,9 @@ function discriminatesTag( definition.name.end <= Number(parameter.end) ) return true; - if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') - return false; + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return false; // Reassigned aliases do not prove identity at this use site. - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return false; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false; return fromParameter(definition.node.init, depth + 1); }); }; @@ -380,8 +352,7 @@ function exitTypeReference(context: Context, name: AnyNode): boolean { const variable = variableAt(context, root); if (variable === null) return false; return variable.defs.some((definition) => { - if (definition.type !== 'ImportBinding' || definition.parent?.type !== 'ImportDeclaration') - return false; + if (definition.type !== 'ImportBinding' || definition.parent?.type !== 'ImportDeclaration') return false; return isExitImport(definition.node, definition.parent.source.value, parts.join('.')); }); } @@ -392,9 +363,7 @@ function isExitImport(specifier: ESTree.Node, source: unknown, path: string): bo return (source === 'effect' && path === 'Exit') || (source === 'effect/Exit' && path === ''); } if (specifier.type !== 'ImportNamespaceSpecifier') return false; - return ( - (source === 'effect/Exit' && path === 'Exit') || (source === 'effect' && path === 'Exit.Exit') - ); + return (source === 'effect/Exit' && path === 'Exit') || (source === 'effect' && path === 'Exit.Exit'); } /** Type identity needs an import, not just a matching printed local type name. */ @@ -408,9 +377,7 @@ function importsClassifierType(context: Context, parameter: unknown, expected: s if (variable === null) return; if ( variable.defs.some( - (definition) => - definition.type === 'ImportBinding' && - matchesClassifierImport(name, definition.node, expected), + (definition) => definition.type === 'ImportBinding' && matchesClassifierImport(name, definition.node, expected), ) ) found = true; @@ -419,8 +386,7 @@ function importsClassifierType(context: Context, parameter: unknown, expected: s } function matchesClassifierImport(name: AnyNode, imported: ESTree.Node, expected: string): boolean { - if (name.type === 'Identifier' && imported.type === 'ImportSpecifier') - return importedName(imported) === expected; + if (name.type === 'Identifier' && imported.type === 'ImportSpecifier') return importedName(imported) === expected; return ( name.type === 'TSQualifiedName' && imported.type === 'ImportNamespaceSpecifier' && @@ -483,10 +449,8 @@ function assignmentDefinition(left: unknown): Definition | null { function anchorDefinition(anchor: AnyNode): Definition | null { const parent = anchor.parent; if (!isNode(parent)) return null; - if (parent.type === 'VariableDeclarator' && parent.init === anchor) - return identifierDefinition(parent.id); - if (parent.type === 'AssignmentExpression' && parent.right === anchor) - return assignmentDefinition(parent.left); + if (parent.type === 'VariableDeclarator' && parent.init === anchor) return identifierDefinition(parent.id); + if (parent.type === 'AssignmentExpression' && parent.right === anchor) return assignmentDefinition(parent.left); return propertyDefinition(parent, anchor); } @@ -520,11 +484,8 @@ function discriminatedParameter( if (isExitEnvelope(context, parameter)) return null; const shape = parameterShape(parameter); const typeMatches = [...shape.typeNames].some((type) => options.errorParameterPattern.test(type)); - const errorBindings = shape.bindings.filter((binding) => - options.errorParameterPattern.test(binding), - ); - if (shape.destructuresTag && (typeMatches || errorBindings.length > 0)) - return shape.name ?? '{ _tag }'; + const errorBindings = shape.bindings.filter((binding) => options.errorParameterPattern.test(binding)); + if (shape.destructuresTag && (typeMatches || errorBindings.length > 0)) return shape.name ?? '{ _tag }'; if (!isNode(parameter)) return null; return ( (typeMatches ? shape.bindings : errorBindings).find((binding) => @@ -533,15 +494,9 @@ function discriminatedParameter( ); } -function classifierInput( - context: Context, - parameters: readonly unknown[], - options: RuleOptions, -): string | undefined { +function classifierInput(context: Context, parameters: readonly unknown[], options: RuleOptions): string | undefined { for (const parameter of parameters) { - const matched = options.classifierInputTypes.find((type) => - importsClassifierType(context, parameter, type), - ); + const matched = options.classifierInputTypes.find((type) => importsClassifierType(context, parameter, type)); if (matched !== undefined) return matched; } return undefined; diff --git a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts index 055b6b227..1869ff511 100644 --- a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts @@ -1,4 +1,3 @@ -import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 records "four runtime roots, 15+ manually @@ -67,17 +66,17 @@ import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts * satisfy it. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { unwrapNode, keyName, memberName as sharedMemberName } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { collectNamespaceLocals as sharedNamespaceLocals, collectDirectMemberImports as sharedDirectMembers, } from '../shared/imports.ts'; +import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; import { isNonReferencePosition as nonReferencePosition } from '../shared/reference-positions.ts'; import { nodeKey } from '../shared/reporting.ts'; @@ -121,12 +120,7 @@ const TRANSPARENT_EXPRESSIONS = new Set([ ]); /** Parents that put an identifier in a type position, where nothing is constructed at runtime. */ -const TYPE_POSITION_PARENTS = new Set([ - 'TSTypeQuery', - 'TSQualifiedName', - 'TSTypeReference', - 'TSImportType', -]); +const TYPE_POSITION_PARENTS = new Set(['TSTypeQuery', 'TSQualifiedName', 'TSTypeReference', 'TSImportType']); interface RuleOptions { readonly include: readonly string[]; @@ -187,8 +181,7 @@ function collectTypeOnlyLocals(program: ESTree.Program): ReadonlySet { if (statement.type !== 'ImportDeclaration') continue; const declarationIsType = statement.importKind === 'type'; for (const specifier of statement.specifiers) { - const specifierIsType = - specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'; + const specifierIsType = specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'; if (declarationIsType || specifierIsType) locals.add(specifier.local.name); } } @@ -215,16 +208,10 @@ function collectNamespaceLocals( reexportModules: readonly string[], typeOnly: ReadonlySet, ): NamespaceLocals { - const { namespaced, barrel } = sharedNamespaceLocals( - program, - bindings, - tracked, - reexportModules, - { - valueOnly: true, - excludedLocals: typeOnly, - }, - ); + const { namespaced, barrel } = sharedNamespaceLocals(program, bindings, tracked, reexportModules, { + valueOnly: true, + excludedLocals: typeOnly, + }); for (const local of typeOnly) namespaced.delete(local); return { namespaces: namespaced, barrels: barrel }; } @@ -267,8 +254,7 @@ function exportedMember( members: ReadonlySet | undefined, directMembers: ReadonlyMap, ): string | undefined { - if (namespace !== null && members !== undefined) - return members.has(local) ? `${namespace}.${local}` : undefined; + if (namespace !== null && members !== undefined) return members.has(local) ? `${namespace}.${local}` : undefined; return directMembers.get(local); } @@ -310,7 +296,10 @@ function memberName(node: ESTree.MemberExpression): string | null { /** `{ make: boot }`, `{ "make": boot }`, `{ ["make"]: boot }`, `` { [`make`]: boot } ``. */ function propertyKeyName(property: Extract): string | null { - return keyName(property.key, property.computed, { templates: true, rawTemplates: true }); + return keyName(property.key, property.computed, { + templates: true, + rawTemplates: true, + }); } /** Peel `as` / `satisfies` / `!` / `` / parentheses / optional-chain wrappers off an expression. */ @@ -320,13 +309,12 @@ function unwrapExpression(node: ESTree.Node): ESTree.Node { /** Identifier positions that are declarations, property keys or type references — never runtime uses. */ function isNonReferencePosition(node: Extract): boolean { - return nonReferencePosition(node, { nonReferenceParents: TYPE_POSITION_PARENTS }); + return nonReferencePosition(node, { + nonReferenceParents: TYPE_POSITION_PARENTS, + }); } -type ResolvedBinding = - | { readonly kind: 'namespace'; readonly namespace: string } - | { readonly kind: 'barrel' } - | null; +type ResolvedBinding = { readonly kind: 'namespace'; readonly namespace: string } | { readonly kind: 'barrel' } | null; interface MemberCandidate { readonly node: ESTree.Node; @@ -424,12 +412,7 @@ export const rule = defineRule({ ); const directMembers = collectDirectMemberImports(program, byNamespace, typeOnly); const reexports = collectReexportedMembers(program, byNamespace, directMembers); - if ( - namespaces.size === 0 && - barrels.size === 0 && - directMembers.size === 0 && - reexports.length === 0 - ) { + if (namespaces.size === 0 && barrels.size === 0 && directMembers.size === 0 && reexports.length === 0) { return {}; } @@ -461,8 +444,7 @@ export const rule = defineRule({ if (depth > 6) return null; const variable = lookupVariable(context, identifier); if (variable === null || variable.defs.length === 0) return fromImports(identifier.name); - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; for (const definition of variable.defs) { if (definition.type === 'ImportBinding') return fromImports(identifier.name); if (definition.type !== 'Variable') continue; @@ -481,8 +463,7 @@ export const rule = defineRule({ const property = destructured.keys.get(name); if (property === undefined || !tracked.has(property)) return null; const source = resolveBinding(destructured.source, depth + 1); - if (source !== null && source.kind === 'barrel') - return { kind: 'namespace', namespace: property }; + if (source !== null && source.kind === 'barrel') return { kind: 'namespace', namespace: property }; return null; } @@ -498,8 +479,7 @@ export const rule = defineRule({ : resolved.kind === 'barrel' ? candidate.viaBarrel : null; - if (namespace === null || byNamespace.get(namespace)?.has(candidate.member) !== true) - continue; + if (namespace === null || byNamespace.get(namespace)?.has(candidate.member) !== true) continue; found.push({ node: candidate.node, member: `${namespace}.${candidate.member}`, @@ -550,7 +530,12 @@ export const rule = defineRule({ if (namespace === null || !tracked.has(namespace)) return; const barrel = unwrapExpression(object.object as ESTree.Node); if (barrel.type !== 'Identifier') return; - memberCandidates.push({ node, object: barrel, viaBarrel: namespace, member }); + memberCandidates.push({ + node, + object: barrel, + viaBarrel: namespace, + member, + }); }, // `const { make } = ManagedRuntime`, `const MR = ManagedRuntime`, `const { Layer } = EffectNs`. @@ -571,7 +556,11 @@ export const rule = defineRule({ if (name === null) continue; const value = property.value as ESTree.Node; if (value.type === 'Identifier') keys.set(value.name, name); - destructureCandidates.push({ node: property, source: init, key: name }); + destructureCandidates.push({ + node: property, + source: init, + key: name, + }); } destructureDeclarators.set(nodeKey(node), { source: init, keys }); }, diff --git a/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts index a520b049b..fa84775ff 100644 --- a/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts @@ -46,7 +46,6 @@ * passing Effects around stay untouched — the rule only objects to *running* them ad hoc. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope } from '@oxlint/plugins'; import { memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; @@ -112,11 +111,7 @@ interface RuleOptions { readonly includeTestFiles?: boolean; } -const FUNCTION_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', -]); +const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']); function readOptions(context: Context): Required { const raw = (context.options[0] ?? {}) as RuleOptions; @@ -156,25 +151,19 @@ function resolvesToModuleImport(context: Context, node: ESTree.Node, name: strin return true; } -function runnerImportName( - specifier: ESTree.ImportDeclaration['specifiers'][number], -): string | null { +function runnerImportName(specifier: ESTree.ImportDeclaration['specifiers'][number]): string | null { if (specifier.type !== 'ImportSpecifier') return specifier.local.name; return specifier.importKind === 'type' ? null : moduleExportName(specifier.imported); } /** Locals bound to an ad hoc runner by name, default or namespace import; local → imported name. */ -function collectRunnerImports( - program: ESTree.Program, - runnerNames: readonly string[], -): ReadonlyMap { +function collectRunnerImports(program: ESTree.Program, runnerNames: readonly string[]): ReadonlyMap { const locals = new Map(); for (const statement of program.body) { if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue; for (const specifier of statement.specifiers) { const imported = runnerImportName(specifier); - if (imported !== null && runnerNames.includes(imported)) - locals.set(specifier.local.name, imported); + if (imported !== null && runnerNames.includes(imported)) locals.set(specifier.local.name, imported); } } return locals; @@ -221,8 +210,7 @@ function unwrap(node: ESTree.Node): ESTree.Node { function ancestorsOf(node: ESTree.Node): ESTree.Node[] { const ancestors: ESTree.Node[] = []; - for (let current = node.parent; current !== null; current = current.parent) - ancestors.push(current); + for (let current = node.parent; current !== null; current = current.parent) ancestors.push(current); return ancestors.reverse(); } @@ -266,13 +254,9 @@ const DECLARATION_PARENTS = new Set([ ]); const CLASS_KEY_PARENTS = new Set(['PropertyDefinition', 'MethodDefinition', 'AccessorProperty']); -function isNonReferenceKey( - node: Extract, - parent: ESTree.Node, -): boolean { +function isNonReferenceKey(node: Extract, parent: ESTree.Node): boolean { if (parent.type === 'MemberExpression') return !parent.computed && parent.property === node; - if (parent.type === 'Property') - return !parent.computed && parent.key === node && !parent.shorthand; + if (parent.type === 'Property') return !parent.computed && parent.key === node && !parent.shorthand; if (!CLASS_KEY_PARENTS.has(parent.type)) return false; const property = parent as ESTree.PropertyDefinition; return property.key === node && !property.computed; @@ -338,7 +322,10 @@ export const rule = defineRule({ if (!options.includeTestFiles && isTestFile(filename)) return {}; const effectModulePatterns = options.effectModules.map((glob) => globToRegExp(glob)); - let bindings: EffectBindings = { importsEffect: false, namespaces: new Map() }; + let bindings: EffectBindings = { + importsEffect: false, + namespaces: new Map(), + }; let runnerImports: ReadonlyMap = new Map(); let namespaceImports: ReadonlyMap = new Map(); let rootNamespaces: ReadonlySet = new Set(); @@ -347,9 +334,7 @@ export const rule = defineRule({ const importedNamespace = (node: Extract): boolean => { const namespace = bindings.namespaces.get(node.name); return ( - namespace !== undefined && - RUNNER_NAMESPACES.has(namespace) && - resolvesToModuleImport(context, node, node.name) + namespace !== undefined && RUNNER_NAMESPACES.has(namespace) && resolvesToModuleImport(context, node, node.name) ); }; @@ -367,8 +352,7 @@ export const rule = defineRule({ const object = unwrap(node.object); const member = memberName(node); if (member === null || !RUN_MEMBER.test(member)) return null; - if (object.type === 'Identifier') - return importedNamespace(object) ? `${object.name}.${member}` : null; + if (object.type === 'Identifier') return importedNamespace(object) ? `${object.name}.${member}` : null; return object.type === 'MemberExpression' ? rootRunSeam(object, member) : null; }; @@ -426,7 +410,11 @@ export const rule = defineRule({ if (key === null) { context.report({ data: { runner }, messageId: 'adHocRun', node }); } else { - context.report({ data: { key, runner }, messageId: 'queryBoundary', node }); + context.report({ + data: { key, runner }, + messageId: 'queryBoundary', + node, + }); } }; @@ -444,11 +432,7 @@ export const rule = defineRule({ }); }; - const isRunnerExport = ( - specifier: ESTree.ExportSpecifier, - source: string | null, - local: string, - ): boolean => { + const isRunnerExport = (specifier: ESTree.ExportSpecifier, source: string | null, local: string): boolean => { if (source === null) return ( (runnerImports.has(local) || importedRunMember(local)) && @@ -496,8 +480,7 @@ export const rule = defineRule({ }, ExportNamedDeclaration(node) { if (node.exportKind === 'type') return; - for (const specifier of node.specifiers) - reportRunnerExport(specifier, node.source?.value ?? null); + for (const specifier of node.specifiers) reportRunnerExport(specifier, node.source?.value ?? null); }, ExportAllDeclaration(node) { if (node.exportKind === 'type') return; diff --git a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts index 8a60d9e8d..1e78fd032 100644 --- a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts +++ b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-sequential-independent-yields * @@ -73,31 +72,17 @@ import { optionRecord } from '../shared/options.ts'; * hatch for known ordering steps, not a proof that all other calls commute. Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { - asNode as sharedAsNode, - childrenOf, - memberName as sharedMemberName, -} from '../shared/ast.ts'; +import { asNode as sharedAsNode, childrenOf, memberName as sharedMemberName } from '../shared/ast.ts'; import { bindingPath, isGenCallee as sharedIsGenCallee } from '../shared/effect-identity.ts'; -import { - bindingsWithExtraModules, - collectRootNamespaces, - collectNamedImports, -} from '../shared/imports.ts'; +import { bindingsWithExtraModules, collectRootNamespaces, collectNamedImports } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { booleanOption as boolean, stringArray, safeRegExp } from '../shared/options.ts'; import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; -const DEFAULT_IGNORE: readonly string[] = [ - '**/dist/**', - '**/build/**', - '**/node_modules/**', - 'tools/**', - '**/*.d.ts', -]; +const DEFAULT_IGNORE: readonly string[] = ['**/dist/**', '**/build/**', '**/node_modules/**', 'tools/**', '**/*.d.ts']; const DEFAULT_SCRIPT_GLOBS: readonly string[] = ['scripts/**', '**/scripts/**']; const DEFAULT_GEN_MEMBERS: readonly string[] = ['gen', 'fn', 'fnUntraced']; /** Barrels that re-export `Effect` verbatim, so `Effect.gen` there is the same generator. */ @@ -143,9 +128,7 @@ function readOptions(context: Context) { includeScripts: boolean(record.includeScripts, false), includeFunctionCallees: boolean(record.includeFunctionCallees, true), orderingCalleePattern: - typeof record.orderingCalleePattern === 'string' - ? record.orderingCalleePattern - : DEFAULT_ORDERING_PATTERN, + typeof record.orderingCalleePattern === 'string' ? record.orderingCalleePattern : DEFAULT_ORDERING_PATTERN, genMembers: stringArray(record.genMembers, DEFAULT_GEN_MEMBERS), effectModules: stringArray(record.effectModules, DEFAULT_EFFECT_MODULES), }; @@ -178,8 +161,7 @@ function unwrap(value: unknown): AnyNode | null { function memberName(node: AnyNode): string | null { const property = asNode(node.property); if (property === null) return null; - if (node.computed !== true) - return property.type === 'Identifier' ? (property.name as string) : null; + if (node.computed !== true) return property.type === 'Identifier' ? (property.name as string) : null; return sharedMemberName(node, { templates: true, babelStrings: true }); } @@ -191,20 +173,14 @@ interface GeneratorMatcher { /** `Effect.gen` / `E.gen` / `X.Effect.gen` / bare `gen` (direct member import), incl. computed + optional. */ function isGenCallee(callee: AnyNode | null, matcher: GeneratorMatcher): boolean { - return sharedIsGenCallee( - matcher.context, - callee as ESTree.Node | null, - matcher.genMembers, - matcher.effectModules, - ); + return sharedIsGenCallee(matcher.context, callee as ESTree.Node | null, matcher.genMembers, matcher.effectModules); } /** `true` when `fn` is a generator function handed to `Effect.gen` / `Effect.fn` / `Effect.fnUntraced`. */ function isEffectGenerator(fn: AnyNode, matcher: GeneratorMatcher): boolean { if (fn.generator !== true) return false; let outer = fn; - while (parentOf(outer) !== null && WRAPPER_TYPES.has(parentOf(outer)!.type)) - outer = parentOf(outer)!; + while (parentOf(outer) !== null && WRAPPER_TYPES.has(parentOf(outer)!.type)) outer = parentOf(outer)!; const call = parentOf(outer); if (call === null || call.type !== 'CallExpression') return false; const args = call.arguments; @@ -220,11 +196,7 @@ function isEffectGenerator(fn: AnyNode, matcher: GeneratorMatcher): boolean { type Walker = (node: AnyNode) => boolean; /** Preserve the generator traversal budget while sharing child enumeration and ordering. */ -function walk( - node: AnyNode, - visitorKeys: Readonly>, - visit: Walker, -): void { +function walk(node: AnyNode, visitorKeys: Readonly>, visit: Walker): void { const stack = [node]; for (let visited = 0; stack.length > 0 && visited < 200_000; visited += 1) { const current = stack.pop()!; @@ -237,11 +209,7 @@ function walk( } /** Peel method and imported pipe calls down to their subject. */ -function pipeSubject( - current: AnyNode, - context: Context, - modules: readonly string[], -): AnyNode | null { +function pipeSubject(current: AnyNode, context: Context, modules: readonly string[]): AnyNode | null { if (current.type !== 'CallExpression') return current; const callee = unwrap(current.callee); if (callee === null) return current; @@ -273,11 +241,7 @@ function collectPatternNames( names.add(node.name as string); return false; } - if ( - node.type === 'Property' || - node.type === 'ObjectProperty' || - node.type === 'PropertyDefinition' - ) { + if (node.type === 'Property' || node.type === 'ObjectProperty' || node.type === 'PropertyDefinition') { // `{ key: local }` binds `local`; `{ key }` is shorthand and binds `key` via the same node. if (node.computed === true) { const key = asNode(node.key); @@ -308,10 +272,7 @@ function collectPatternNames( } /** Identifiers *read* by an expression: member property names and literal object keys are not reads. */ -function collectReferencedNames( - node: AnyNode, - visitorKeys: Readonly>, -): Set { +function collectReferencedNames(node: AnyNode, visitorKeys: Readonly>): Set { const names = new Set(); walk(node, visitorKeys, (current) => collectInto(current, names, visitorKeys)); return names; @@ -329,14 +290,10 @@ function collectInto( } if (MEMBER_TYPES.has(current.type) && current.computed !== true) { const object = asNode(current.object); - if (object !== null) - walk(object, visitorKeys, (inner) => collectInto(inner, names, visitorKeys)); + if (object !== null) walk(object, visitorKeys, (inner) => collectInto(inner, names, visitorKeys)); return false; } - if ( - (current.type === 'Property' || current.type === 'ObjectProperty') && - current.computed !== true - ) { + if ((current.type === 'Property' || current.type === 'ObjectProperty') && current.computed !== true) { if (current.shorthand === true) return true; const value = asNode(current.value); if (value !== null) walk(value, visitorKeys, (inner) => collectInto(inner, names, visitorKeys)); @@ -368,24 +325,11 @@ function singleDeclarator(statement: AnyNode): AnyNode | null { const declarations = statement.declarations; return Array.isArray(declarations) && declarations.length === 1 ? asNode(declarations[0]) : null; } -const TRANSPARENT_MEMBERS = new Set([ - 'withSpan', - 'annotateLogs', - 'timeout', - 'timeoutOption', - 'retry', -]); -function transparentArguments( - subject: AnyNode, - context: Context, - modules: readonly string[], -): unknown[] | null { +const TRANSPARENT_MEMBERS = new Set(['withSpan', 'annotateLogs', 'timeout', 'timeoutOption', 'retry']); +function transparentArguments(subject: AnyNode, context: Context, modules: readonly string[]): unknown[] | null { const path = bindingPath(context, subject.callee as unknown as ESTree.Node, modules); - if (path?.length !== 2 || path[0] !== 'Effect' || !TRANSPARENT_MEMBERS.has(path[1] ?? '')) - return null; - return Array.isArray(subject.arguments) && subject.arguments.length >= 2 - ? subject.arguments - : null; + if (path?.length !== 2 || path[0] !== 'Effect' || !TRANSPARENT_MEMBERS.has(path[1] ?? '')) return null; + return Array.isArray(subject.arguments) && subject.arguments.length >= 2 ? subject.arguments : null; } /** Only known data-first wrappers preserve the effect; constructors and callbacks remain opaque. */ function readSubject(value: unknown, context: Context, modules: readonly string[]): AnyNode | null { @@ -483,12 +427,8 @@ export const rule = defineRule({ const calleeNode = readCallee(subject, options.includeFunctionCallees); if (calleeNode === null) return null; // `Effect.all(...)`, `Schema.decodeUnknown(...)`, … are the target shape, never the anti-pattern. - if ( - bindingPath(context, calleeNode as unknown as ESTree.Node, options.effectModules) !== null - ) - return null; - const calleeName = - calleeNode.type === 'Identifier' ? (calleeNode.name as string) : memberName(calleeNode); + if (bindingPath(context, calleeNode as unknown as ESTree.Node, options.effectModules) !== null) return null; + const calleeName = calleeNode.type === 'Identifier' ? (calleeNode.name as string) : memberName(calleeNode); if (calleeName === null) return null; return { @@ -538,7 +478,10 @@ export const rule = defineRule({ continue; } context.report({ - data: { first: [...head.bound].join(', '), second: [...candidate.bound].join(', ') }, + data: { + first: [...head.bound].join(', '), + second: [...candidate.bound].join(', '), + }, messageId: 'sequentialIndependentYields', node: candidate.calleeNode as unknown as ESTree.Node, }); @@ -551,10 +494,8 @@ export const rule = defineRule({ if (body === null) return; walk(body, visitorKeys, (node) => { if (node !== body && FUNCTION_TYPES.has(node.type)) return false; - if (node.type === 'BlockStatement' && Array.isArray(node.body)) - analyseStatements(node.body); - else if (node.type === 'SwitchCase' && Array.isArray(node.consequent)) - analyseStatements(node.consequent); + if (node.type === 'BlockStatement' && Array.isArray(node.body)) analyseStatements(node.body); + else if (node.type === 'SwitchCase' && Array.isArray(node.consequent)) analyseStatements(node.consequent); return true; }); }; diff --git a/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts b/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts index 18dc04e00..c570ba4c7 100644 --- a/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" — and * **B5** — "Adopt Effect's ADTs and temporal model consistently" @@ -79,22 +78,16 @@ import { optionRecord } from '../shared/options.ts'; * Unknown pipe steps are not assumed transparent, and generic/cross-file type aliases are not inferred. Report-only; this rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; - -import { - memberName as staticMemberName, - unwrapNode, - skipWrappers, - keyName, -} from '../shared/ast.ts'; +import { memberName as staticMemberName, unwrapNode, skipWrappers, keyName } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { importedName } from '../shared/imports.ts'; -import { isSchemaConstructorArgument as isConstructorArgument } from '../shared/schema-constructor.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray, stringOption, safeRegExp } from '../shared/options.ts'; +import { globToRegExp, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { isSchemaConstructorArgument as isConstructorArgument } from '../shared/schema-constructor.ts'; const SCHEMA_NAMESPACE = 'Schema'; @@ -153,9 +146,7 @@ function normaliseRegexSource(source: string): string { const repeat = (run: string, count: string): string => run.repeat(Math.min(Number(count), 12)); let text = source.replace(QUANTIFIED_DIGIT, (_match, count: string) => repeat('D', count)); text = text.replace(BARE_DIGIT, 'D'); - text = text.replace(GROUPED_DIGIT_RUN, (_match, run: string, count: string) => - repeat(run, count), - ); + text = text.replace(GROUPED_DIGIT_RUN, (_match, run: string, count: string) => repeat(run, count)); return text.replace(REDUNDANT_ESCAPE, '$1'); } @@ -246,11 +237,7 @@ function readOptions(context: Context): RuleOptions { include: stringArray(record.include, DEFAULT_INCLUDE), includeTypeMembers: record.includeTypeMembers !== false, schemaModules: stringArray(record.schemaModules, DEFAULT_SCHEMA_MODULES), - temporalKeyPattern: stringOption( - record.temporalKeyPattern, - DEFAULT_TEMPORAL_KEY_PATTERN, - false, - ), + temporalKeyPattern: stringOption(record.temporalKeyPattern, DEFAULT_TEMPORAL_KEY_PATTERN, false), }; } @@ -388,17 +375,18 @@ export const rule = defineRule({ if (options.ignoreTests && isTestFile(path)) return {}; const keyPattern = safeRegExp(options.temporalKeyPattern, DEFAULT_TEMPORAL_KEY_PATTERN); - const ignoreKey = - options.ignoreKeyPattern.length > 0 ? safeRegExp(options.ignoreKeyPattern, '$^') : null; - const ignoreType = - options.ignoreTypePattern.length > 0 ? safeRegExp(options.ignoreTypePattern, '$^') : null; + const ignoreKey = options.ignoreKeyPattern.length > 0 ? safeRegExp(options.ignoreKeyPattern, '$^') : null; + const ignoreType = options.ignoreTypePattern.length > 0 ? safeRegExp(options.ignoreTypePattern, '$^') : null; const isTemporalKey = (key: string): boolean => { if (!keyPattern.test(key)) return false; return ignoreKey === null || !ignoreKey.test(key); }; - let bindings: EffectBindings = { importsEffect: false, namespaces: new Map() }; + let bindings: EffectBindings = { + importsEffect: false, + namespaces: new Map(), + }; let locals: SchemaLocals = { barrel: new Set(), members: new Map(), @@ -474,8 +462,7 @@ export const rule = defineRule({ const localDeclarator = (node: ESTree.Node, name: string): ESTree.VariableDeclarator | null => { const variable = lookupVariable(context, node, name); if (variable === null || variable.defs.length !== 1) return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; const definition = variable.defs[0]; if (definition === undefined || definition.type !== 'Variable') return null; const declarator = definition.node; @@ -522,12 +509,7 @@ export const rule = defineRule({ return isStringRooted(declarator.init, seen, depth + 1, trace); }; - const stringCall = ( - expression: ESTree.Node, - seen: Set, - depth: number, - trace: StringRootTrace, - ): boolean => { + const stringCall = (expression: ESTree.Node, seen: Set, depth: number, trace: StringRootTrace): boolean => { if (expression.type !== 'CallExpression') return false; const callee = unwrap(expression.callee); @@ -540,8 +522,7 @@ export const rule = defineRule({ return isStringRooted(first, seen, depth + 1, trace); } - if (callee.type === 'MemberExpression') - return stringMethod(callee, expression.arguments, seen, depth, trace); + if (callee.type === 'MemberExpression') return stringMethod(callee, expression.arguments, seen, depth, trace); return importedStringPipeline(callee, expression.arguments, seen, depth, trace); }; @@ -553,11 +534,7 @@ export const rule = defineRule({ trace: StringRootTrace, ): boolean => { // `pipe(Schema.String, Schema.brand('X'))`. - if ( - callee.type === 'Identifier' && - locals.pipe.has(callee.name) && - resolvesToImport(callee, callee.name) - ) { + if (callee.type === 'Identifier' && locals.pipe.has(callee.name) && resolvesToImport(callee, callee.name)) { const first = args[0]; if (first === undefined || first.type === 'SpreadElement') return false; return stringPipeline(first, args.slice(1), seen, depth, trace); @@ -614,8 +591,7 @@ export const rule = defineRule({ const expression = unwrap(node); if (expression.type !== 'Identifier') return null; const declarator = localDeclarator(expression, expression.name); - if (declarator === null || declarator.init === null || declarator.init === undefined) - return null; + if (declarator === null || declarator.init === null || declarator.init === undefined) return null; const init = unwrap(declarator.init); return init.type === 'ObjectExpression' ? init : null; }; @@ -680,27 +656,18 @@ export const rule = defineRule({ if (type.type === 'TSStringKeyword') return true; if (type.type === 'TSTypeReference') return stringTypeAlias(type, seen, depth); - if (type.type === 'TSUnionType') - return stringTypeMembers(type.types, seen, depth, NULLISH_KEYWORDS); + if (type.type === 'TSUnionType') return stringTypeMembers(type.types, seen, depth, NULLISH_KEYWORDS); if (type.type === 'TSIntersectionType') return stringTypeMembers(type.types, seen, depth, new Set(['TSTypeLiteral'])); return false; }; - const stringTypeAlias = ( - type: ESTree.TSTypeReference, - seen: Set, - depth: number, - ): boolean => { + const stringTypeAlias = (type: ESTree.TSTypeReference, seen: Set, depth: number): boolean => { const name = type.typeName; if (name.type !== 'Identifier' || seen.has(name.name)) return false; const variable = lookupVariable(context, name, name.name); const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if ( - definition?.node.type !== 'TSTypeAliasDeclaration' || - definition.node.typeParameters != null - ) - return false; + if (definition?.node.type !== 'TSTypeAliasDeclaration' || definition.node.typeParameters != null) return false; seen.add(name.name); return isPlainStringType(definition.node.typeAnnotation, seen, depth + 1); }; @@ -749,8 +716,7 @@ export const rule = defineRule({ const isParameterTypeLiteral = (owner: ESTree.Node): boolean => { if (owner.type !== 'TSTypeLiteral') return false; const annotation = owner.parent; - if (annotation === null || annotation === undefined || annotation.type !== 'TSTypeAnnotation') - return false; + if (annotation === null || annotation === undefined || annotation.type !== 'TSTypeAnnotation') return false; const target = annotation.parent; if (target === null || target === undefined) return false; const owner2 = target.parent; @@ -762,9 +728,7 @@ export const rule = defineRule({ /** The declared member names of the interface body / type literal that owns a signature. */ const siblingKeys = (owner: ESTree.Node): ReadonlySet => { const members: readonly ESTree.Node[] = - owner.type === 'TSInterfaceBody' - ? owner.body - : ((owner as { members?: readonly ESTree.Node[] }).members ?? []); + owner.type === 'TSInterfaceBody' ? owner.body : ((owner as { members?: readonly ESTree.Node[] }).members ?? []); const names = new Set(); for (const member of members) { if (member.type !== 'TSPropertySignature') continue; @@ -804,9 +768,7 @@ export const rule = defineRule({ return null; }; - const literalRegexSource = ( - expression: Extract, - ): string | null => { + const literalRegexSource = (expression: Extract): string | null => { const regex = (expression as { regex?: { pattern: string } }).regex; if (regex !== undefined) return regex.pattern; return typeof expression.value === 'string' ? expression.value : null; @@ -822,10 +784,7 @@ export const rule = defineRule({ return text; }; - const concatenatedRegexSource = ( - expression: ESTree.BinaryExpression, - depth: number, - ): string | null => { + const concatenatedRegexSource = (expression: ESTree.BinaryExpression, depth: number): string | null => { const left = regexSource(expression.left, depth + 1); const right = regexSource(expression.right, depth + 1); return left === null && right === null ? null : `${left ?? ''}${right ?? ''}`; @@ -851,9 +810,7 @@ export const rule = defineRule({ const isTransparentCall = (call: ESTree.CallExpression): boolean => { const callee = unwrap(call.callee); - return ( - callee.type === 'MemberExpression' && TRANSPARENT_METHODS.has(memberName(callee) ?? '') - ); + return callee.type === 'MemberExpression' && TRANSPARENT_METHODS.has(memberName(callee) ?? ''); }; /** Walk out of `Schema.isPattern(...)` to the `.check(...)` / `.pipe(...)` that owns it. */ @@ -873,10 +830,7 @@ export const rule = defineRule({ const reports: Array<{ readonly node: ESTree.Node; - readonly messageId: - | 'stringTemporalField' - | 'handRolledTemporalCodec' - | 'stringTemporalMember'; + readonly messageId: 'stringTemporalField' | 'handRolledTemporalCodec' | 'stringTemporalMember'; readonly data: Record; readonly start: number; }> = []; @@ -903,10 +857,7 @@ export const rule = defineRule({ return parent.parent; return enclosingPipe(result, parent); }; - const enclosingPipe = ( - result: ESTree.Node, - parent: ESTree.Node | null | undefined, - ): ESTree.Node | null => { + const enclosingPipe = (result: ESTree.Node, parent: ESTree.Node | null | undefined): ESTree.Node | null => { if ( parent?.type === 'CallExpression' && parent.arguments.some((argument) => argument === result) && @@ -927,8 +878,7 @@ export const rule = defineRule({ if (!isCalendarDate && !isIsoTime) return; const target = enclosingCheck(call); const result = outerCodecResult(target); - if (result !== target && !isStringRooted(result, new Set(), 0, { viaReportedCodec: false })) - return; + if (result !== target && !isStringRooted(result, new Set(), 0, { viaReportedCodec: false })) return; codecSpans.push({ end: target.end, start: target.start }); const owner = enclosingDeclarator(target); if (owner !== null) reportedCodecDeclarators.add(owner.start); @@ -1008,8 +958,7 @@ export const rule = defineRule({ }, SpreadElement(node) { const container = node.parent; - if (container === null || container === undefined || container.type !== 'ObjectExpression') - return; + if (container === null || container === undefined || container.type !== 'ObjectExpression') return; const argument = unwrap(node.argument); if (argument.type === 'Identifier') spreads.push({ container, id: argument }); }, @@ -1019,8 +968,7 @@ export const rule = defineRule({ 'Program:exit'() { reports.length = 0; codecSpans.length = 0; - const hasSchema = - locals.schema.size > 0 || locals.barrel.size > 0 || locals.members.size > 0; + const hasSchema = locals.schema.size > 0 || locals.barrel.size > 0 || locals.members.size > 0; if (hasSchema) { patternCalls.forEach(reportCodec); reportFields(); @@ -1028,7 +976,11 @@ export const rule = defineRule({ if (options.includeTypeMembers) typeMembers.forEach(reportTypeMember); reports.sort((left, right) => left.start - right.start); for (const report of reports) { - context.report({ data: report.data, messageId: report.messageId, node: report.node }); + context.report({ + data: report.data, + messageId: report.messageId, + node: report.node, + }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts b/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts index 35de64963..2052e85d7 100644 --- a/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts +++ b/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts @@ -71,16 +71,9 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { - parentOf, - unwrapNode as unwrap, - staticString, - nearestFunction, - walk, -} from '../shared/ast.ts'; +import { parentOf, unwrapNode as unwrap, staticString, nearestFunction, walk } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; import { importedName } from '../shared/imports.ts'; import { stringList } from '../shared/options.ts'; @@ -169,8 +162,7 @@ function readOptions(raw: unknown): RuleOptions { const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, + ignoreTestFiles: typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, documentIdentifiers: typeof given.documentIdentifiers === 'string' && given.documentIdentifiers.length > 0 @@ -188,19 +180,25 @@ function compilePattern(source: string): RegExp { } } -function spanOf(node: AnyNode): { readonly start: number; readonly end: number } { +function spanOf(node: AnyNode): { + readonly start: number; + readonly end: number; +} { return node as unknown as { readonly start: number; readonly end: number }; } function asStringLiteral(node: AnyNode): string | null { - return staticString(node, { unwrap: {}, templates: true, rawTemplates: true }); + return staticString(node, { + unwrap: {}, + templates: true, + rawTemplates: true, + }); } /** `x.y` / `x["y"]` → `"y"`; a dynamic key → `null`. */ function staticPropertyName(node: ESTree.MemberExpression): string | null { const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; + if (!node.computed) return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; return asStringLiteral(property); } @@ -219,16 +217,13 @@ function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): bool }; return ( def.type === 'Type' || - (def.type === 'ImportBinding' && - (def.node?.importKind === 'type' || def.parent?.importKind === 'type')) + (def.type === 'ImportBinding' && (def.node?.importKind === 'type' || def.parent?.importKind === 'type')) ); }) ); } -function typeofComparison( - binary: ESTree.BinaryExpression, -): { argument: AnyNode; other: AnyNode } | null { +function typeofComparison(binary: ESTree.BinaryExpression): { argument: AnyNode; other: AnyNode } | null { const left = unwrap(binary.left); const right = unwrap(binary.right); if (left.type === 'UnaryExpression' && left.operator === 'typeof') @@ -318,7 +313,10 @@ export const rule = defineRule({ const documentIdentifier = compilePattern(options.documentIdentifiers); const allowedKeys = new Set(options.allowInKeys); /** Spans already reported as a whole object-shape guard; nested field probes stay silent there. */ - const guardedSpans: Array<{ readonly start: number; readonly end: number }> = []; + const guardedSpans: Array<{ + readonly start: number; + readonly end: number; + }> = []; const printed = (node: AnyNode): string => { const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); @@ -345,10 +343,7 @@ export const rule = defineRule({ const container = unwrap(member.object as AnyNode); if (container.type !== 'Identifier') return false; const containerName = (container as ESTree.IdentifierReference).name; - return ( - CONTAINER_GLOBALS.has(containerName) && - isUnshadowedGlobal(context, container, containerName) - ); + return CONTAINER_GLOBALS.has(containerName) && isUnshadowedGlobal(context, container, containerName); }; /** `Array.isArray` / `Object.keys` / `JSON.stringify` — the global namespace method itself. */ @@ -366,20 +361,12 @@ export const rule = defineRule({ * `Predicate` is accepted when it is an import (a re-export barrel this rule cannot follow), * never when it is a local object literal. */ - const namedPredicateIdentity = ( - source: string, - imported: string, - submodule: boolean, - ): string | null => { + const namedPredicateIdentity = (source: string, imported: string, submodule: boolean): string | null => { if (submodule) return imported; return source === 'effect' && imported === 'Predicate' ? '@predicate' : null; }; const importPredicateIdentity = (def: Variable['defs'][number]): string | null => { - if ( - def.type !== 'ImportBinding' || - def.parent?.type !== 'ImportDeclaration' || - def.parent.importKind === 'type' - ) + if (def.type !== 'ImportBinding' || def.parent?.type !== 'ImportDeclaration' || def.parent.importKind === 'type') return null; const source = def.parent.source.value; const submodule = /^effect\/(?:.*\/)?Predicate$/u.test(source); @@ -389,17 +376,14 @@ export const rule = defineRule({ }; const constantInitializer = (def: Variable['defs'][number]): AnyNode | null => { if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - if (def.node.id.type !== 'Identifier' || def.node.parent?.type !== 'VariableDeclaration') - return null; + if (def.node.id.type !== 'Identifier' || def.node.parent?.type !== 'VariableDeclaration') return null; return def.node.parent.kind === 'const' ? def.node.init : null; }; const predicateIdentity = (input: AnyNode, depth = 0): string | null => { if (depth > 12) return null; const node = unwrap(input); if (node.type === 'MemberExpression') - return predicateIdentity(node.object, depth + 1) === '@predicate' - ? staticPropertyName(node) - : null; + return predicateIdentity(node.object, depth + 1) === '@predicate' ? staticPropertyName(node) : null; if (node.type !== 'Identifier') return null; const variable = resolveVariable(context, node.name, node); for (const def of variable?.defs ?? []) { @@ -455,10 +439,7 @@ export const rule = defineRule({ logicalLeaves(logical.right as AnyNode, into); return; } - if ( - expression.type === 'UnaryExpression' && - (expression as ESTree.UnaryExpression).operator === '!' - ) { + if (expression.type === 'UnaryExpression' && (expression as ESTree.UnaryExpression).operator === '!') { logicalLeaves((expression as ESTree.UnaryExpression).argument as AnyNode, into); return; } @@ -472,8 +453,7 @@ export const rule = defineRule({ for (;;) { if (parent === null) return true; if (parent.type === 'LogicalExpression') return false; - const negation = - parent.type === 'UnaryExpression' && (parent as ESTree.UnaryExpression).operator === '!'; + const negation = parent.type === 'UnaryExpression' && (parent as ESTree.UnaryExpression).operator === '!'; if (negation || TRANSPARENT_PARENTS.has(parent.type)) { current = parent; parent = parentOf(current); @@ -484,8 +464,7 @@ export const rule = defineRule({ }; /** Source text used to decide "the same X" across guard arms. */ - const targetKey = (node: AnyNode): string => - context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); + const targetKey = (node: AnyNode): string => context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); /** * A member access that reads a field out of a decoded document: a computed string key @@ -493,8 +472,7 @@ export const rule = defineRule({ */ const documentRoot = (input: AnyNode): AnyNode => { let node = unwrap(input); - while (node.type === 'MemberExpression' && staticPropertyName(node) !== null) - node = unwrap(node.object); + while (node.type === 'MemberExpression' && staticPropertyName(node) !== null) node = unwrap(node.object); return node; }; // Receiver hints constrain membership/equality checks too. A DOM Event, service or driver @@ -503,17 +481,11 @@ export const rule = defineRule({ const root = documentRoot(input); if (root.type === 'MemberExpression' && root.object.type === 'ThisExpression') { const key = root.property; - return ( - (key.type === 'PrivateIdentifier' || key.type === 'Identifier') && - documentIdentifier.test(key.name) - ); + return (key.type === 'PrivateIdentifier' || key.type === 'Identifier') && documentIdentifier.test(key.name); } if (root.type === 'ThisExpression') { const member = unwrap(input); - return ( - member.type === 'MemberExpression' && - documentIdentifier.test(staticPropertyName(member) ?? '') - ); + return member.type === 'MemberExpression' && documentIdentifier.test(staticPropertyName(member) ?? ''); } return root.type === 'Identifier' && documentIdentifier.test(root.name); }; @@ -529,9 +501,7 @@ export const rule = defineRule({ const functionIdentifier = (fn: AnyNode): ESTree.BindingIdentifier | null => { if (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') return fn.id; const parent = parentOf(fn); - return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' - ? parent.id - : null; + return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' ? parent.id : null; }; const inGenericRecursiveTraversal = (node: AnyNode): boolean => { const fn = nearestFunction(node); @@ -548,21 +518,12 @@ export const rule = defineRule({ fn, {}, (current) => { - if ( - current !== fn && - ['FunctionDeclaration', 'FunctionExpression'].includes(current.type) - ) - return false; + if (current !== fn && ['FunctionDeclaration', 'FunctionExpression'].includes(current.type)) return false; if (current.type !== 'CallExpression') return; const callee = unwrap(current.callee); - if ( - callee.type === 'Identifier' && - resolveVariable(context, callee.name, callee) === binding - ) + if (callee.type === 'Identifier' && resolveVariable(context, callee.name, callee) === binding) recursive = true; - if ( - ['values', 'entries', 'keys'].some((method) => isGlobalMethod(callee, 'Object', method)) - ) + if (['values', 'entries', 'keys'].some((method) => isGlobalMethod(callee, 'Object', method))) genericKeys = true; if (isGlobalMethod(callee, 'Array', 'isArray')) array = true; }, @@ -587,14 +548,9 @@ export const rule = defineRule({ (serializedDocument(left) || serializedDocument(right)) ); }; - const reportMembership = ( - node: AnyNode, - receiver: AnyNode | undefined, - keyNode: AnyNode | undefined, - ): void => { + const reportMembership = (node: AnyNode, receiver: AnyNode | undefined, keyNode: AnyNode | undefined): void => { if (!receiver || receiver.type === 'SpreadElement' || !isDocumentReceiver(receiver)) return; - if (!keyNode || keyNode.type === 'SpreadElement' || keyNode.type === 'PrivateIdentifier') - return; + if (!keyNode || keyNode.type === 'SpreadElement' || keyNode.type === 'PrivateIdentifier') return; const key = asStringLiteral(keyNode); if (key !== null && !allowedKeys.has(key)) report(node, 'documentKeyProbe'); }; @@ -612,18 +568,11 @@ export const rule = defineRule({ return true; }; const spreadKeys = (node: AnyNode): AnyNode => { - if ( - node.type === 'ArrayExpression' && - node.elements.length === 1 && - node.elements[0]?.type === 'SpreadElement' - ) + if (node.type === 'ArrayExpression' && node.elements.length === 1 && node.elements[0]?.type === 'SpreadElement') return unwrap(node.elements[0].argument); return node; }; - const reportExactKeyJoin = ( - call: ESTree.CallExpression, - member: ESTree.MemberExpression, - ): void => { + const reportExactKeyJoin = (call: ESTree.CallExpression, member: ESTree.MemberExpression): void => { const sorted = unwrap(member.object); if (sorted.type !== 'CallExpression') return; const sortCallee = unwrap(sorted.callee); @@ -638,10 +587,7 @@ export const rule = defineRule({ const isStaticMembership = (member: ESTree.MemberExpression, method: string | null): boolean => (method === 'hasOwn' && isGlobalHost(member.object, 'Object')) || (method === 'has' && isGlobalHost(member.object, 'Reflect')); - const reportMemberCall = ( - call: ESTree.CallExpression, - member: ESTree.MemberExpression, - ): void => { + const reportMemberCall = (call: ESTree.CallExpression, member: ESTree.MemberExpression): void => { const method = staticPropertyName(member); if (isStaticMembership(member, method)) { reportMembership(call, call.arguments[0], call.arguments[1]); diff --git a/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts b/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts index 32a5277b5..33caec59c 100644 --- a/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts +++ b/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts @@ -13,12 +13,11 @@ * Report only; no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Scope } from '@oxlint/plugins'; -import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; -import { booleanOption as boolean, stringList } from '../shared/options.ts'; import { unwrapNode, unwrapType } from '../shared/ast.ts'; +import { booleanOption as boolean, stringList } from '../shared/options.ts'; +import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; import { spanOf } from '../shared/reporting.ts'; type AnyNode = ESTree.Node; @@ -67,9 +66,7 @@ interface Span { function definesSpan(definitionNode: AnyNode | null, declarators: readonly Span[]): boolean { const span = spanOf(definitionNode); if (span === null) return false; - return declarators.some( - (declarator) => span.start <= declarator.start && span.end >= declarator.end, - ); + return declarators.some((declarator) => span.start <= declarator.start && span.end >= declarator.end); } const VALUE_WRAPPERS = new Set([ @@ -104,10 +101,7 @@ function isSymbolTypeAnnotation(annotation: AnyNode | null | undefined): boolean if (inner.type === 'TSSymbolKeyword') return true; if (inner.type !== 'TSTypeOperator') return false; const operator = inner as ESTree.TSTypeOperator; - return ( - operator.operator === 'unique' && - unwrapType(operator.typeAnnotation as AnyNode).type === 'TSSymbolKeyword' - ); + return operator.operator === 'unique' && unwrapType(operator.typeAnnotation as AnyNode).type === 'TSSymbolKeyword'; } /** `Symbol('…')` / `Symbol.for('…')` — the callee must be the *global* `Symbol`. */ @@ -122,17 +116,13 @@ function isSymbolFactoryCall(node: AnyNode | null | undefined, symbolIsGlobal: b } function isSymbolForMember(callee: ESTree.MemberExpression): boolean { - const member = callee as unknown as { computed: boolean; object: AnyNode; property: AnyNode }; - if ( - member.computed || - member.object.type !== 'Identifier' || - member.property.type !== 'Identifier' - ) - return false; - return ( - (member.object as { name: string }).name === 'Symbol' && - (member.property as { name: string }).name === 'for' - ); + const member = callee as unknown as { + computed: boolean; + object: AnyNode; + property: AnyNode; + }; + if (member.computed || member.object.type !== 'Identifier' || member.property.type !== 'Identifier') return false; + return (member.object as { name: string }).name === 'Symbol' && (member.property as { name: string }).name === 'for'; } /** A type that carries no capability: `true`, `'tag'`, `typeof X`, `symbol`, or `X` named like the key. */ @@ -150,8 +140,7 @@ function isMarkerType(annotation: AnyNode | null | undefined, keyName: string): ].includes(type.type) ) return true; - if (type.type === 'TSUnionType') - return type.types.every((member) => isMarkerType(member, keyName)); + if (type.type === 'TSUnionType') return type.types.every((member) => isMarkerType(member, keyName)); if (type.type === 'TSTypeQuery') return true; if (type.type === 'TSSymbolKeyword') return true; if (type.type === 'TSTypeOperator') return isSymbolTypeAnnotation(type); @@ -178,17 +167,14 @@ function typeOfAnnotation(holder: { typeAnnotation?: unknown } | null | undefine type SymbolKind = 'local' | 'import'; /** Statements that can hold the program-scope `const x: unique symbol = Symbol(…)` declarations. */ -function programVariableDeclarations( - program: ESTree.Program, -): readonly ESTree.VariableDeclaration[] { +function programVariableDeclarations(program: ESTree.Program): readonly ESTree.VariableDeclaration[] { const declarations: ESTree.VariableDeclaration[] = []; const statements = [...program.body] as AnyNode[]; for (let index = 0; index < statements.length; index++) { const statement = statements[index]!; if (statement.type === 'TSModuleDeclaration' && statement.body?.type === 'TSModuleBlock') statements.push(...statement.body.body); - if (statement.type === 'ExportNamedDeclaration' && statement.declaration) - statements.push(statement.declaration); + if (statement.type === 'ExportNamedDeclaration' && statement.declaration) statements.push(statement.declaration); const declaration = statementVariableDeclaration(statement); if (declaration) declarations.push(declaration); } @@ -210,10 +196,7 @@ function hasSymbolImport(program: ESTree.Program): boolean { ); } -function symbolIsGlobal( - program: ESTree.Program, - declarations: readonly ESTree.VariableDeclaration[], -): boolean { +function symbolIsGlobal(program: ESTree.Program, declarations: readonly ESTree.VariableDeclaration[]): boolean { return ( !hasSymbolImport(program) && !declarations.some((declaration) => @@ -258,8 +241,7 @@ function hasNamedOrDefaultImport(program: ESTree.Program): boolean { (statement) => statement.type === 'ImportDeclaration' && statement.specifiers.some( - (specifier) => - specifier.type === 'ImportSpecifier' || specifier.type === 'ImportDefaultSpecifier', + (specifier) => specifier.type === 'ImportSpecifier' || specifier.type === 'ImportDefaultSpecifier', ), ); } @@ -301,8 +283,7 @@ export const rule = defineRule({ ignore: { type: 'array', items: { type: 'string' }, - description: - 'Globs exempted from the rule (default: none — no carve-out has been ratified).', + description: 'Globs exempted from the rule (default: none — no carve-out has been ratified).', }, includePaths: { type: 'array', @@ -363,9 +344,7 @@ export const rule = defineRule({ } const spans = localSymbols.get(name); if (spans === undefined) return null; - return variable.defs.some((definition) => definesSpan(definition.node as AnyNode, spans)) - ? 'local' - : null; + return variable.defs.some((definition) => definesSpan(definition.node as AnyNode, spans)) ? 'local' : null; } scope = scope.upper; } @@ -390,14 +369,12 @@ export const rule = defineRule({ /** Imported names proven to be symbol slots because this file uses them as *type* member keys. */ const importedSlotKeys = new Set(); /** `record[importedKey]` reads, resolved once the whole program has been walked. */ - const importedAccesses: { readonly node: AnyNode; readonly slot: string }[] = []; - - const recordSlot = ( - node: AnyNode, - name: string, - kind: SymbolKind, - isTypeMember: boolean, - ): void => { + const importedAccesses: { + readonly node: AnyNode; + readonly slot: string; + }[] = []; + + const recordSlot = (node: AnyNode, name: string, kind: SymbolKind, isTypeMember: boolean): void => { if (kind === 'import' && isTypeMember) importedSlotKeys.add(name); pending.push({ kind, @@ -418,11 +395,7 @@ export const rule = defineRule({ return { TSMappedType(node: any) { - inspectKeyType( - node, - node.typeParameter?.constraint ?? node.constraint, - node.typeAnnotation, - ); + inspectKeyType(node, node.typeParameter?.constraint ?? node.constraint, node.typeAnnotation); }, TSTypeReference(node: any) { if (node.typeName.type !== 'Identifier' || node.typeName.name !== 'Record') return; @@ -453,7 +426,10 @@ export const rule = defineRule({ // `[actionHandler](payload: P): Effect<…>` — a method slot is never a brand marker. TSMethodSignature(node) { - const signature = node as unknown as { computed: boolean; key: AnyNode }; + const signature = node as unknown as { + computed: boolean; + key: AnyNode; + }; const name = keyName(signature.key, signature.computed); if (name === null) return; const kind = classify(node as unknown as AnyNode, name); @@ -463,14 +439,22 @@ export const rule = defineRule({ // `{ [actionHandler]: handler, [actionRegistration]: true as const }`. Property(node) { - const property = node as unknown as { computed: boolean; key: AnyNode; value: AnyNode }; + const property = node as unknown as { + computed: boolean; + key: AnyNode; + value: AnyNode; + }; const name = keyName(property.key, property.computed); if (name === null) return; const kind = classify(node as unknown as AnyNode, name); if (kind === null) return; if ((node as any).parent?.type === 'ObjectPattern') { if (!options.allowSameFileAccessors) - context.report({ node, messageId: 'symbolSlotAccess', data: { slot: name } }); + context.report({ + node, + messageId: 'symbolSlotAccess', + data: { slot: name }, + }); return; } if (options.allowBrandMarkers && isMarkerValue(property.value, name)) return; @@ -491,12 +475,7 @@ export const rule = defineRule({ if (kind === null) return; if (options.allowBrandMarkers) { const annotation = typeOfAnnotation(property); - if ( - annotation !== null - ? isMarkerType(annotation, name) - : isMarkerValue(property.value, name) - ) - return; + if (annotation !== null ? isMarkerType(annotation, name) : isMarkerValue(property.value, name)) return; } recordSlot(node as unknown as AnyNode, name, kind, false); }, @@ -529,7 +508,10 @@ export const rule = defineRule({ // `registration[actionHandler]` — the accessor a symbol slot forces on consumers. MemberExpression(node) { - const member = node as unknown as { computed: boolean; property: AnyNode }; + const member = node as unknown as { + computed: boolean; + property: AnyNode; + }; if (!member.computed) return; const inner = unwrapValue(member.property); if (inner.type !== 'Identifier') return; @@ -537,7 +519,10 @@ export const rule = defineRule({ const kind = classify(node as unknown as AnyNode, name); if (kind === null) return; if (kind === 'import') { - importedAccesses.push({ node: node as unknown as AnyNode, slot: name }); + importedAccesses.push({ + node: node as unknown as AnyNode, + slot: name, + }); return; } if (options.allowSameFileAccessors) return; diff --git a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts index e580d001c..69ea984a7 100644 --- a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts @@ -59,26 +59,19 @@ * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { booleanOption, optionRecord, stringArray } from '../shared/options.ts'; import { keyName } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; -import { schemaIdentity } from '../shared/schema-identity.ts'; +import { booleanOption, optionRecord, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; import { isNonReferencePosition, isInErasedTypePosition } from '../shared/reference-positions.ts'; +import { schemaIdentity } from '../shared/schema-identity.ts'; const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; /** Synchronous, throwing codec entry points. Everything here has an `Effect`/`Result` sibling. */ -const DEFAULT_MEMBERS = [ - 'decodeSync', - 'decodeUnknownSync', - 'encodeSync', - 'encodeUnknownSync', - 'validateSync', -]; +const DEFAULT_MEMBERS = ['decodeSync', 'decodeUnknownSync', 'encodeSync', 'encodeUnknownSync', 'validateSync']; /** * Bundler / test-runner configuration roots. These modules are evaluated by the framework before any @@ -217,16 +210,10 @@ export const rule = defineRule({ } }, ExportNamedDeclaration(node) { - if ( - !node.source || - !EFFECT_SCHEMA_MODULE.test(node.source.value) || - node.exportKind === 'type' - ) - return; + if (!node.source || !EFFECT_SCHEMA_MODULE.test(node.source.value) || node.exportKind === 'type') return; for (const specifier of node.specifiers) { if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') continue; - const member = - specifier.local.type === 'Identifier' ? specifier.local.name : specifier.local.value; + const member = specifier.local.type === 'Identifier' ? specifier.local.name : specifier.local.value; if (members.has(member)) report(specifier, member); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts b/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts index 6f5d25034..2084438ea 100644 --- a/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts +++ b/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; + /** * Audit A6 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) targets repeated request * identity inputs and asks for ambient services/references plus one instrumentation seam. @@ -24,14 +26,12 @@ * Report-only, with no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { fileURLToPath } from 'node:url'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; import { keyName as staticKeyName, parentOf, unwrapBinding } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; import { compile, stringList } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; @@ -46,12 +46,7 @@ const DEFAULT_IGNORE: readonly string[] = []; const MEMBER_CONTAINERS = new Set(['TSInterfaceBody', 'TSTypeLiteral']); /** Type wrappers that never change which members an object type declares. */ -const TYPE_WRAPPERS = new Set([ - 'TSParenthesizedType', - 'TSTypeOperator', - 'TSArrayType', - 'TSOptionalType', -]); +const TYPE_WRAPPERS = new Set(['TSParenthesizedType', 'TSTypeOperator', 'TSArrayType', 'TSOptionalType']); interface RuleOptions { readonly ambientKeys: ReadonlySet; @@ -140,9 +135,7 @@ function inlineMemberKeys(annotation: AnyNode | null | undefined, depth = 0): Re const keys = new Set(); if (annotation === null || annotation === undefined || depth > 4) return keys; const node = - annotation.type === 'TSTypeAnnotation' - ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation - : annotation; + annotation.type === 'TSTypeAnnotation' ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation : annotation; for (const child of inlineTypeChildren(node)) { for (const key of inlineMemberKeys(child, depth + 1)) keys.add(key); } @@ -167,9 +160,7 @@ function inlineTypeChildren(node: AnyNode): readonly (AnyNode | null | undefined } if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') return node.types; if (node.type !== 'TSTypeLiteral') return []; - return node.members.flatMap((member) => - member.type === 'TSPropertySignature' ? [member.typeAnnotation] : [], - ); + return node.members.flatMap((member) => (member.type === 'TSPropertySignature' ? [member.typeAnnotation] : [])); } function importedContextBinding(definition: Variable['defs'][number]): string | null { @@ -183,19 +174,13 @@ function importedContextBinding(definition: Variable['defs'][number]): string | function contextImportName(source: string, specifier: AnyNode): string | null { if (source === 'effect/Context') - return specifier.type === 'ImportSpecifier' - ? `Context.${keyName(specifier.imported, false)}` - : 'Context'; + return specifier.type === 'ImportSpecifier' ? `Context.${keyName(specifier.imported, false)}` : 'Context'; if (source !== 'effect' && source !== '@modern-js/plugin-bff/effect-edge') return null; if (specifier.type === 'ImportNamespaceSpecifier') return '$root'; return specifier.type === 'ImportSpecifier' ? keyName(specifier.imported, false) : null; } -function variableContextBinding( - context: Context, - node: AnyNode, - seen: Set, -): string | null { +function variableContextBinding(context: Context, node: AnyNode, seen: Set): string | null { const variable = lookupVariable(context, node); if (!variable || seen.has(variable)) return null; seen.add(variable); @@ -207,11 +192,7 @@ function variableContextBinding( return contextBinding(context, definition.node.init, seen); } -function contextBinding( - context: Context, - node: AnyNode, - seen = new Set(), -): string | null { +function contextBinding(context: Context, node: AnyNode, seen = new Set()): string | null { if ( [ 'TSAsExpression', @@ -236,11 +217,7 @@ function contextBinding( function isAmbientOrReadType(context: Context, from: AnyNode): boolean { let node = parentOf(from); while (node) { - if ( - ['TSAsExpression', 'TSTypeAssertion', 'TSTypePredicate', 'TSSatisfiesExpression'].includes( - node.type, - ) - ) + if (['TSAsExpression', 'TSTypeAssertion', 'TSTypePredicate', 'TSSatisfiesExpression'].includes(node.type)) return true; if ( node.type === 'CallExpression' && @@ -264,8 +241,7 @@ function isAmbientOrReadType(context: Context, from: AnyNode): boolean { } function isConciseWireProjection(owner: AnyNode, wireTypeNames: RegExp): boolean { - if (owner.type !== 'ArrowFunctionExpression' || owner.body.type !== 'ObjectExpression') - return false; + if (owner.type !== 'ArrowFunctionExpression' || owner.body.type !== 'ObjectExpression') return false; const output = owner.returnType?.typeAnnotation; return ( output?.type === 'TSTypeReference' && @@ -279,16 +255,9 @@ function isWireProjection(from: AnyNode, wireTypeNames: RegExp): boolean { let owner = parentOf(from); while ( owner && - ![ - 'BlockStatement', - 'TSPropertySignature', - 'TSInterfaceDeclaration', - 'TSTypeAliasDeclaration', - ].includes(owner.type) + !['BlockStatement', 'TSPropertySignature', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration'].includes(owner.type) ) { - if ( - ['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(owner.type) - ) + if (['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(owner.type)) return isConciseWireProjection(owner, wireTypeNames); owner = parentOf(owner); } @@ -358,18 +327,14 @@ export const rule = defineRule({ const options = readOptions(context.options[0]); const path = scopePath(context.filename); if (!matchesGlobs(path, options.includePaths)) return {}; - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) + if (/\.d\.[cm]?ts$/u.test(path) || /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path)) return {}; if (matchesGlobs(path, options.ignore)) return {}; if (/(?:^|\/)scripts\//u.test(path)) return {}; if (!options.includeTests && isTestFile(path)) return {}; /** The HTTP/transport edge the audit blesses: a wire-named enclosing declaration. */ - const isWireEdge = (names: readonly string[]): boolean => - names.some((name) => options.wireTypeNames.test(name)); + const isWireEdge = (names: readonly string[]): boolean => names.some((name) => options.wireTypeNames.test(name)); /** * `node` is what gets underlined; `from` is where the enclosing-declaration walk starts, so a @@ -387,7 +352,11 @@ export const rule = defineRule({ if (isAmbientOrReadType(context, from)) return; if (isWireProjection(from, options.wireTypeNames)) return; } - context.report({ data: { key, owner: names[0] ?? '' }, messageId, node }); + context.report({ + data: { key, owner: names[0] ?? '' }, + messageId, + node, + }); }; /** Report every ambient key destructured by a parameter pattern (top level + one nesting). */ @@ -395,7 +364,11 @@ export const rule = defineRule({ if (pattern.type !== 'ObjectPattern') return; for (const property of (pattern as { properties: readonly AnyNode[] }).properties) { if (property.type !== 'Property') continue; - const entry = property as unknown as { key: AnyNode; computed: boolean; value: AnyNode }; + const entry = property as unknown as { + key: AnyNode; + computed: boolean; + value: AnyNode; + }; const name = keyName(entry.key, entry.computed); if (name !== null && options.ambientKeys.has(name) && !skip.has(name)) { report(entry.key, property, 'threadedParameter', name); @@ -415,9 +388,7 @@ export const rule = defineRule({ } // An inline object type on the pattern declares the same keys; let the member visitor // report those so `({ correlationId }: { readonly correlationId: string })` counts once. - const skip = inlineMemberKeys( - (binding as { typeAnnotation?: AnyNode | null }).typeAnnotation, - ); + const skip = inlineMemberKeys((binding as { typeAnnotation?: AnyNode | null }).typeAnnotation); inspectPattern(binding, skip, 0); } }; @@ -446,7 +417,10 @@ export const rule = defineRule({ TSAbstractPropertyDefinition: inspectClassField, TSAbstractAccessorProperty: inspectClassField, TSPropertySignature(node) { - const signature = node as unknown as { key: AnyNode; computed: boolean }; + const signature = node as unknown as { + key: AnyNode; + computed: boolean; + }; const parent = parentOf(node as unknown as AnyNode); if (parent === null || !MEMBER_CONTAINERS.has(parent.type)) return; const name = keyName(signature.key, signature.computed); diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts index 97974bf4b..abc875225 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-throw-in-configuration-parser * @@ -85,19 +84,13 @@ import { optionRecord } from '../shared/options.ts'; * The rule never fixes and never suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { - keyName as staticKeyName, - memberName, - parentOf, - isFunctionNode, - unwrapNode, -} from '../shared/ast.ts'; +import { keyName as staticKeyName, memberName, parentOf, isFunctionNode, unwrapNode } from '../shared/ast.ts'; import { resolveVariable as lookupNamedVariable } from '../shared/bindings.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray, safeRegExp, stringOption, positiveInteger } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; type AnyNode = ESTree.Node; @@ -242,10 +235,7 @@ function readOptions(context: Context): RuleOptions { allowPaths: stringArray(record.allowPaths, DEFAULTS.allowPaths), ignoreTestFiles: record.ignoreTestFiles !== false, includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, - environmentIdentifiers: stringOption( - record.environmentIdentifiers, - DEFAULTS.environmentIdentifiers, - ), + environmentIdentifiers: stringOption(record.environmentIdentifiers, DEFAULTS.environmentIdentifiers), environmentReaders: stringArray(record.environmentReaders, DEFAULTS.environmentReaders), environmentTypeNames: stringOption(record.environmentTypeNames, DEFAULTS.environmentTypeNames), followLocalHelpers: record.followLocalHelpers !== false, @@ -307,13 +297,11 @@ export const rule = defineRule({ allowPaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs of files allowed to throw from a configuration parser (default: none).', + description: 'Globs of files allowed to throw from a configuration parser (default: none).', }, ignoreTestFiles: { type: 'boolean', - description: - 'Skip test files (default: true — the D tier blesses fail-fast throws in tests).', + description: 'Skip test files (default: true — the D tier blesses fail-fast throws in tests).', }, includePaths: { type: 'array', @@ -329,8 +317,7 @@ export const rule = defineRule({ environmentReaders: { type: 'array', items: { type: 'string' }, - description: - 'Hand-rolled single-variable readers whose call counts as an environment read.', + description: 'Hand-rolled single-variable readers whose call counts as an environment read.', }, environmentTypeNames: { type: 'string', @@ -372,14 +359,8 @@ export const rule = defineRule({ if (/(?:^|\/)scripts\//u.test(path)) return {}; if (options.ignoreTestFiles && isTestFile(`/${path}`)) return {}; - const environmentIdentifier = safeRegExp( - options.environmentIdentifiers, - DEFAULTS.environmentIdentifiers, - ); - const environmentTypeName = safeRegExp( - options.environmentTypeNames, - DEFAULTS.environmentTypeNames, - ); + const environmentIdentifier = safeRegExp(options.environmentIdentifiers, DEFAULTS.environmentIdentifiers); + const environmentTypeName = safeRegExp(options.environmentTypeNames, DEFAULTS.environmentTypeNames); const environmentReaders = new Set(options.environmentReaders); /** `start` offsets of functions proven to parse configuration. */ @@ -398,8 +379,7 @@ export const rule = defineRule({ const throwRecords: ThrowRecord[] = []; - const resolveVariable = (name: string, from: AnyNode): Variable | null => - lookupNamedVariable(context, name, from); + const resolveVariable = (name: string, from: AnyNode): Variable | null => lookupNamedVariable(context, name, from); /** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ const isUnshadowedGlobal = (node: AnyNode, name: string): boolean => { @@ -443,16 +423,10 @@ export const rule = defineRule({ member: specifier.type === 'ImportSpecifier' ? (keyName(specifier.imported) ?? '') : '*', }; }; - const isIdentifierEnvHost = ( - node: Extract, - depth: number, - ): boolean => { + const isIdentifierEnvHost = (node: Extract, depth: number): boolean => { const imported = importOf(node); if (imported) - return ( - PROCESS_MODULES.has(imported.source) && - (imported.member === '*' || imported.member === 'default') - ); + return PROCESS_MODULES.has(imported.source) && (imported.member === '*' || imported.member === 'default'); if (ENV_HOSTS.has(node.name) && isUnshadowedGlobal(node, node.name)) return true; const declaration = declarationOf(node); return ( @@ -467,14 +441,12 @@ export const rule = defineRule({ if (node.type === 'AwaitExpression') return isEnvHost(node.argument, depth + 1); if (node.type === 'ImportExpression') return node.source.type === 'Literal' && PROCESS_MODULES.has(String(node.source.value)); - if (node.type === 'MetaProperty') - return node.meta.name === 'import' && node.property.name === 'meta'; + if (node.type === 'MetaProperty') return node.meta.name === 'import' && node.property.name === 'meta'; if (node.type === 'Identifier') return isIdentifierEnvHost(node, depth); return isContainerEnvHost(node); }; const isContainerEnvHost = (node: AnyNode): boolean => { - if (node.type !== 'MemberExpression' || !ENV_HOSTS.has(staticPropertyName(node) ?? '')) - return false; + if (node.type !== 'MemberExpression' || !ENV_HOSTS.has(staticPropertyName(node) ?? '')) return false; const container = unwrap(node.object as AnyNode); return ( container.type === 'Identifier' && @@ -484,9 +456,7 @@ export const rule = defineRule({ }; const isEnvironmentClassMember = (member: ESTree.Node, key: string): boolean => { if (member.type === 'PropertyDefinition' && keyName(member.key) === key) - return ( - !!member.typeAnnotation && isEnvironmentRecordType(member.typeAnnotation.typeAnnotation) - ); + return !!member.typeAnnotation && isEnvironmentRecordType(member.typeAnnotation.typeAnnotation); if (member.type !== 'MethodDefinition' || member.kind !== 'constructor') return false; return member.value.params.some( (parameter) => @@ -501,20 +471,12 @@ export const rule = defineRule({ if (node.object.type !== 'ThisExpression') return false; const key = staticPropertyName(node); let enclosing = parentOf(node); - while ( - enclosing && - enclosing.type !== 'ClassDeclaration' && - enclosing.type !== 'ClassExpression' - ) + while (enclosing && enclosing.type !== 'ClassDeclaration' && enclosing.type !== 'ClassExpression') enclosing = parentOf(enclosing); if (!enclosing || key === null) return false; return enclosing.body.body.some((member) => isEnvironmentClassMember(member, key)); }; - const initializedEnvBag = ( - declaration: ESTree.VariableDeclarator, - name: string, - depth: number, - ): boolean | null => { + const initializedEnvBag = (declaration: ESTree.VariableDeclarator, name: string, depth: number): boolean | null => { if (declaration.init) { if (declaration.id.type === 'Identifier') { const init = unwrap(declaration.init as AnyNode); @@ -547,10 +509,7 @@ export const rule = defineRule({ if (node.type !== 'Identifier') return false; return isIdentifierEnvBag(node, depth); }; - const isIdentifierEnvBag = ( - node: Extract, - depth: number, - ): boolean => { + const isIdentifierEnvBag = (node: Extract, depth: number): boolean => { const imported = importOf(node); if (imported) return PROCESS_MODULES.has(imported.source) && imported.member === 'env'; const declaration = declarationOf(node); @@ -559,19 +518,17 @@ export const rule = defineRule({ const variable = resolveVariable(node.name, node); const definition = variable?.defs[0]; const annotation = ( - definition?.name as unknown as { typeAnnotation?: { typeAnnotation: AnyNode } } + definition?.name as unknown as { + typeAnnotation?: { typeAnnotation: AnyNode }; + } )?.typeAnnotation; return ( - (annotation && isEnvironmentRecordType(annotation.typeAnnotation)) || - environmentIdentifier.test(node.name) + (annotation && isEnvironmentRecordType(annotation.typeAnnotation)) || environmentIdentifier.test(node.name) ); }; const isEnvironmentBagRead = (node: ESTree.MemberExpression, depth = 0): boolean => { const key = staticPropertyName(node); - return ( - (key === null ? node.computed : ENV_VARIABLE_KEY.test(key)) && - isEnvBag(node.object as AnyNode, depth + 1) - ); + return (key === null ? node.computed : ENV_VARIABLE_KEY.test(key)) && isEnvBag(node.object as AnyNode, depth + 1); }; const resolveFunction = (input: AnyNode): AnyNode | null => { const node = unwrap(input); @@ -592,10 +549,8 @@ export const rule = defineRule({ const isCallEnvDerived = (node: ESTree.CallExpression, depth: number): boolean => { const callee = unwrap(node.callee as AnyNode); const imported = importOf(callee); - if (callee.type === 'Identifier' && environmentReaders.has(imported?.member ?? callee.name)) - return true; - if (callee.type === 'MemberExpression' && isEnvDerived(callee.object as AnyNode, depth + 1)) - return true; + if (callee.type === 'Identifier' && environmentReaders.has(imported?.member ?? callee.name)) return true; + if (callee.type === 'MemberExpression' && isEnvDerived(callee.object as AnyNode, depth + 1)) return true; const target = resolveFunction(callee); if (!target) return false; const body = (target as ESTree.ArrowFunctionExpression).body; @@ -650,13 +605,10 @@ export const rule = defineRule({ /** `{ name, typeText }` for one formal parameter; `name` is `null` for destructured params. */ const parameterInfo = (parameter: AnyNode): { name: string | null; type: AnyNode | null } => { let target = parameter; - if (target.type === 'TSParameterProperty') - target = (target as { parameter: AnyNode }).parameter; - if (target.type === 'AssignmentPattern') - target = (target as ESTree.AssignmentPattern).left as AnyNode; + if (target.type === 'TSParameterProperty') target = (target as { parameter: AnyNode }).parameter; + if (target.type === 'AssignmentPattern') target = (target as ESTree.AssignmentPattern).left as AnyNode; if (target.type === 'RestElement') target = (target as { argument: AnyNode }).argument; - const annotation = (target as { typeAnnotation?: { typeAnnotation?: AnyNode } | null }) - .typeAnnotation; + const annotation = (target as { typeAnnotation?: { typeAnnotation?: AnyNode } | null }).typeAnnotation; const type = annotation?.typeAnnotation ?? null; const name = target.type === 'Identifier' ? (target as ESTree.BindingIdentifier).name : null; return { name, type }; @@ -669,8 +621,7 @@ export const rule = defineRule({ const reference = node as ESTree.TSTypeReference; const name = qualifiedTypeName(reference.typeName as AnyNode); if (name !== null) names.push(name); - const parameters = (reference as { typeArguments?: { params?: AnyNode[] } | null }) - .typeArguments; + const parameters = (reference as { typeArguments?: { params?: AnyNode[] } | null }).typeArguments; for (const parameter of parameters?.params ?? []) visit(parameter, depth + 1); }; const visit = (node: AnyNode | null | undefined, depth: number): void => { @@ -684,8 +635,7 @@ export const rule = defineRule({ return; } if (['TSUnionType', 'TSIntersectionType'].includes(node.type)) { - for (const member of (node as { types?: AnyNode[] }).types ?? []) - visit(member, depth + 1); + for (const member of (node as { types?: AnyNode[] }).types ?? []) visit(member, depth + 1); } }; visit(type, 0); @@ -730,29 +680,20 @@ export const rule = defineRule({ if (type.type !== 'TSTypeReference') return false; return isEnvironmentTypeReference(type, depth, optional); }; - const isEnvironmentTypeReference = ( - type: ESTree.TSTypeReference, - depth: number, - optional: boolean, - ): boolean => { + const isEnvironmentTypeReference = (type: ESTree.TSTypeReference, depth: number, optional: boolean): boolean => { const name = qualifiedTypeName(type.typeName); const args = type.typeArguments?.params ?? []; const resolved = resolvedEnvironmentType(type, depth, optional); if (resolved !== null) return resolved; if (name === 'NodeJS.ProcessEnv') return true; if (name === 'Readonly' || name === 'Partial') { - return ( - !!args[0] && isEnvironmentRecordType(args[0], depth + 1, optional || name === 'Partial') - ); + return !!args[0] && isEnvironmentRecordType(args[0], depth + 1, optional || name === 'Partial'); } return name === 'Record' && isStringRecordArguments(args, optional); }; const isStringRecordArguments = (args: readonly AnyNode[], optional: boolean): boolean => { return ( - args.length === 2 && - args[0]?.type === 'TSStringKeyword' && - !!args[1] && - isStringValueType(args[1], optional) + args.length === 2 && args[0]?.type === 'TSStringKeyword' && !!args[1] && isStringValueType(args[1], optional) ); }; const isStringValueType = (type: AnyNode, optional: boolean): boolean => { @@ -761,12 +702,8 @@ export const rule = defineRule({ type.type === 'TSUnionType' && type.types.some((member) => member.type === 'TSStringKeyword') && (optional || - type.types.some( - (member) => member.type === 'TSNullKeyword' || member.type === 'TSUndefinedKeyword', - )) && - type.types.every((member) => - ['TSStringKeyword', 'TSNullKeyword', 'TSUndefinedKeyword'].includes(member.type), - ) + type.types.some((member) => member.type === 'TSNullKeyword' || member.type === 'TSUndefinedKeyword')) && + type.types.every((member) => ['TSStringKeyword', 'TSNullKeyword', 'TSUndefinedKeyword'].includes(member.type)) ); }; @@ -779,8 +716,7 @@ export const rule = defineRule({ const { name, type } = parameterInfo(parameter); if (type !== null) return ( - isEnvironmentRecordType(type) || - typeNamesIn(type).some((typeName) => environmentTypeName.test(typeName)) + isEnvironmentRecordType(type) || typeNamesIn(type).some((typeName) => environmentTypeName.test(typeName)) ); return name !== null && environmentIdentifier.test(name); }; @@ -853,8 +789,7 @@ export const rule = defineRule({ let parent = parentOf(target); if (parent?.type === 'VariableDeclarator') parent = parentOf(parent); if (parent?.type === 'VariableDeclaration') parent = parentOf(parent); - if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') - return false; + if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') return false; return true; }; const hasNonCallReferences = (start: number, sites: readonly CallSite[]): boolean => { @@ -876,17 +811,13 @@ export const rule = defineRule({ }; const canMarkHelper = (start: number, target: AnyNode): boolean => { if (markedFunctions.has(start) || !isPrivateHelper(target)) return false; - const sites = calls.filter( - (entry) => resolveFunction(entry.node.callee as AnyNode)?.start === start, - ); - if (sites.length === 0 || sites.some((entry) => !markedFunctions.has(entry.owner))) - return false; + const sites = calls.filter((entry) => resolveFunction(entry.node.callee as AnyNode)?.start === start); + if (sites.length === 0 || sites.some((entry) => !markedFunctions.has(entry.owner))) return false; if (hasNonCallReferences(start, sites)) return false; if ( !sites.every( (entry) => - helperMarked.has(entry.owner) || - entry.node.arguments.some((argument) => isEnvDerived(argument as AnyNode)), + helperMarked.has(entry.owner) || entry.node.arguments.some((argument) => isEnvDerived(argument as AnyNode)), ) ) return false; @@ -909,14 +840,18 @@ export const rule = defineRule({ if (record.insideEffectCallback) return; if (record.chain.length === 0) { if (markedModule) - context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); + context.report({ + node: record.node, + messageId: 'throwInConfigurationParser', + }); return; } - const parser = record.chain.find( - (start) => markedFunctions.has(start) && !helperMarked.has(start), - ); + const parser = record.chain.find((start) => markedFunctions.has(start) && !helperMarked.has(start)); if (parser !== undefined) { - context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); + context.report({ + node: record.node, + messageId: 'throwInConfigurationParser', + }); return; } const helper = record.chain.find((start) => helperMarked.has(start)); diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts index 5c274759c..b84d9770f 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-throw-in-effect-callback * @@ -86,25 +85,16 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; -import { - unwrapNode as unwrap, - parentOf, - nearestFunction as enclosingFunction, - FUNCTION_TYPES, -} from '../shared/ast.ts'; +import { unwrapNode as unwrap, parentOf, nearestFunction as enclosingFunction, FUNCTION_TYPES } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; import { effectOrigin } from '../shared/effect-identity.ts'; -import { - collectRootNamespaces, - collectDirectMemberImports, - importDeclarations, -} from '../shared/imports.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { collectRootNamespaces, collectDirectMemberImports, importDeclarations } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; /** S1/A4 are application-architecture findings: `scripts/**` is excluded on purpose (see B3). */ const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -124,17 +114,7 @@ const DEFAULT_IGNORE = [ * Effect namespaces whose combinators take user callbacks that run *inside* a fiber. A `throw` in * any of them bypasses the typed failure channel of the surrounding program. */ -const DEFAULT_NAMESPACES = [ - 'Effect', - 'Layer', - 'Stream', - 'Schedule', - 'Cause', - 'Exit', - 'Option', - 'Result', - 'Match', -]; +const DEFAULT_NAMESPACES = ['Effect', 'Layer', 'Stream', 'Schedule', 'Cause', 'Exit', 'Option', 'Result', 'Match']; /** Import sources that make a thrown constructor "module-local" — i.e. a private sentinel. */ const DEFAULT_LOCAL_IMPORT_PREFIXES = ['./', '../', '@app/']; @@ -196,9 +176,7 @@ function collectModuleView(program: ESTree.Program, options: RuleOptions): boole program, (source) => source !== EFFECT_ROOT_MODULE && options.effectModules.includes(source), ); - return ( - shared.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0 || barrels.length > 0 - ); + return shared.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0 || barrels.length > 0; } /** `Effect.gen` / `E.gen` / `Effect["gen"]` / `Eff.Effect.gen` / bare `gen` from `effect/Effect`. */ @@ -234,36 +212,23 @@ function argumentCall(node: ESTree.Node): ESTree.CallExpression | null { } function isAdapterCall(context: Context, call: ESTree.CallExpression): boolean { - const origin = effectOrigin(context, call.callee, [ - 'react', - '@tanstack/react-query', - '@tanstack/react-router', - ]); + const origin = effectOrigin(context, call.callee, ['react', '@tanstack/react-query', '@tanstack/react-router']); return ( origin?.length === 1 && - ['useCallback', 'useMutation', 'useQuery', 'queryOptions', 'mutationOptions'].includes( - origin[0]!, - ) + ['useCallback', 'useMutation', 'useQuery', 'queryOptions', 'mutationOptions'].includes(origin[0]!) ); } function isDataCall(context: Context, call: ESTree.CallExpression, options: RuleOptions): boolean { const origin = effectOrigin(context, call.callee, options.effectModules); - return ( - origin?.length === 2 && - ['succeed', 'fail', 'die', 'fromNullable', 'fromIterable'].includes(origin[1]!) - ); + return origin?.length === 2 && ['succeed', 'fail', 'die', 'fromNullable', 'fromIterable'].includes(origin[1]!); } /** * Transitively: is this node lexically inside a callback passed to an Effect combinator? Nested * non-Effect callbacks (`db.transaction(async (tx) => …)`) keep climbing to their outer function. */ -function isInsideEffectCallback( - context: Context, - node: ESTree.Node, - options: RuleOptions, -): boolean { +function isInsideEffectCallback(context: Context, node: ESTree.Node, options: RuleOptions): boolean { let cursor: ESTree.Node = node; for (;;) { const fn = enclosingFunction(cursor); @@ -279,10 +244,7 @@ function isInsideEffectCallback( } /** The module specifier a definition came from, when the definition is an import binding. */ -function importSourceOf(definition: { - node: ESTree.Node; - parent: ESTree.Node | null; -}): string | null { +function importSourceOf(definition: { node: ESTree.Node; parent: ESTree.Node | null }): string | null { let current: ESTree.Node | null = definition.parent ?? definition.node; for (let depth = 0; current !== null && depth < 6; depth += 1) { if (current.type === 'ImportDeclaration') return current.source.value; @@ -296,11 +258,7 @@ function importSourceOf(definition: { * constructor resolves to a module-local class/function/const or a project-local import. * `new Error(…)` (an unresolved global) → `null`. */ -function sentinelName( - context: Context, - argument: ESTree.Node, - options: RuleOptions, -): string | null { +function sentinelName(context: Context, argument: ESTree.Node, options: RuleOptions): string | null { const thrown = unwrap(argument); if (thrown.type !== 'NewExpression' && thrown.type !== 'CallExpression') return null; const callee = unwrap(thrown.callee as ESTree.Node); @@ -309,9 +267,7 @@ function sentinelName( const variable = lookupVariable(context, callee); if (variable === null || variable.defs.length === 0) return null; - return variable.defs.some((definition) => isLocalDefinition(definition, options)) - ? callee.name - : null; + return variable.defs.some((definition) => isLocalDefinition(definition, options)) ? callee.name : null; } function isLocalDefinition(definition: Variable['defs'][number], options: RuleOptions): boolean { diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts index 774f254ad..32a021e58 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts @@ -76,14 +76,13 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { parentOf, unwrapNode as unwrap, memberName as staticMemberName } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { globToRegExp, scriptScope, inScriptScope } from '../shared/paths.ts'; -import { parentOf, unwrapNode as unwrap, memberName as staticMemberName } from '../shared/ast.ts'; -import { resolveVariable } from '../shared/bindings.ts'; import { provenance } from '../shared/provenance.ts'; /** Native error globals. A `throw new X(...)` against one of these is the B3 "manual throw". */ @@ -121,12 +120,9 @@ function readOptions(raw: unknown): RuleOptions { : DEFAULTS.allowPaths; return { allowPaths: globs, - allowRethrow: - typeof given.allowRethrow === 'boolean' ? given.allowRethrow : DEFAULTS.allowRethrow, + allowRethrow: typeof given.allowRethrow === 'boolean' ? given.allowRethrow : DEFAULTS.allowRethrow, allowInsideEffectTry: - typeof given.allowInsideEffectTry === 'boolean' - ? given.allowInsideEffectTry - : DEFAULTS.allowInsideEffectTry, + typeof given.allowInsideEffectTry === 'boolean' ? given.allowInsideEffectTry : DEFAULTS.allowInsideEffectTry, }; } @@ -149,8 +145,7 @@ function nativeErrorName(context: Context, node: AnyNode): string | null { const variable = resolveVariable(context, name, callee); // Unresolved, or resolved only to an implicit global, means the real native constructor. if (variable === null) return name; - return variable.defs.length === 0 || - variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable') + return variable.defs.length === 0 || variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable') ? name : null; } diff --git a/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts b/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts index 69196651f..e123e71fe 100644 --- a/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A2** — "Make Schema the sole authority for contracts and domain models" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A2 measures **zero branded identifiers** and @@ -67,19 +66,19 @@ import { optionRecord } from '../shared/options.ts'; * Report-only; this rule never fixes or suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { memberName, keyName, skipWrappers, unwrapNode } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; +import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray, stringOption, safeRegExp } from '../shared/options.ts'; import { matchesGlobs } from '../shared/paths.ts'; import { isSchemaRuleInScope, isSchemaConstructorArgument as isConstructorArgument, } from '../shared/schema-rule-support.ts'; -import { stringArray, stringOption, safeRegExp } from '../shared/options.ts'; -import { memberName, keyName, skipWrappers, unwrapNode } from '../shared/ast.ts'; -import { resolveVariable } from '../shared/bindings.ts'; -import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; const SCHEMA_NAMESPACE = 'Schema'; const EFFECT_ROOT_MODULE = 'effect'; @@ -188,11 +187,7 @@ function readOptions(context: Context): RuleOptions { return { brandHelpers: stringArray(record.brandHelpers, DEFAULT_BRAND_HELPERS), identifierKeyPattern: stringOption(record.identifierKeyPattern, DEFAULT_KEY_PATTERN, false), - identifierSchemaNamePattern: stringOption( - record.identifierSchemaNamePattern, - DEFAULT_SCHEMA_NAME_PATTERN, - false, - ), + identifierSchemaNamePattern: stringOption(record.identifierSchemaNamePattern, DEFAULT_SCHEMA_NAME_PATTERN, false), ignore: stringArray(record.ignore, DEFAULT_IGNORE), ignoreTests: record.ignoreTests === true, include: stringArray(record.include, DEFAULT_INCLUDE), @@ -241,10 +236,7 @@ function collectSchemaLocals( if (namespace === SCHEMA_NAMESPACE) schema.add(local); if (namespace === 'pipe') pipe.add(local); } - const barrel = collectRootNamespaces( - program, - (source) => source === EFFECT_ROOT_MODULE || isReexport(source), - ); + const barrel = collectRootNamespaces(program, (source) => source === EFFECT_ROOT_MODULE || isReexport(source)); const reexports = collectNamedImports(program, isReexport); for (const [local, member] of reexports) { if (member === SCHEMA_NAMESPACE) schema.add(local); @@ -256,9 +248,7 @@ function collectSchemaLocals( if (KNOWN_SCHEMA_MEMBERS.has(member)) members.set(local, { declarator: null, member }); } for (const [local, member] of direct) members.set(local, { declarator: null, member }); - const brandDirect = new Set( - [...direct].filter(([, member]) => BRAND_MEMBERS.has(member)).map(([local]) => local), - ); + const brandDirect = new Set([...direct].filter(([, member]) => BRAND_MEMBERS.has(member)).map(([local]) => local)); return { barrel, brandDirect, members, pipe, schema }; } @@ -320,12 +310,12 @@ export const rule = defineRule({ if (!isSchemaRuleInScope(context.filename, options)) return {}; const keyPattern = safeRegExp(options.identifierKeyPattern, DEFAULT_KEY_PATTERN); - const schemaNamePattern = safeRegExp( - options.identifierSchemaNamePattern, - DEFAULT_SCHEMA_NAME_PATTERN, - ); + const schemaNamePattern = safeRegExp(options.identifierSchemaNamePattern, DEFAULT_SCHEMA_NAME_PATTERN); - let bindings: EffectBindings = { importsEffect: false, namespaces: new Map() }; + let bindings: EffectBindings = { + importsEffect: false, + namespaces: new Map(), + }; let locals: SchemaLocals = { barrel: new Set(), brandDirect: new Set(), @@ -366,8 +356,7 @@ export const rule = defineRule({ const destructuredMembers = new Map>(); const memberOfIdentifier = (node: ESTree.Node, name: string): string | null => { const local = localDeclarator(node, name); - const destructured = - local === null ? undefined : destructuredMembers.get(local.start)?.get(name); + const destructured = local === null ? undefined : destructuredMembers.get(local.start)?.get(name); if (destructured !== undefined) return destructured; const binding = locals.members.get(name); if (binding === undefined) return null; @@ -382,8 +371,7 @@ export const rule = defineRule({ const isSchemaSource = (node: ESTree.Node): boolean => { const source = unwrap(node); if (source.type === 'Identifier') return isImportedLocal(source, locals.schema); - if (source.type !== 'MemberExpression' || memberName(source) !== SCHEMA_NAMESPACE) - return false; + if (source.type !== 'MemberExpression' || memberName(source) !== SCHEMA_NAMESPACE) return false; return isImportedLocal(unwrap(source.object), locals.barrel); }; @@ -407,18 +395,14 @@ export const rule = defineRule({ if (expression.type !== 'MemberExpression') return false; const member = memberName(expression); if (member === null) return false; - return ( - (BRAND_MEMBERS.has(member) && schemaMember(expression) !== null) || - options.brandHelpers.includes(member) - ); + return (BRAND_MEMBERS.has(member) && schemaMember(expression) !== null) || options.brandHelpers.includes(member); }; const recordDestructuredProperty = ( declarator: ESTree.VariableDeclarator, property: ESTree.ObjectPattern['properties'][number], ): void => { - if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') - return; + if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') return; const member = keyName(property.key); if (member === null) return; const members = destructuredMembers.get(declarator.start) ?? new Map(); @@ -430,8 +414,7 @@ export const rule = defineRule({ for (const declarator of declarators) { if (declarator.id.type !== 'ObjectPattern' || declarator.init == null) continue; if (!isSchemaSource(declarator.init)) continue; - for (const property of declarator.id.properties) - recordDestructuredProperty(declarator, property); + for (const property of declarator.id.properties) recordDestructuredProperty(declarator, property); } }; @@ -442,10 +425,8 @@ export const rule = defineRule({ }; const isIdentityFunction = (step: ESTree.Node): boolean => { - if (step.type !== 'ArrowFunctionExpression' && step.type !== 'FunctionExpression') - return false; - if (step.body === null || step.params.length !== 1 || step.params[0]?.type !== 'Identifier') - return false; + if (step.type !== 'ArrowFunctionExpression' && step.type !== 'FunctionExpression') return false; + if (step.body === null || step.params.length !== 1 || step.params[0]?.type !== 'Identifier') return false; const returned = identityResult(step.body); return returned?.type === 'Identifier' && returned.name === step.params[0].name; }; @@ -464,11 +445,7 @@ export const rule = defineRule({ } if (step.type === 'Identifier') { const declaration = localDeclarator(step, step.name); - if ( - declaration?.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return false; + if (declaration?.parent?.type !== 'VariableDeclaration' || declaration.parent.kind !== 'const') return false; return declaration.init !== null && isTransparentStep(declaration.init, depth + 1); } return isIdentityFunction(step); @@ -478,11 +455,7 @@ export const rule = defineRule({ * A schema expression whose runtime identity is still "any string": no brand anywhere in the * chain. `seen` guards mutually-referential `const`s. */ - const isStringRooted = ( - node: ESTree.Node | null, - seen: Set, - depth: number, - ): boolean => { + const isStringRooted = (node: ESTree.Node | null, seen: Set, depth: number): boolean => { if (node === null || depth > 24) return false; const expression = unwrap(node); @@ -505,17 +478,9 @@ export const rule = defineRule({ return isStringRootedCall(expression, seen, depth); }; - const firstArgumentRooted = ( - expression: ESTree.CallExpression, - seen: Set, - depth: number, - ): boolean => { + const firstArgumentRooted = (expression: ESTree.CallExpression, seen: Set, depth: number): boolean => { const first = expression.arguments[0]; - return ( - first !== undefined && - first.type !== 'SpreadElement' && - isStringRooted(first, seen, depth + 1) - ); + return first !== undefined && first.type !== 'SpreadElement' && isStringRooted(first, seen, depth + 1); }; const isStringRootedMethod = ( @@ -525,27 +490,17 @@ export const rule = defineRule({ depth: number, ): boolean => { const method = memberName(callee); - if (method === null || BRAND_MEMBERS.has(method) || !TRANSPARENT_METHODS.has(method)) - return false; + if (method === null || BRAND_MEMBERS.has(method) || !TRANSPARENT_METHODS.has(method)) return false; if (expression.arguments.some((argument) => isBrandExpression(argument))) return false; - if ( - method === 'pipe' && - !expression.arguments.every((argument) => isTransparentStep(argument)) - ) - return false; + if (method === 'pipe' && !expression.arguments.every((argument) => isTransparentStep(argument))) return false; return isStringRooted(callee.object, seen, depth + 1); }; - const isStringRootedCall = ( - expression: ESTree.CallExpression, - seen: Set, - depth: number, - ): boolean => { + const isStringRootedCall = (expression: ESTree.CallExpression, seen: Set, depth: number): boolean => { const callee = unwrap(expression.callee); if (isImportedLocal(callee, locals.pipe)) { if (expression.arguments.some((argument) => isBrandExpression(argument))) return false; - if (!expression.arguments.slice(1).every((argument) => isTransparentStep(argument))) - return false; + if (!expression.arguments.slice(1).every((argument) => isTransparentStep(argument))) return false; return firstArgumentRooted(expression, seen, depth); } const wrapper = schemaMember(callee); @@ -556,9 +511,7 @@ export const rule = defineRule({ firstArgumentRooted(expression, seen, depth) ); } - return ( - callee.type === 'MemberExpression' && isStringRootedMethod(expression, callee, seen, depth) - ); + return callee.type === 'MemberExpression' && isStringRootedMethod(expression, callee, seen, depth); }; /** Is `node` an argument of a `Schema.Struct` / `Schema.TaggedError()('T', ...)` style call? */ @@ -649,8 +602,7 @@ export const rule = defineRule({ for (const call of calls) { for (const argument of call.arguments) { const value = unwrap(argument); - if (value.type === 'Identifier' && isSchemaConstructorArgument(argument)) - bagIdentifierNames.add(value.name); + if (value.type === 'Identifier' && isSchemaConstructorArgument(argument)) bagIdentifierNames.add(value.name); } } }; @@ -674,8 +626,7 @@ export const rule = defineRule({ declarators.push(node); }, 'Program:exit'() { - if (locals.schema.size === 0 && locals.barrel.size === 0 && locals.members.size === 0) - return; + if (locals.schema.size === 0 && locals.barrel.size === 0 && locals.members.size === 0) return; collectDestructuredMembers(); collectBagIdentifiers(); for (const declarator of declarators) reportDeclarator(declarator); @@ -686,7 +637,11 @@ export const rule = defineRule({ reports.sort((left, right) => left.start - right.start); for (const report of reports) { - context.report({ data: report.data, messageId: report.messageId, node: report.node }); + context.report({ + data: report.data, + messageId: report.messageId, + node: report.node, + }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts b/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts index da230f84f..e8cb0b32a 100644 --- a/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts +++ b/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A8** — "Fix the generators before generating more code" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A8's Effect v4 target ends with an explicit @@ -69,20 +68,14 @@ import { optionRecord } from '../shared/options.ts'; * `tools/` is edited to satisfy this rule, and no disable comment is added to silence it. */ import { defineRule } from '@oxlint/plugins'; - import type { Comment, Context } from '@oxlint/plugins'; -import { scopePath, matchesGlobs } from '../shared/paths.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray, booleanOption as boolean } from '../shared/options.ts'; +import { scopePath, matchesGlobs } from '../shared/paths.ts'; /** A8 names `scripts/` and `tools/oxlint` explicitly; the seam suppressions live across all roots. */ -const DEFAULT_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', - 'tools/**', -]; +const DEFAULT_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**', 'tools/**']; /** This rule's own fixtures deliberately contain ungoverned suppressions. */ const DEFAULT_IGNORE_PATHS: readonly string[] = []; // Production config excludes fixtures; synthetic paths stay testable. @@ -175,9 +168,7 @@ function compileExpiry(pattern: string): RegExp | null { function normaliseRuleName(name: string): string { const lower = name.trim().toLowerCase().replace(/^@/u, ''); if (lower.startsWith('eslint/')) return lower.slice('eslint/'.length); - return lower.startsWith('typescript-eslint/') - ? `typescript/${lower.slice('typescript-eslint/'.length)}` - : lower; + return lower.startsWith('typescript-eslint/') ? `typescript/${lower.slice('typescript-eslint/'.length)}` : lower; } /** Rule names in a directive are comma- and/or whitespace-separated. */ @@ -219,7 +210,10 @@ function parseDirective(value: string): Directive | null { // `-disable-next-line`, `-disable-line` and any other `-disable-…` suffix are not file-wide. if (rest.startsWith('-')) return null; const parsed = splitDescription(rest); - return { justification: parsed.description, rules: parseRuleList(parsed.head) }; + return { + justification: parsed.description, + rules: parseRuleList(parsed.head), + }; } /** The justification and expiry criteria, shared by every kind of file-wide waiver. */ @@ -241,10 +235,7 @@ function justificationReasons( } /** Returns null when a later enable fully bounds this disable region. */ -function unboundedRules( - directive: Directive, - laterComments: readonly Comment[], -): readonly string[] | null { +function unboundedRules(directive: Directive, laterComments: readonly Comment[]): readonly string[] | null { const remaining = new Set(directive.rules.map(normaliseRuleName)); for (const later of laterComments) { const enable = ENABLE.exec(later.value.trim()); @@ -332,10 +323,7 @@ export const rule = defineRule({ const seamRules = new Set(options.effectSeamRules.map(normaliseRuleName)); const expiry = compileExpiry(options.expiryPattern); - const inspectEffectDiagnostics = ( - comment: Comment, - effectDiagnostics: RegExpExecArray, - ): void => { + const inspectEffectDiagnostics = (comment: Comment, effectDiagnostics: RegExpExecArray): void => { if (!options.includeEffectDiagnosticsDirectives) return; const body = effectDiagnostics.groups?.rest ?? ''; const parsed = splitDescription(body); @@ -406,10 +394,7 @@ export const rule = defineRule({ const parsedDirective = parseDirective(comment.value); if (parsedDirective === null) return; - const directive = - unboundedRules === undefined - ? parsedDirective - : { ...parsedDirective, rules: unboundedRules }; + const directive = unboundedRules === undefined ? parsedDirective : { ...parsedDirective, rules: unboundedRules }; inspectDirective(comment, directive); }; diff --git a/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts b/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts index 366af05d3..c9d9af74d 100644 --- a/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts +++ b/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; + /** * Audit A4/C3/B4 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) targets WeakMap * defect-cause storage, non-reactive Contacts UI side channels and module memoization whose lifecycle @@ -23,23 +25,19 @@ * Report-only, with no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { fileURLToPath } from 'node:url'; - import type { Context, ESTree, Reference, Variable } from '@oxlint/plugins'; -import { matchesGlobs as matchesAny, isTestFile, normalisePath } from '../shared/paths.ts'; - import { staticString, skipWrappers, unwrapNode } from '../shared/ast.ts'; import { isUnshadowedGlobal, resolveVariable } from '../shared/bindings.ts'; import { booleanOption as boolean, stringList } from '../shared/options.ts'; +import { matchesGlobs as matchesAny, isTestFile, normalisePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; /** Exact app-root/fixture-prefix normalization; nested workspace markers never change scope. */ function workspacePath(filename: string): string { const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); + const fixture = /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); if (fixture?.[1]) return fixture[1]; const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); return unified.startsWith(root) ? unified.slice(root.length) : normalisePath(unified); @@ -151,9 +149,7 @@ function constructorAlias(variable: Variable): AnyNode | null { const definition = variable.defs[0]; if (definition?.type !== 'ClassName') return null; const node = definition.node; - return node.type === 'ClassDeclaration' || node.type === 'ClassExpression' - ? node.superClass - : null; + return node.type === 'ClassDeclaration' || node.type === 'ClassExpression' ? node.superClass : null; } /** @@ -162,11 +158,7 @@ function constructorAlias(variable: Variable): AnyNode | null { * Accepts the bare global (`new WeakMap()`) and the container-global forms * (`new globalThis.WeakMap()`, `new window["WeakSet"]()`, `new globalThis?.WeakMap()`). */ -function globalConstructorName( - context: Context, - callee: AnyNode, - seen = new Set(), -): string | null { +function globalConstructorName(context: Context, callee: AnyNode, seen = new Set()): string | null { const node = unwrap(callee); if (node.type === 'Identifier') { const name = (node as ESTree.IdentifierReference).name; @@ -209,12 +201,7 @@ function moduleDeclarations(program: ESTree.Program): readonly ESTree.VariableDe function inScope(path: string, options: RuleOptions): boolean { const include = options.includeScripts ? [...options.include, 'scripts/**'] : options.include; if (!matchesAny(path, include)) return false; - if ( - [ALWAYS_IGNORED, options.ignore, options.allowPaths].some((patterns) => - matchesAny(path, patterns), - ) - ) - return false; + if ([ALWAYS_IGNORED, options.ignore, options.allowPaths].some((patterns) => matchesAny(path, patterns))) return false; if (!options.includeTests && isTestFile(path)) return false; return options.includeScripts || !/(?:^|\/)scripts\//u.test(path); } @@ -223,8 +210,7 @@ function moduleClassOwner(node: ESTree.PropertyDefinition) { const body = node.parent; const owner = body?.type === 'ClassBody' ? body.parent : null; if (owner?.type !== 'ClassDeclaration' || !owner.id) return null; - const ancestor = - owner.parent?.type === 'ExportNamedDeclaration' ? owner.parent.parent : owner.parent; + const ancestor = owner.parent?.type === 'ExportNamedDeclaration' ? owner.parent.parent : owner.parent; return ancestor?.type === 'Program' ? owner : null; } @@ -340,10 +326,7 @@ export const rule = defineRule({ } return value.type === 'CallExpression' ? factoryContainerKind(value, depth) : null; }; - const factoryContainerKind = ( - value: ESTree.CallExpression, - depth: number, - ): 'object' | 'collection' | null => { + const factoryContainerKind = (value: ESTree.CallExpression, depth: number): 'object' | 'collection' | null => { const callee = unwrap(value.callee); if (callee.type !== 'MemberExpression') return null; const method = staticPropertyName(callee) ?? ''; @@ -352,8 +335,7 @@ export const rule = defineRule({ if (['create', 'fromEntries'].includes(method)) return 'object'; if (['entries', 'keys', 'values'].includes(method)) return 'collection'; } - if (isUnshadowedGlobal(context, object, 'Array') && ['from', 'of'].includes(method)) - return 'collection'; + if (isUnshadowedGlobal(context, object, 'Array') && ['from', 'of'].includes(method)) return 'collection'; if (!['slice', 'concat', 'map', 'filter', 'flat', 'flatMap'].includes(method)) return null; return containerKind(object, depth + 1) === 'collection' ? 'collection' : null; }; @@ -380,11 +362,7 @@ export const rule = defineRule({ const method = staticPropertyName(member); const outer = skipWrappers(member); if (outer.parent?.type === 'CallExpression' && outer.parent.callee === outer.node) { - return ( - method !== null && - mutatingMembers.has(method) && - containerKind(receiver) === 'collection' - ); + return method !== null && mutatingMembers.has(method) && containerKind(receiver) === 'collection'; } receiver = propertyValue(receiver, method); current = outer.node; @@ -415,10 +393,7 @@ export const rule = defineRule({ const globalContainer = (input: AnyNode, seen = new Set()): boolean => { const node = unwrap(input); if (node.type !== 'Identifier') return false; - if ( - ['globalThis', 'global', 'self'].includes(node.name) && - isUnshadowedGlobal(context, node, node.name) - ) + if (['globalThis', 'global', 'self'].includes(node.name) && isUnshadowedGlobal(context, node, node.name)) return true; const initial = variableInitializer(immutableVariable(context, node, seen)); return initial !== null && globalContainer(initial, seen); @@ -430,14 +405,19 @@ export const rule = defineRule({ context.report({ node: declarator.id as unknown as AnyNode, messageId: 'moduleMutable', - data: { name: context.sourceCode.getText(declarator.id as unknown as AnyNode) }, + data: { + name: context.sourceCode.getText(declarator.id as unknown as AnyNode), + }, }); return; } for (const variable of variables) { - const anchor = - (variable.identifiers[0] as AnyNode | undefined) ?? (declarator.id as unknown as AnyNode); - context.report({ node: anchor, messageId: 'moduleMutable', data: { name: variable.name } }); + const anchor = (variable.identifiers[0] as AnyNode | undefined) ?? (declarator.id as unknown as AnyNode); + context.report({ + node: anchor, + messageId: 'moduleMutable', + data: { name: variable.name }, + }); } }; @@ -453,9 +433,7 @@ export const rule = defineRule({ if (!containerKind(declarator.init ?? null)) return; const variables = context.sourceCode.getDeclaredVariables(declarator); const mutated = variables.some((variable) => - variable.references.some((reference) => - isMutatingReference(reference, declarator.init ?? null), - ), + variable.references.some((reference) => isMutatingReference(reference, declarator.init ?? null)), ); if (mutated) reportDeclarator(declarator); }; @@ -481,9 +459,7 @@ export const rule = defineRule({ variable.references.some((reference) => { const access = skipWrappers(reference.identifier).parent; return ( - access?.type === 'MemberExpression' && - staticPropertyName(access) === key && - mutatedAt(access, node.value) + access?.type === 'MemberExpression' && staticPropertyName(access) === key && mutatedAt(access, node.value) ); }), ); @@ -501,11 +477,7 @@ export const rule = defineRule({ const outer = skipWrappers(node); // A4 targets cause/provenance/UI storage, not ephemeral recursive cycle guards // (D tier and Existing patterns to preserve). No escape analysis is claimed. - if ( - outer.parent?.type === 'CallExpression' && - outer.parent.arguments.includes(outer.node as never) - ) - return; + if (outer.parent?.type === 'CallExpression' && outer.parent.arguments.includes(outer.node as never)) return; if (outer.parent?.type === 'MemberExpression' && outer.parent.object === outer.node) return; context.report({ node: node as unknown as AnyNode, @@ -517,8 +489,7 @@ export const rule = defineRule({ // (2) module-scope `let`/`var`, and mutated module-scope container `const`s. Program(node) { for (const declaration of moduleDeclarations(node)) { - for (const declarator of declaration.declarations) - inspectDeclarator(declarator, declaration.kind); + for (const declarator of declaration.declarations) inspectDeclarator(declarator, declaration.kind); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts b/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts index 572d7b9d7..d354fa1df 100644 --- a/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts +++ b/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts @@ -86,25 +86,19 @@ * suggests. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { compile, stringList } from '../shared/options.ts'; import { parentOf, unwrapNode } from '../shared/ast.ts'; import { resolveVariable, resolvesToImport as importedReference } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; import { collectRootNamespaces, collectNamedImports, importedName } from '../shared/imports.ts'; +import { compile, stringList } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; type AnyNode = ESTree.Node; type IdentifierNode = Extract; -const DEFAULT_INCLUDE_PATHS: readonly string[] = [ - 'apps/**', - 'verticals/**', - 'packages/**', - 'scripts/**', -]; +const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_ALLOW_PATHS: readonly string[] = []; /** @@ -236,8 +230,7 @@ function unwrap(node: AnyNode): AnyNode { function staticString(node: AnyNode): string | null { const value = unwrap(node); if (value.type === 'Literal' && typeof value.value === 'string') return value.value; - if (value.type === 'TemplateLiteral' && value.expressions.length === 0) - return value.quasis[0]?.value.cooked ?? null; + if (value.type === 'TemplateLiteral' && value.expressions.length === 0) return value.quasis[0]?.value.cooked ?? null; return null; } function memberName(node: ESTree.MemberExpression): string | null { @@ -254,9 +247,7 @@ function typeNameOf(node: AnyNode): string | null { if (node.type === 'Identifier') return (node as { name: string }).name; if (node.type === 'TSQualifiedName') { const right = (node as { right?: AnyNode }).right; - return right !== undefined && right.type === 'Identifier' - ? (right as { name: string }).name - : null; + return right !== undefined && right.type === 'Identifier' ? (right as { name: string }).name : null; } return null; } @@ -268,10 +259,7 @@ function unwrapType(node: AnyNode): AnyNode { current = (current as { typeAnnotation: AnyNode }).typeAnnotation; continue; } - if ( - current.type === 'TSTypeOperator' && - (current as { operator?: string }).operator === 'readonly' - ) { + if (current.type === 'TSTypeOperator' && (current as { operator?: string }).operator === 'readonly') { current = (current as { typeAnnotation: AnyNode }).typeAnnotation; continue; } @@ -283,11 +271,7 @@ function unwrapType(node: AnyNode): AnyNode { /** Nullable and literal alternatives do not themselves establish a raw string payload. */ function isNeutralStringAlternative(type: AnyNode): boolean { if (type.type === 'TSNullKeyword' || type.type === 'TSUndefinedKeyword') return true; - return ( - type.type === 'TSLiteralType' && - type.literal.type === 'Literal' && - typeof type.literal.value === 'string' - ); + return type.type === 'TSLiteralType' && type.literal.type === 'Literal' && typeof type.literal.value === 'string'; } function isStringUnion(types: readonly AnyNode[], depth: number): boolean { @@ -368,14 +352,12 @@ export const rule = defineRule({ includePaths: { type: 'array', items: { type: 'string' }, - description: - 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', + description: 'Globs the rule applies to (default: apps/**, verticals/**, packages/**, scripts/**).', }, reexportModules: { type: 'array', items: { type: 'string' }, - description: - 'Modules that re-export Effect namespaces verbatim (Modern.js BFF barrels).', + description: 'Modules that re-export Effect namespaces verbatim (Modern.js BFF barrels).', }, secretConfigKeys: { type: 'string', @@ -383,8 +365,7 @@ export const rule = defineRule({ }, secretNames: { type: 'string', - description: - 'Regex (case-insensitive) matched against field/parameter/Schema-field names.', + description: 'Regex (case-insensitive) matched against field/parameter/Schema-field names.', }, }, }, @@ -414,8 +395,7 @@ export const rule = defineRule({ const isSecretName = (name: string): boolean => options.secretNames.test(name); - const lookupVariable = (identifier: AnyNode, name: string) => - resolveVariable(context, name, identifier); + const lookupVariable = (identifier: AnyNode, name: string) => resolveVariable(context, name, identifier); const resolvesToImport = (node: AnyNode): boolean => importedReference(context, node); @@ -448,10 +428,7 @@ export const rule = defineRule({ return { specifier, declaration }; }; - const directMember = ( - node: IdentifierNode, - depth: number, - ): { namespace: string; member: string } | null => { + const directMember = (node: IdentifierNode, depth: number): { namespace: string; member: string } | null => { const initializer = constantInitializer(node); if (initializer) return resolveMember(initializer, depth + 1); const binding = valueImport(node); @@ -469,21 +446,12 @@ export const rule = defineRule({ const barrelMember = (object: ESTree.MemberExpression, member: string) => { const namespace = memberName(object); const root = unwrap(object.object as AnyNode); - if ( - !namespace || - root.type !== 'Identifier' || - !barrels.has(root.name) || - !resolvesToImport(root) - ) - return null; + if (!namespace || root.type !== 'Identifier' || !barrels.has(root.name) || !resolvesToImport(root)) return null; return { namespace, member }; }; /** Exact imports and bounded const aliases, without broadening namespace alias support. */ - const resolveMember = ( - input: AnyNode, - depth = 0, - ): { namespace: string; member: string } | null => { + const resolveMember = (input: AnyNode, depth = 0): { namespace: string; member: string } | null => { if (depth > 12) return null; const node = unwrap(input); if (node.type === 'Identifier') return directMember(node, depth); @@ -506,13 +474,8 @@ export const rule = defineRule({ }; const isRedaction = (node: AnyNode): boolean => { const expression = unwrap(node); - const member = resolveMember( - expression.type === 'CallExpression' ? (expression.callee as AnyNode) : expression, - ); - return ( - member?.namespace === 'Schema' && - (member.member === 'Redacted' || member.member === 'RedactedFromSelf') - ); + const member = resolveMember(expression.type === 'CallExpression' ? (expression.callee as AnyNode) : expression); + return member?.namespace === 'Schema' && (member.member === 'Redacted' || member.member === 'RedactedFromSelf'); }; const isPipeIdentifier = (node: AnyNode): boolean => { @@ -523,18 +486,12 @@ export const rule = defineRule({ const isSchemaMember = (node: AnyNode, members: ReadonlySet): boolean => { const resolved = resolveMember(node); - return ( - resolved !== null && resolved.namespace === SCHEMA_NAMESPACE && members.has(resolved.member) - ); + return resolved !== null && resolved.namespace === SCHEMA_NAMESPACE && members.has(resolved.member); }; const stringSchemaArgument = (call: ESTree.CallExpression, depth: number): boolean => { const argument = call.arguments[0]; - return ( - argument !== undefined && - argument.type !== 'SpreadElement' && - isStringSchema(argument, depth + 1) - ); + return argument !== undefined && argument.type !== 'SpreadElement' && isStringSchema(argument, depth + 1); }; const isStringSchemaChain = ( @@ -544,8 +501,7 @@ export const rule = defineRule({ ): boolean | null => { const method = memberName(callee); if (method === null || !SCHEMA_CHAIN_METHODS.has(method)) return null; - if (method === 'pipe' && call.arguments.some((argument) => isRedaction(argument as AnyNode))) - return false; + if (method === 'pipe' && call.arguments.some((argument) => isRedaction(argument as AnyNode))) return false; return isStringSchema(callee.object as AnyNode, depth + 1); }; @@ -620,11 +576,9 @@ export const rule = defineRule({ Program(node) { namespaces = new Map(collectEffectBindings(node).namespaces); const isReexport = (source: string) => matchesGlobs(source, options.reexportModules); - barrels = collectRootNamespaces( - node, - (source) => source === EFFECT_ROOT_MODULE || isReexport(source), - { valueOnly: true }, - ); + barrels = collectRootNamespaces(node, (source) => source === EFFECT_ROOT_MODULE || isReexport(source), { + valueOnly: true, + }); for (const [local, imported] of collectNamedImports(node, isReexport, undefined, { valueOnly: true, })) { @@ -680,7 +634,10 @@ export const rule = defineRule({ if (argument === undefined) return; const key = configKey(argument); if (typeof key !== 'string' || !options.secretConfigKeys.test(key)) return; - report(node as unknown as AnyNode, 'secretConfigKey', { member: called.member, name: key }); + report(node as unknown as AnyNode, 'secretConfigKey', { + member: called.member, + name: key, + }); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts b/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts index c1d4cdb58..9c38b8943 100644 --- a/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts +++ b/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts @@ -11,7 +11,6 @@ * Configurable naming and width controls intentionally cover only part of B4. Report only. */ import { defineRule } from '@oxlint/plugins'; - import type { ESTree } from '@oxlint/plugins'; import { keyName as staticKeyName, parentOf, unwrapBinding } from '../shared/ast.ts'; @@ -47,13 +46,7 @@ const NAMED_MEMBERS = new Set([ ]); /** Type wrappers that never change which named type a parameter is annotated with. */ -const TYPE_WRAPPERS = new Set([ - 'TSParenthesizedType', - 'TSTypeOperator', - 'TSArrayType', - 'TSOptionalType', - 'TSRestType', -]); +const TYPE_WRAPPERS = new Set(['TSParenthesizedType', 'TSTypeOperator', 'TSArrayType', 'TSOptionalType', 'TSRestType']); interface RuleOptions { readonly factoryNamePattern: RegExp; @@ -80,17 +73,17 @@ function readOptions(raw: unknown): RuleOptions { includeScripts: booleanOption(given.includeScripts, false), includeTests: booleanOption(given.includeTests, false), includeTypeSignatures: booleanOption(given.includeTypeSignatures, true), - maxPositionalParams: positiveInteger( - given.maxPositionalParams, - DEFAULT_MAX_POSITIONAL_PARAMS, - 0, - ), + maxPositionalParams: positiveInteger(given.maxPositionalParams, DEFAULT_MAX_POSITIONAL_PARAMS, 0), optionBagTypePattern: compile(given.optionBagTypePattern, DEFAULT_OPTION_BAG_TYPE_PATTERN), }; } function keyName(key: AnyNode, computed: boolean): string | null { - return staticKeyName(key, computed, { templates: true, rawTemplates: false, singleQuasi: false }); + return staticKeyName(key, computed, { + templates: true, + rawTemplates: false, + singleQuasi: false, + }); } /** @@ -112,17 +105,14 @@ function countPositionalParameters(params: readonly AnyNode[]): number { function typeReferenceNames(annotation: AnyNode | null | undefined, depth = 0): readonly string[] { if (annotation === null || annotation === undefined || depth > 5) return []; const node = - annotation.type === 'TSTypeAnnotation' - ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation - : annotation; + annotation.type === 'TSTypeAnnotation' ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation : annotation; return namesInType(node, depth); } function namesInType(node: AnyNode, depth: number): readonly string[] { if (TYPE_WRAPPERS.has(node.type)) { const inner = - (node as { typeAnnotation?: AnyNode }).typeAnnotation ?? - (node as { elementType?: AnyNode }).elementType; + (node as { typeAnnotation?: AnyNode }).typeAnnotation ?? (node as { elementType?: AnyNode }).elementType; return inner === undefined ? [] : typeReferenceNames(inner, depth + 1); } if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { @@ -138,8 +128,7 @@ function namesInType(node: AnyNode, depth: number): readonly string[] { function referenceLeafNames(typeName: AnyNode): readonly string[] { if (typeName.type === 'Identifier') return [typeName.name]; - if (typeName.type === 'TSQualifiedName' && typeName.right.type === 'Identifier') - return [typeName.right.name]; + if (typeName.type === 'TSQualifiedName' && typeName.right.type === 'Identifier') return [typeName.right.name]; return []; } @@ -184,7 +173,11 @@ function holderName(parent: AnyNode, child: AnyNode): FactoryName | null { return parent.right === child ? assignmentName(parent.left) : null; } if (!NAMED_MEMBERS.has(parent.type)) return null; - const holder = parent as unknown as { key: AnyNode; computed: boolean; value: AnyNode | null }; + const holder = parent as unknown as { + key: AnyNode; + computed: boolean; + value: AnyNode | null; + }; if (holder.value !== child) return null; const name = keyName(holder.key, holder.computed); return name === null ? null : { name, node: holder.key }; @@ -192,14 +185,17 @@ function holderName(parent: AnyNode, child: AnyNode): FactoryName | null { /** The type-level declaration that owns a signature node (`TSMethodSignature` / `TSPropertySignature`). */ function signatureName(node: AnyNode): FactoryName | null { - const holder = node as unknown as { key?: AnyNode; computed?: boolean; id?: AnyNode | null }; + const holder = node as unknown as { + key?: AnyNode; + computed?: boolean; + id?: AnyNode | null; + }; if (holder.key !== undefined) { const name = keyName(holder.key, holder.computed === true); return name === null ? null : { name, node: holder.key }; } const id = holder.id ?? null; - if (id !== null && id.type === 'Identifier') - return { name: (id as { name: string }).name, node: id }; + if (id !== null && id.type === 'Identifier') return { name: (id as { name: string }).name, node: id }; return null; } @@ -250,8 +246,7 @@ export const rule = defineRule({ }, includeScripts: { type: 'boolean', - description: - 'Also report inside scripts/** (default: false — one-shot programs, no Layer graph).', + description: 'Also report inside scripts/** (default: false — one-shot programs, no Layer graph).', }, includeTests: { type: 'boolean', @@ -303,8 +298,7 @@ export const rule = defineRule({ const resolve = (node: any, seen = new Set()): string | null => { if (!node || seen.has(node)) return null; seen.add(node); - if (VALUE_WRAPPERS.has(node.type) || node.type === 'ChainExpression') - return resolve(node.expression, seen); + if (VALUE_WRAPPERS.has(node.type) || node.type === 'ChainExpression') return resolve(node.expression, seen); if (node.type === 'MemberExpression') { const object = resolve(node.object, seen); const key = keyName(node.property, node.computed); @@ -322,15 +316,11 @@ export const rule = defineRule({ if (source === 'effect/Effect') return imported ? `Effect.${imported}` : 'Effect'; return imported ?? 'root'; }; - const resolveDefinitions = ( - variable: import('@oxlint/plugins').Variable, - seen: Set, - ): string | null => { + const resolveDefinitions = (variable: import('@oxlint/plugins').Variable, seen: Set): string | null => { for (const def of variable.defs as any[]) { if (def.type === 'ImportBinding') return importIdentity(def); if (def.type !== 'Variable' || def.parent?.kind !== 'const') continue; - if (!variable.references.some((r) => r.isWrite() && !r.init)) - return resolve(def.node.init, seen); + if (!variable.references.some((r) => r.isWrite() && !r.init)) return resolve(def.node.init, seen); } return null; }; @@ -339,16 +329,12 @@ export const rule = defineRule({ if (Array.isArray(node)) return node.some((child) => someNode(child, predicate)); if (typeof node.type !== 'string') return false; if (predicate(node)) return true; - return Object.entries(node).some( - ([key, value]) => key !== 'parent' && someNode(value, predicate), - ); + return Object.entries(node).some(([key, value]) => key !== 'parent' && someNode(value, predicate)); }; const bodyIsEffectProgram = (fn: AnyNode): boolean => someNode( (fn as any).body, - (node) => - node.type === 'CallExpression' && - /^(?:root\.)?Effect\./u.test(resolve(node.callee) ?? ''), + (node) => node.type === 'CallExpression' && /^(?:root\.)?Effect\./u.test(resolve(node.callee) ?? ''), ); // Only proven local scalar/data shapes are exempt. Unknown/imported annotations stay @@ -359,8 +345,7 @@ export const rule = defineRule({ return classifyDataType(node, seen); }; const classifyDataType = (node: any, seen: Set): boolean => { - if (['TSTypeAnnotation', 'TSTypeOperator'].includes(node.type)) - return dataType(node.typeAnnotation, seen); + if (['TSTypeAnnotation', 'TSTypeOperator'].includes(node.type)) return dataType(node.typeAnnotation, seen); if ( [ 'TSStringKeyword', @@ -384,9 +369,7 @@ export const rule = defineRule({ const dataMembers = (node: any, seen: Set): boolean => { const members = node.members ?? node.body.body; return ( - members.every( - (m: any) => m.type === 'TSPropertySignature' && dataType(m.typeAnnotation, new Set(seen)), - ) && + members.every((m: any) => m.type === 'TSPropertySignature' && dataType(m.typeAnnotation, new Set(seen))) && (node.extends ?? []).every((e: any) => dataType({ type: 'TSTypeReference', typeName: e.expression }, new Set(seen)), ) diff --git a/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts b/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts index d7a948056..a7ca4e324 100644 --- a/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts +++ b/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts @@ -1,4 +1,5 @@ -import { optionRecord } from '../shared/options.ts'; +import { fileURLToPath } from 'node:url'; + /** * Audit A6/B4 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) asks for Effect.fn * on service operations and handlers. Named Effect.fn standardizes spans and definition/call-site @@ -19,14 +20,8 @@ import { optionRecord } from '../shared/options.ts'; * Report-only, with no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { fileURLToPath } from 'node:url'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { matchesGlobs, isTestFile, normalisePath, rootedScopePath } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { parentOf, unwrapNode as unwrap, @@ -34,11 +29,16 @@ import { keyName as sharedKeyName, } from '../shared/ast.ts'; import { resolvesToImport as sharedResolvesToImport } from '../shared/bindings.ts'; +import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; +import type { EffectBindings } from '../shared/effect-imports.ts'; import { collectRootNamespaces as sharedRootNamespaces, collectNamedImports, importDeclarations, } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray } from '../shared/options.ts'; +import { matchesGlobs, isTestFile, normalisePath, rootedScopePath } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; @@ -76,10 +76,7 @@ interface RuleOptions { function readOptions(context: Context): RuleOptions { const record = optionRecord(context.options?.[0]); - const minParams = - typeof record.minParams === 'number' && Number.isInteger(record.minParams) - ? record.minParams - : 1; + const minParams = typeof record.minParams === 'number' && Number.isInteger(record.minParams) ? record.minParams : 1; return { minParams: minParams < 0 ? 0 : minParams, allowLeadingConstants: record.allowLeadingConstants !== false, @@ -110,14 +107,8 @@ function resolvesToImport(context: Context, identifier: ESTree.Node): boolean { * Locals bound by `import * as E from "effect"` — `E.Effect.gen` must still be caught. Barrels that * re-export the Effect namespaces verbatim (the Modern.js BFF edge barrel) behave identically. */ -function collectRootNamespaces( - program: ESTree.Program, - reexportModules: readonly string[], -): ReadonlySet { - return sharedRootNamespaces( - program, - (source) => source === EFFECT_ROOT_MODULE || reexportModules.includes(source), - ); +function collectRootNamespaces(program: ESTree.Program, reexportModules: readonly string[]): ReadonlySet { + return sharedRootNamespaces(program, (source) => source === EFFECT_ROOT_MODULE || reexportModules.includes(source)); } /** @@ -127,7 +118,10 @@ function collectRootNamespaces( function collectReexportBindings( program: ESTree.Program, reexportModules: readonly string[], -): { readonly namespaces: ReadonlyMap; readonly found: boolean } { +): { + readonly namespaces: ReadonlyMap; + readonly found: boolean; +} { const accepts = (source: string): boolean => reexportModules.includes(source); return { namespaces: collectNamedImports(program, accepts), @@ -137,13 +131,7 @@ function collectReexportBindings( /** Locals bound by `import { gen as effectGen } from "effect/Effect"`. */ function collectDirectMemberImports(program: ESTree.Program, member: string): ReadonlySet { - return new Set( - collectNamedImports( - program, - (source) => EFFECT_EFFECT_MODULE.test(source), - new Set([member]), - ).keys(), - ); + return new Set(collectNamedImports(program, (source) => EFFECT_EFFECT_MODULE.test(source), new Set([member])).keys()); } interface Resolver { @@ -161,15 +149,12 @@ function resolveNamespaceMember( ): { namespace: string; member: string } | null { const shared = effectMember(node, resolver.bindings); if (shared !== null) { - return resolvesToImport(context, node.object as Extract) - ? shared - : null; + return resolvesToImport(context, node.object as Extract) ? shared : null; } const member = memberName(node); if (member === null) return null; const object = unwrap(node.object); - if (object.type === 'Identifier') - return resolveIdentifierMember(object, member, context, resolver); + if (object.type === 'Identifier') return resolveIdentifierMember(object, member, context, resolver); // `E.Effect.gen` where `E` is `import * as E from "effect"`. if (object.type !== 'MemberExpression') return null; const namespace = memberName(object); @@ -185,11 +170,7 @@ function resolveIdentifierMember( context: Context, resolver: Resolver, ): { namespace: string; member: string } | null { - if ( - resolver.rootNamespaces.has(object.name) && - member === PIPE_MEMBER && - resolvesToImport(context, object) - ) + if (resolver.rootNamespaces.has(object.name) && member === PIPE_MEMBER && resolvesToImport(context, object)) return { namespace: 'Function', member }; const namespace = resolver.bindings.namespaces.get(object.name); if (namespace === undefined) return null; @@ -205,9 +186,7 @@ function isEffectGenCall(node: ESTree.Node, context: Context, resolver: Resolver } if (callee.type !== 'MemberExpression') return false; const matched = resolveNamespaceMember(callee, context, resolver); - return ( - matched !== null && matched.namespace === EFFECT_NAMESPACE && matched.member === GEN_MEMBER - ); + return matched !== null && matched.namespace === EFFECT_NAMESPACE && matched.member === GEN_MEMBER; } /** The `Effect.gen` callee node, used as the report anchor. */ @@ -254,20 +233,13 @@ const preserving = new Set([ ]); function isDataFirstPipe(callee: ESTree.Node, context: Context, resolver: Resolver): boolean { - if (callee.type === 'Identifier') - return resolver.pipeLocals.has(callee.name) && resolvesToImport(context, callee); + if (callee.type === 'Identifier') return resolver.pipeLocals.has(callee.name) && resolvesToImport(context, callee); if (callee.type !== 'MemberExpression') return false; const matched = resolveNamespaceMember(callee, context, resolver); - return ( - matched !== null && matched.member === PIPE_MEMBER && PIPE_NAMESPACES.has(matched.namespace) - ); + return matched !== null && matched.member === PIPE_MEMBER && PIPE_NAMESPACES.has(matched.namespace); } -function isPreservingOperator( - argument: ESTree.Node, - context: Context, - resolver: Resolver, -): boolean { +function isPreservingOperator(argument: ESTree.Node, context: Context, resolver: Resolver): boolean { let operator = unwrap(argument); if (operator.type === 'CallExpression') operator = unwrap(operator.callee); if (operator.type !== 'MemberExpression') return false; @@ -290,9 +262,7 @@ function peelPipes(expression: ESTree.Node, context: Context, resolver: Resolver // A pipeline can leave Effect (runners, predicates, or arbitrary user functions). Only // peel syntactically known Effect-to-Effect operators, never assume a pipe preserves types. if ( - !current.arguments - .slice(dataFirst ? 1 : 0) - .every((argument) => isPreservingOperator(argument, context, resolver)) + !current.arguments.slice(dataFirst ? 1 : 0).every((argument) => isPreservingOperator(argument, context, resolver)) ) return expression; const next = dataFirst ? current.arguments[0] : (callee as ESTree.MemberExpression).object; @@ -306,15 +276,8 @@ function peelPipes(expression: ESTree.Node, context: Context, resolver: Resolver * The single expression the function evaluates to, or `null` when the body does more than that. * Leading `const`/`let`/`var` declarations are tolerated when `allowLeadingConstants`. */ -function isAllowedLeadingStatement( - statement: ESTree.Node, - allowLeadingConstants: boolean, -): boolean { - if ( - new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSDeclareFunction']).has( - statement.type, - ) - ) +function isAllowedLeadingStatement(statement: ESTree.Node, allowLeadingConstants: boolean): boolean { + if (new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSDeclareFunction']).has(statement.type)) return true; return allowLeadingConstants && statement.type === 'VariableDeclaration'; } @@ -329,11 +292,7 @@ function soleReturnedExpression( const statements = body.body.filter((statement) => statement.type !== 'EmptyStatement'); const last = statements.at(-1); if (last === undefined || last.type !== 'ReturnStatement' || last.argument === null) return null; - if ( - !statements - .slice(0, -1) - .every((statement) => isAllowedLeadingStatement(statement, allowLeadingConstants)) - ) + if (!statements.slice(0, -1).every((statement) => isAllowedLeadingStatement(statement, allowLeadingConstants))) return null; return last.argument; } @@ -362,20 +321,14 @@ function enclosingCallArgument(fn: ESTree.Node): ESTree.CallExpression | null { * (`Effect.fn`, `Effect.fnUntraced`, `Effect.suspend`, `Effect.gen`, or the curried * `Effect.fn('span')(fn)` form) or to a caller-exempted combinator. */ -function isExemptArgument( - fn: ESTree.Node, - context: Context, - resolver: Resolver, - exempt: ReadonlySet, -): boolean { +function isExemptArgument(fn: ESTree.Node, context: Context, resolver: Resolver, exempt: ReadonlySet): boolean { const call = enclosingCallArgument(fn); if (call === null) return false; const callee = unwrap(call.callee); if (callee.type === 'MemberExpression') { const matched = resolveNamespaceMember(callee, context, resolver); if (matched === null) return false; - if (matched.namespace === EFFECT_NAMESPACE && CONSTRUCTOR_MEMBERS.has(matched.member)) - return true; + if (matched.namespace === EFFECT_NAMESPACE && CONSTRUCTOR_MEMBERS.has(matched.member)) return true; return exempt.has(matched.member); } // `Effect.fn('span')(function* () {})` @@ -384,9 +337,7 @@ function isExemptArgument( if (inner.type !== 'MemberExpression') return false; const matched = resolveNamespaceMember(inner, context, resolver); return ( - matched !== null && - matched.namespace === EFFECT_NAMESPACE && - CURRIED_CONSTRUCTOR_MEMBERS.has(matched.member) + matched !== null && matched.namespace === EFFECT_NAMESPACE && CURRIED_CONSTRUCTOR_MEMBERS.has(matched.member) ); } return false; @@ -498,7 +449,10 @@ function describeOperation(fn: ESTree.Node, filename: string): OperationName { const span = `${qualifier}.${operation}`; if (span.length <= MAX_SPAN_NAME) return { name, suggestedSpanName: span }; const fallback = `${fileQualifier(filename)}.${operation}`; - return { name, suggestedSpanName: fallback.length <= MAX_SPAN_NAME ? fallback : operation }; + return { + name, + suggestedSpanName: fallback.length <= MAX_SPAN_NAME ? fallback : operation, + }; } function parameterCount(fn: ESTree.Node): number { @@ -510,10 +464,7 @@ function parameterCount(fn: ESTree.Node): number { function parameterList(fn: ESTree.Node): string { const params = (fn as { params?: readonly ESTree.Node[] }).params ?? []; const names = params.map((param) => { - const target = - param.type === 'TSParameterProperty' - ? (param as { parameter: ESTree.Node }).parameter - : param; + const target = param.type === 'TSParameterProperty' ? (param as { parameter: ESTree.Node }).parameter : param; if (target.type === 'Identifier') return target.name; if (target.type === 'AssignmentPattern') { const left = (target as { left: ESTree.Node }).left; @@ -527,9 +478,7 @@ function parameterList(fn: ESTree.Node): string { if (target.type === 'ObjectPattern') { const keys = (target as { properties: readonly ESTree.Node[] }).properties .map((property) => - property.type === 'Property' - ? keyName(property.key as ESTree.Node, property.computed === true) - : '…', + property.type === 'Property' ? keyName(property.key as ESTree.Node, property.computed === true) : '…', ) .filter((key): key is string => key !== null); return keys.length === 0 ? '{ … }' : `{ ${keys.join(', ')} }`; @@ -541,10 +490,7 @@ function parameterList(fn: ESTree.Node): string { function isIncludedPath(path: string, options: RuleOptions): boolean { if (!matchesGlobs(path, options.include)) return false; - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) + if (/\.d\.[cm]?ts$/u.test(path) || /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path)) return false; if (matchesGlobs(path, options.ignore)) return false; if (!options.includeTests && isTestFile(path)) return false; @@ -620,7 +566,12 @@ export const rule = defineRule({ for (const [local, namespace] of bindings.namespaces) { if (namespace === PIPE_MEMBER) pipeLocals.add(local); } - const resolver: Resolver = { bindings, rootNamespaces, genImports, pipeLocals }; + const resolver: Resolver = { + bindings, + rootNamespaces, + genImports, + pipeLocals, + }; const exempt = new Set(options.exemptCombinators); const check = (fn: ESTree.Node): void => { diff --git a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts index a2a8c8dc8..fe2e1a996 100644 --- a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts +++ b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/prefer-match-over-tag-switch * @@ -76,14 +75,14 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. Existing violations are the intended output. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; +import { unwrapNode } from '../shared/ast.ts'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray, positiveInteger } from '../shared/options.ts'; -import { unwrapNode } from '../shared/ast.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -121,10 +120,7 @@ function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { tagProperties: stringArray(record.tagProperties, DEFAULT_TAG_PROPERTIES), - discriminantProperties: stringArray( - record.discriminantProperties, - DEFAULT_DISCRIMINANT_PROPERTIES, - ), + discriminantProperties: stringArray(record.discriminantProperties, DEFAULT_DISCRIMINANT_PROPERTIES), minLiteralCases: positiveInteger(record.minLiteralCases, DEFAULT_MIN_LITERAL_CASES), allowExhaustive: record.allowExhaustive === true, exhaustiveHelpers: stringArray(record.exhaustiveHelpers, DEFAULT_EXHAUSTIVE_HELPERS), @@ -143,8 +139,7 @@ function unwrapExpression(node: ESTree.Node): ESTree.Node { /** Static string value of a `case` test: `'ready'` and `` `ready` `` both yield `"ready"`. */ function staticStringTest(node: ESTree.Node): string | null { const expression = unwrapExpression(node); - if (expression.type === 'Literal') - return typeof expression.value === 'string' ? expression.value : null; + if (expression.type === 'Literal') return typeof expression.value === 'string' ? expression.value : null; if (expression.type === 'TemplateLiteral') { if (expression.expressions.length > 0 || expression.quasis.length !== 1) return null; return expression.quasis[0]?.value.cooked ?? null; @@ -162,10 +157,7 @@ function isNumericTest(node: ESTree.Node): boolean { if (expression.type === 'Literal') { return typeof expression.value === 'number' || typeof expression.value === 'bigint'; } - if ( - expression.type === 'UnaryExpression' && - (expression.operator === '-' || expression.operator === '+') - ) { + if (expression.type === 'UnaryExpression' && (expression.operator === '-' || expression.operator === '+')) { return isNumericTest(expression.argument); } return false; @@ -217,16 +209,13 @@ function describeDiscriminant(context: Context, node: ESTree.Node): string { } const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); if (text.length === 0) return '…'; - return text.length > MAX_DISCRIMINANT_LENGTH - ? `${text.slice(0, MAX_DISCRIMINANT_LENGTH - 1)}…` - : text; + return text.length > MAX_DISCRIMINANT_LENGTH ? `${text.slice(0, MAX_DISCRIMINANT_LENGTH - 1)}…` : text; } /** Statements of a `default:` branch, unwrapping the common single-block form. */ function defaultStatements(node: ESTree.SwitchCase): readonly ESTree.Node[] { const consequent = node.consequent; - if (consequent.length === 1 && consequent[0]?.type === 'BlockStatement') - return consequent[0].body; + if (consequent.length === 1 && consequent[0]?.type === 'BlockStatement') return consequent[0].body; return consequent; } @@ -236,11 +225,7 @@ function isNeverAnnotation(node: ESTree.Node | null | undefined): boolean { return node.type === 'TSNeverKeyword'; } -function isExhaustiveHelperCall( - node: ESTree.Node, - options: RuleOptions, - bindings: EffectBindings, -): boolean { +function isExhaustiveHelperCall(node: ESTree.Node, options: RuleOptions, bindings: EffectBindings): boolean { if (node.type !== 'CallExpression') return false; const callee = unwrapExpression(node.callee); if (callee.type === 'Identifier') return options.exhaustiveHelpers.includes(callee.name); @@ -252,20 +237,14 @@ function isExhaustiveHelperCall( } function statementExpression(statement: ESTree.Node): ESTree.Node | null | undefined { - if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') - return statement.argument; + if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return statement.argument; return statement.type === 'ExpressionStatement' ? statement.expression : null; } -function statementIsExhaustive( - statement: ESTree.Node, - options: RuleOptions, - bindings: EffectBindings, -): boolean { +function statementIsExhaustive(statement: ESTree.Node, options: RuleOptions, bindings: EffectBindings): boolean { if (statement.type === 'VariableDeclaration') { return statement.declarations.some( - (declarator) => - declarator.id.type === 'Identifier' && isNeverAnnotation(declarator.id.typeAnnotation), + (declarator) => declarator.id.type === 'Identifier' && isNeverAnnotation(declarator.id.typeAnnotation), ); } const expression = statementExpression(statement); @@ -279,14 +258,8 @@ function statementIsExhaustive( } /** `true` when the `default:` branch proves exhaustiveness to the compiler. */ -function hasExhaustiveGuard( - node: ESTree.SwitchCase, - options: RuleOptions, - bindings: EffectBindings, -): boolean { - return defaultStatements(node).some((statement) => - statementIsExhaustive(statement, options, bindings), - ); +function hasExhaustiveGuard(node: ESTree.SwitchCase, options: RuleOptions, bindings: EffectBindings): boolean { + return defaultStatements(node).some((statement) => statementIsExhaustive(statement, options, bindings)); } function summarizeCases(cases: readonly ESTree.SwitchCase[]) { @@ -322,8 +295,7 @@ function reportSwitch( property: string | null, ): void { const adtTag = literals.find((literal) => options.adtTags.includes(literal)); - const messageId = - adtTag !== undefined ? 'adtSwitch' : property !== null ? 'tagSwitch' : 'literalSwitch'; + const messageId = adtTag !== undefined ? 'adtSwitch' : property !== null ? 'tagSwitch' : 'literalSwitch'; context.report({ node: node.discriminant, messageId, @@ -401,7 +373,10 @@ export const rule = defineRule({ if (!matchesGlobs(path, options.include)) return {}; if (options.ignoreTests && isTestFile(path)) return {}; - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; + let bindings: EffectBindings = { + namespaces: new Map(), + importsEffect: false, + }; return { Program(node) { @@ -412,23 +387,14 @@ export const rule = defineRule({ const discriminant = unwrapExpression(node.discriminant); const candidate = discriminantProperty(discriminant, options); - const { literals, everyCaseIsLiteral, defaultCase, allTestsNumeric } = summarizeCases( - node.cases, - ); + const { literals, everyCaseIsLiteral, defaultCase, allTestsNumeric } = summarizeCases(node.cases); // Numeric protocol spaces are allowed; Effect tag properties remain string discriminators. - const property = - candidate !== null && (candidate.kind === 'tag' || !allTestsNumeric) - ? candidate.name - : null; + const property = candidate !== null && (candidate.kind === 'tag' || !allTestsNumeric) ? candidate.name : null; const closedVocabulary = everyCaseIsLiteral && literals.length >= options.minLiteralCases; if (property === null && !closedVocabulary) return; - if ( - options.allowExhaustive && - defaultCase !== null && - hasExhaustiveGuard(defaultCase, options, bindings) - ) { + if (options.allowExhaustive && defaultCase !== null && hasExhaustiveGuard(defaultCase, options, bindings)) { return; } diff --git a/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts b/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts index cd6454873..52ee21427 100644 --- a/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts +++ b/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-concurrency-option * @@ -63,25 +62,20 @@ import { optionRecord } from '../shared/options.ts'; * Report-only: no fixer, no suggestion. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { - skipWrappers, - staticString, - unwrapNode, - memberName as staticMemberName, -} from '../shared/ast.ts'; +import { skipWrappers, staticString, unwrapNode, memberName as staticMemberName } from '../shared/ast.ts'; import { bindingPath } from '../shared/effect-identity.ts'; -import { stringArray, positiveInteger } from '../shared/options.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { collectRootNamespaces, collectDirectMemberImports, collectNamedImports, importDeclarations, } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray, positiveInteger } from '../shared/options.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const EFFECT_ROOT_MODULE = 'effect'; /** `effect/Effect`, `effect/Stream`, and any nested re-export path ending in those names. */ @@ -214,10 +208,7 @@ function memberName(node: ESTree.MemberExpression): string | null { * The shared `effect`/`effect/*` bindings, widened with the named imports of the Effect re-export * barrels. `import { Effect } from "@modern-js/plugin-bff/effect-edge"` binds Effect's own `Effect`. */ -function collectBindings( - program: ESTree.Program, - reexportModules: readonly string[], -): EffectBindings { +function collectBindings(program: ESTree.Program, reexportModules: readonly string[]): EffectBindings { const shared = collectEffectBindings(program); const accepts = (source: string) => matchesGlobs(source, reexportModules); const namespaces = new Map([...shared.namespaces, ...collectNamedImports(program, accepts)]); @@ -256,9 +247,7 @@ function literalLength(node: ESTree.Node | undefined): number | null { : value.elements.length; } if (value.type === 'ObjectExpression') { - return value.properties.some((property) => property.type === 'SpreadElement') - ? null - : value.properties.length; + return value.properties.some((property) => property.type === 'SpreadElement') ? null : value.properties.length; } return null; } @@ -304,11 +293,7 @@ function inspectConcurrency(value: ESTree.Node, allowUnbounded: boolean): Verdic return OK; } -function memberShape( - namespace: string, - member: string, - options: RuleOptions, -): MemberShape | undefined { +function memberShape(namespace: string, member: string, options: RuleOptions): MemberShape | undefined { if (namespace === 'Effect') return EFFECT_MEMBERS.get(member); if (namespace === 'Stream' && options.streamMembers.has(member)) return STREAM; return undefined; @@ -350,7 +335,11 @@ function reportVerdict( context.report({ node, messageId: 'unboundedConcurrency', - data: { namespace: callee.namespace, member: callee.member, value: verdict.value }, + data: { + namespace: callee.namespace, + member: callee.member, + value: verdict.value, + }, }); } @@ -429,8 +418,7 @@ export const rule = defineRule({ bindings = collectBindings(program, resolved.reexportModules); rootNamespaces = collectRootNamespaces( program, - (source) => - source === EFFECT_ROOT_MODULE || matchesGlobs(source, resolved.reexportModules), + (source) => source === EFFECT_ROOT_MODULE || matchesGlobs(source, resolved.reexportModules), ); directMembers = collectDirectMemberImports( program, diff --git a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts index 5ddda0662..ea02be247 100644 --- a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts +++ b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-context-service-for-service-interface * @@ -14,18 +13,13 @@ import { optionRecord } from '../shared/options.ts'; * not proof that values never enter a runtime. Report only; no fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { - booleanOption as boolean, - stringArray, - stringOption, - safeRegExp, -} from '../shared/options.ts'; import { typeNameSegments } from '../shared/ast.ts'; import { resolveVariable } from '../shared/bindings.ts'; +import { optionRecord } from '../shared/options.ts'; +import { booleanOption as boolean, stringArray, stringOption, safeRegExp } from '../shared/options.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -40,22 +34,13 @@ const DEFAULT_IGNORE = [ '**/scripts/**', ]; -const DEFAULT_SERVICE_NAME_PATTERN = - '(Service|Repository|Gateway|Resolver|Access|Store|Port|Contract)$'; -const DEFAULT_DATA_TYPE_PATTERN = - '(Input|Output|Options|Config|Record|Row|Payload|Result|Error|Problem)$'; +const DEFAULT_SERVICE_NAME_PATTERN = '(Service|Repository|Gateway|Resolver|Access|Store|Port|Contract)$'; +const DEFAULT_DATA_TYPE_PATTERN = '(Input|Output|Options|Config|Record|Row|Payload|Result|Error|Problem)$'; const DEFAULT_EFFECT_TYPES = ['Effect']; const DEFAULT_PROMISE_TYPES = ['Promise', 'PromiseLike']; const DEFAULT_TAG_MEMBERS = ['Service', 'Reference', 'Tag', 'GenericTag']; const DEFAULT_TAG_NAMESPACES = ['Context', 'Effect']; -const DEFAULT_LAYER_MEMBERS = [ - 'effect', - 'succeed', - 'sync', - 'scoped', - 'scopedDiscard', - 'effectDiscard', -]; +const DEFAULT_LAYER_MEMBERS = ['effect', 'succeed', 'sync', 'scoped', 'scopedDiscard', 'effectDiscard']; const TSX_FILE = /\.[cm]?[jt]sx$/u; /** Depth cap for the generic type-subtree walk; deep enough for nested generics, cheap enough to run per member. */ @@ -75,11 +60,7 @@ function readOptions(context: Context) { includePromiseMembers: boolean(record.includePromiseMembers, true), allowLayerConstruction: boolean(record.allowLayerConstruction, true), requireTagPerContract: boolean(record.requireTagPerContract, true), - serviceNamePattern: stringOption( - record.serviceNamePattern, - DEFAULT_SERVICE_NAME_PATTERN, - false, - ), + serviceNamePattern: stringOption(record.serviceNamePattern, DEFAULT_SERVICE_NAME_PATTERN, false), dataTypePattern: stringOption(record.dataTypePattern, DEFAULT_DATA_TYPE_PATTERN, false), effectTypes: stringArray(record.effectTypes, DEFAULT_EFFECT_TYPES), promiseTypes: stringArray(record.promiseTypes, DEFAULT_PROMISE_TYPES), @@ -148,8 +129,7 @@ function importedMemberKey(node: any): unknown { const property = node.property ?? node.right; if (!node.computed) return property.name; if (property.type === 'Literal') return property.value; - if (property.type === 'TemplateLiteral' && !property.expressions.length) - return property.quasis[0]?.value.cooked; + if (property.type === 'TemplateLiteral' && !property.expressions.length) return property.quasis[0]?.value.cooked; return null; } @@ -225,9 +205,8 @@ export const rule = defineRule({ const variableFor = (node: any, name: string): any => resolveVariable(context, name, node); const localAlias = (node: any): any => node?.type === 'Identifier' - ? variableFor(node, node.name)?.defs.find( - (d: any) => d.node.type === 'TSTypeAliasDeclaration', - )?.node.typeAnnotation + ? variableFor(node, node.name)?.defs.find((d: any) => d.node.type === 'TSTypeAliasDeclaration')?.node + .typeAnnotation : null; const imported = (node: any, seen = new Set()): string | null => { if (!node || seen.has(node)) return null; @@ -266,8 +245,7 @@ export const rule = defineRule({ for (const statement of program.body) if (statement.type === 'ExportNamedDeclaration' && !statement.source) { for (const spec of statement.specifiers) - if (spec.local.type === 'Identifier') - separateExports.add(variableFor(spec.local, spec.local.name)); + if (spec.local.type === 'Identifier') separateExports.add(variableFor(spec.local, spec.local.name)); } }; collectSeparateExports(); @@ -285,9 +263,7 @@ export const rule = defineRule({ const taggedDeclarations = new Set(); const taggedFactories = new Set(); const declarationsFor = (id: any): any[] => - id?.type === 'Identifier' - ? (variableFor(id, id.name)?.defs ?? []).map((def: any) => def.node) - : []; + id?.type === 'Identifier' ? (variableFor(id, id.name)?.defs ?? []).map((def: any) => def.node) : []; const collectContracts = (value: any, depth = 0): void => { if (!value || typeof value !== 'object' || depth > MAX_TYPE_DEPTH) return; if (Array.isArray(value)) { @@ -295,13 +271,11 @@ export const rule = defineRule({ return; } collectContractReferences(value); - for (const [key, child] of Object.entries(value)) - if (key !== 'parent') collectContracts(child, depth + 1); + for (const [key, child] of Object.entries(value)) if (key !== 'parent') collectContracts(child, depth + 1); }; const collectContractReferences = (value: any): void => { if (value.type === 'TSTypeReference') - for (const declaration of declarationsFor(value.typeName)) - taggedDeclarations.add(declaration); + for (const declaration of declarationsFor(value.typeName)) taggedDeclarations.add(declaration); if (value.type === 'TSTypeQuery') for (const declaration of declarationsFor(value.exprName)) taggedFactories.add(declaration); }; @@ -330,8 +304,7 @@ export const rule = defineRule({ /** Any effectful type reference anywhere inside a *return type* subtree (unions, arrays, generics). */ const returnTypeIsEffectful = (node: unknown, depth: number): string | null => { if (depth > MAX_TYPE_DEPTH || node === null || typeof node !== 'object') return null; - if (Array.isArray(node)) - return firstResult(node, (entry) => returnTypeIsEffectful(entry, depth + 1)); + if (Array.isArray(node)) return firstResult(node, (entry) => returnTypeIsEffectful(entry, depth + 1)); const record = node as Record; if (typeof record.type !== 'string') return null; if (['TSFunctionType', 'TSConstructorType'].includes(record.type)) @@ -344,13 +317,9 @@ export const rule = defineRule({ } return returnChildrenAreEffectful(record, depth); }; - const returnChildrenAreEffectful = ( - record: Record, - depth: number, - ): string | null => + const returnChildrenAreEffectful = (record: Record, depth: number): string | null => firstResult(Object.entries(record), ([key, value]) => { - if (key === 'parent' || key === 'type' || value === null || typeof value !== 'object') - return null; + if (key === 'parent' || key === 'type' || value === null || typeof value !== 'object') return null; return returnTypeIsEffectful(value, depth + 1); }); @@ -358,8 +327,7 @@ export const rule = defineRule({ const annotationIsEffectful = (type: ESTree.TSType, depth: number): string | null => { if (depth > MAX_TYPE_DEPTH) return null; const current = unwrapType(type); - if (['TSFunctionType', 'TSConstructorType'].includes(current.type)) - return signatureEffect(current); + if (['TSFunctionType', 'TSConstructorType'].includes(current.type)) return signatureEffect(current); if (current.type === 'TSUnionType' || current.type === 'TSIntersectionType') { return firstResult(current.types, (member) => annotationIsEffectful(member, depth + 1)); } @@ -424,9 +392,7 @@ export const rule = defineRule({ }; const tagNameFor = (name: string): string => - name.endsWith('Service') && name.length > 'Service'.length - ? name.slice(0, -'Service'.length) - : `${name}Tag`; + name.endsWith('Service') && name.length > 'Service'.length ? name.slice(0, -'Service'.length) : `${name}Tag`; const isUtilityReference = (name: ESTree.TSTypeName): boolean => name.type === 'Identifier' && @@ -444,9 +410,7 @@ export const rule = defineRule({ }; /** `ReturnType` — a factory-derived service contract. */ - const returnTypeAlias = ( - type: ESTree.TSType, - ): { label: string; factory: ESTree.Node | null } | null => { + const returnTypeAlias = (type: ESTree.TSType): { label: string; factory: ESTree.Node | null } | null => { const current = unwrapType(type); if (current.type !== 'TSTypeReference') return null; if (isUtilityReference(current.typeName)) { @@ -459,8 +423,7 @@ export const rule = defineRule({ const inner = unwrapType(argument); if (inner.type !== 'TSTypeQuery') return null; const name = inner.exprName; - if (name.type === 'Identifier') - return { label: `ReturnType`, factory: name }; + if (name.type === 'Identifier') return { label: `ReturnType`, factory: name }; return { label: 'ReturnType', factory: null }; }; @@ -469,8 +432,7 @@ export const rule = defineRule({ for (const args of tagConstructionArguments(callee)) collectContracts(args); }; const collectDeclarationContract = (declaration: any): void => { - if (declaration.type === 'TSTypeAliasDeclaration') - collectContracts(declaration.typeAnnotation); + if (declaration.type === 'TSTypeAliasDeclaration') collectContracts(declaration.typeAnnotation); }; const collectFactoryContract = (declaration: any): void => { if (declaration.type === 'FunctionDeclaration') collectContracts(declaration.returnType); @@ -498,9 +460,7 @@ export const rule = defineRule({ if (['TSSatisfiesExpression', 'TSAsExpression', 'TSTypeAssertion'].includes(value.type)) { collectContracts(value.typeAnnotation); providedContract(value.expression, seen); - } else if ( - ['TSNonNullExpression', 'TSInstantiationExpression', 'ChainExpression'].includes(value.type) - ) { + } else if (['TSNonNullExpression', 'TSInstantiationExpression', 'ChainExpression'].includes(value.type)) { providedContract(value.expression, seen); } else if (value.type === 'Identifier') { providedIdentifierContract(value, seen); @@ -534,14 +494,12 @@ export const rule = defineRule({ if (config?.type !== 'ObjectExpression') return; for (const property of config.properties) { const key = property.computed ? property.key?.value : property.key?.name; - if (property.type === 'Property' && ['effect', 'defaultValue'].includes(key)) - providedContract(property.value); + if (property.type === 'Property' && ['effect', 'defaultValue'].includes(key)) providedContract(property.value); } }; const outerCall = (node: any): any => { let outer = node; - while (outer.parent?.type === 'CallExpression' && outer.parent.callee === outer) - outer = outer.parent; + while (outer.parent?.type === 'CallExpression' && outer.parent.callee === outer) outer = outer.parent; return outer; }; @@ -550,12 +508,8 @@ export const rule = defineRule({ const path = imported(node.callee); if (!path) return; const segments = path.replace(/^root\./u, '').split('.'); - const isTag = - segments.length === 2 && tagNamespaces.has(segments[0]!) && tagMembers.has(segments[1]!); - const isLayer = - options.allowLayerConstruction && - segments[0] === 'Layer' && - layerMembers.has(segments[1]!); + const isTag = segments.length === 2 && tagNamespaces.has(segments[0]!) && tagMembers.has(segments[1]!); + const isLayer = options.allowLayerConstruction && segments[0] === 'Layer' && layerMembers.has(segments[1]!); if (!isTag && !isLayer) return; moduleHasTag = true; if (isTag) recordTagConstruction(node.callee as AnyNode); @@ -584,9 +538,7 @@ export const rule = defineRule({ if (options.exportedOnly && !isExported(node as unknown as AnyNode)) return; const annotation = unwrapType(node.typeAnnotation); if (annotation.type === 'TSTypeLiteral') { - const member = firstEffectfulMember( - annotation.members as unknown as readonly ESTree.Node[], - ); + const member = firstEffectfulMember(annotation.members as unknown as readonly ESTree.Node[]); if (member === null) return; candidates.push({ node: node.id as unknown as ESTree.Node, diff --git a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts index ebcd41fb0..62e61585c 100644 --- a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts +++ b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts @@ -1,4 +1,5 @@ -import { optionRecord } from '../shared/options.ts'; +import { fileURLToPath } from 'node:url'; + /** * Audit A6 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) asks for Logger, * Tracer/OpenTelemetry and minimum-level Layers at runtime roots. @@ -21,15 +22,14 @@ import { optionRecord } from '../shared/options.ts'; * serialization remain untouched. Tests/scripts are excluded by default. No fixer or suggestions. */ import { defineRule } from '@oxlint/plugins'; -import { fileURLToPath } from 'node:url'; - import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, rootedScopePath, matchesGlobs } from '../shared/paths.ts'; -import { stringArray, booleanOption as boolOption } from '../shared/options.ts'; import { unwrapNode as unwrap, memberName as sharedMemberName, keyName } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { importedName } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; +import { stringArray, booleanOption as boolOption } from '../shared/options.ts'; +import { isTestFile, rootedScopePath, matchesGlobs } from '../shared/paths.ts'; import { isNonReferencePosition as sharedNonReferencePosition } from '../shared/reference-positions.ts'; const EFFECT_MODULE = /^effect(?:\/.*)?$/u; @@ -94,10 +94,7 @@ function readOptions(context: Context) { runtimeTypeNames: stringArray(record.runtimeTypeNames, DEFAULT_RUNTIME_TYPE_NAMES), otelModules: stringArray(record.otelModules, DEFAULT_OTEL_MODULES), reexportModules: stringArray(record.reexportModules, DEFAULT_REEXPORT_MODULES), - minimumLogLevelMembers: stringArray( - record.minimumLogLevelMembers, - DEFAULT_MINIMUM_LOG_LEVEL_MEMBERS, - ), + minimumLogLevelMembers: stringArray(record.minimumLogLevelMembers, DEFAULT_MINIMUM_LOG_LEVEL_MEMBERS), includeScripts: boolOption(record.includeScripts, false), includeTests: boolOption(record.includeTests, false), require: { @@ -124,10 +121,7 @@ function qualifiedSet(entries: readonly string[]): ReadonlySet { return set; } -function isTypeOnly( - declaration: ESTree.ImportDeclaration, - specifier: ESTree.ImportDeclarationSpecifier, -): boolean { +function isTypeOnly(declaration: ESTree.ImportDeclaration, specifier: ESTree.ImportDeclarationSpecifier): boolean { if (declaration.importKind === 'type') return true; return specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'; } @@ -170,8 +164,7 @@ function collectRuntimeTypes( bindings: CollectedBindings, ): void { for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') - bindings.runtimeTypeNamespaces.add(specifier.local.name); + if (specifier.type === 'ImportNamespaceSpecifier') bindings.runtimeTypeNamespaces.add(specifier.local.name); if (specifier.type === 'ImportSpecifier' && names.has(importedName(specifier))) bindings.runtimeTypeLocals.add(specifier.local.name); } @@ -195,10 +188,7 @@ function collectEffectSpecifier( else if (submodule !== undefined) bindings.namespaces.set(local, submodule); } -function collectBarrelSpecifier( - specifier: ESTree.ImportDeclarationSpecifier, - bindings: CollectedBindings, -): void { +function collectBarrelSpecifier(specifier: ESTree.ImportDeclarationSpecifier, bindings: CollectedBindings): void { if (specifier.type === 'ImportNamespaceSpecifier') bindings.barrels.add(specifier.local.name); if (specifier.type !== 'ImportSpecifier') return; const imported = importedName(specifier); @@ -265,8 +255,7 @@ function collectFileBindings(program: ESTree.Program, options: RuleOptions): Fil const names = new Set(options.runtimeTypeNames); for (const statement of program.body) { if (statement.type !== 'ImportDeclaration') continue; - if (matchesGlobs(statement.source.value, options.reexportModules)) - collectRuntimeTypes(statement, names, bindings); + if (matchesGlobs(statement.source.value, options.reexportModules)) collectRuntimeTypes(statement, names, bindings); collectValueImport(statement, options, bindings); } return bindings; @@ -283,11 +272,7 @@ function isNonReferencePosition(node: ESTree.Node): boolean { }); } -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); +const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); /** `true` when any ancestor is a function — i.e. the node is *not* at module top level. */ function insideFunction(node: ESTree.Node): boolean { @@ -295,8 +280,7 @@ function insideFunction(node: ESTree.Node): boolean { while (current !== null && current !== undefined) { if (FUNCTION_TYPES.has(current.type)) { let expression = current; - while (expression.parent && unwrap(expression.parent) === current) - expression = expression.parent; + while (expression.parent && unwrap(expression.parent) === current) expression = expression.parent; const parent = expression.parent; if (parent?.type !== 'CallExpression' || parent.callee !== expression) return true; } @@ -307,12 +291,7 @@ function insideFunction(node: ESTree.Node): boolean { } /** Type positions a runtime type may hide inside while still being *the* return type. */ -const RETURN_TYPE_WRAPPERS = new Set([ - 'TSIntersectionType', - 'TSUnionType', - 'TSParenthesizedType', - 'TSTypeReference', -]); +const RETURN_TYPE_WRAPPERS = new Set(['TSIntersectionType', 'TSUnionType', 'TSParenthesizedType', 'TSTypeReference']); /** * When `node` is (part of) a function's return type annotation, return that function. Walks up @@ -325,16 +304,12 @@ function initializedFunctionType(owner: ESTree.Node): ESTree.Node | null { if (annotation?.type !== 'TSTypeAnnotation') return null; const binding = annotation.parent; const declaration = binding?.parent; - if (declaration?.type !== 'VariableDeclarator' || declaration.id !== binding || !declaration.init) - return null; + if (declaration?.type !== 'VariableDeclarator' || declaration.id !== binding || !declaration.init) return null; return declaration; } function returnAnnotationOwner(annotation: ESTree.Node): ESTree.Node | null { - const owner = annotation.parent as - | (ESTree.Node & { returnType?: unknown; body?: unknown }) - | null - | undefined; + const owner = annotation.parent as (ESTree.Node & { returnType?: unknown; body?: unknown }) | null | undefined; if (!owner || owner.returnType !== annotation) return null; if (FUNCTION_TYPES.has(owner.type) && owner.body) return owner; return initializedFunctionType(owner); @@ -351,16 +326,11 @@ function functionOwningReturnType(node: ESTree.Node): ESTree.Node | null { } function excludedPath(path: string, options: RuleOptions): boolean { - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) + if (/\.d\.[cm]?ts$/u.test(path) || /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path)) return true; if (matchesGlobs(path, options.ignore)) return true; if (!options.includeTests && isTestFile(path)) return true; - return /(?:^|\/)scripts\//u.test(path) - ? !options.includeScripts - : !matchesGlobs(path, options.include); + return /(?:^|\/)scripts\//u.test(path) ? !options.includeScripts : !matchesGlobs(path, options.include); } function qualifyValue(base: string | null, key: string | null): string | null { @@ -368,46 +338,27 @@ function qualifyValue(base: string | null, key: string | null): string | null { return base === '$root' ? key : `${base}.${key}`; } -function destructuredValue( - declaration: ESTree.VariableDeclarator, - name: string, - base: string | null, -): string | null { +function destructuredValue(declaration: ESTree.VariableDeclarator, name: string, base: string | null): string | null { if (declaration.id.type === 'Identifier') return base; if (declaration.id.type !== 'ObjectPattern' || base === null) return null; for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; + if (property.type !== 'Property' || property.value.type !== 'Identifier' || property.value.name !== name) continue; return qualifyValue(base, keyName(property.key, property.computed, { templates: false })); } return null; } -function aliasDeclaration( - variable: Variable | null, - seen: Set, -): ESTree.VariableDeclarator | null { +function aliasDeclaration(variable: Variable | null, seen: Set): ESTree.VariableDeclarator | null { if (!variable || seen.has(variable)) return null; if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; seen.add(variable); const definition = variable.defs[0]; - if ( - definition?.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - !definition.node.init - ) + if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator' || !definition.node.init) return null; return definition.node; } -function rootAnchor( - roots: RootHit[], - program: ESTree.Program, -): { node: ESTree.Node; kind: string } { +function rootAnchor(roots: RootHit[], program: ESTree.Program): { node: ESTree.Node; kind: string } { roots.sort((left, right) => left.start - right.start); const first = roots[0]; return { @@ -553,16 +504,14 @@ export const rule = defineRule({ } if (typeName.type !== 'TSQualifiedName' || typeName.left.type !== 'Identifier') return null; const name = typeName.right.name; - if (!runtimeTypeNameSet.has(name) || !bindings.runtimeTypeNamespaces.has(typeName.left.name)) - return null; + if (!runtimeTypeNameSet.has(name) || !bindings.runtimeTypeNamespaces.has(typeName.left.name)) return null; return resolvesToImport(context, typeName.left) ? name : null; }; const missingEvidence = (): string[] => { const missing: string[] = []; if (options.require.logger && !hasLogger) missing.push('missingLogger'); if (options.require.tracer && !hasTracer) missing.push('missingTracer'); - if (options.require.minimumLogLevel && !hasMinimumLogLevel) - missing.push('missingMinimumLogLevel'); + if (options.require.minimumLogLevel && !hasMinimumLogLevel) missing.push('missingMinimumLogLevel'); return missing; }; @@ -613,7 +562,11 @@ export const rule = defineRule({ const { node: anchor, kind } = rootAnchor(roots, node); for (const entry of missing) { - context.report({ node: anchor, messageId: entry, data: { root: path, kind } }); + context.report({ + node: anchor, + messageId: entry, + data: { root: path, kind }, + }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts b/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts index d639b0e35..f5d839dcd 100644 --- a/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts +++ b/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-timeout-on-external-effect * @@ -81,15 +80,15 @@ import { optionRecord } from '../shared/options.ts'; * or `scripts/` is edited to satisfy this rule. */ import { defineRule } from '@oxlint/plugins'; - import type { Context, ESTree } from '@oxlint/plugins'; -import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; import { identityUnwrap, staticString, FUNCTION_TYPES } from '../shared/ast.ts'; import { lookupVariable as lexicalVariable } from '../shared/bindings.ts'; import { bindingPath } from '../shared/effect-identity.ts'; import { importedName } from '../shared/imports.ts'; +import { optionRecord } from '../shared/options.ts'; import { stringArray, booleanOption as boolean, stringOption, compile } from '../shared/options.ts'; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -142,11 +141,7 @@ function readOptions(context: Context): RuleOptions { requireRetry: boolean(record.requireRetry, false), portFiles: stringArray(record.portFiles, DEFAULT_PORT_FILES), trustPorts: boolean(record.trustPorts, false), - policyHelperPattern: stringOption( - record.policyHelperPattern, - DEFAULT_POLICY_HELPER_PATTERN, - false, - ), + policyHelperPattern: stringOption(record.policyHelperPattern, DEFAULT_POLICY_HELPER_PATTERN, false), includeTests: boolean(record.includeTests, false), includeScripts: boolean(record.includeScripts, false), include: stringArray(record.include, DEFAULT_INCLUDE), @@ -159,19 +154,13 @@ function readOptions(context: Context): RuleOptions { function inScope(path: string, options: RuleOptions): boolean { if (!options.requireTimeout && !options.requireRetry) return false; - if (matchesGlobs(path, options.ignore) || (!options.includeTests && isTestFile(path))) - return false; - if (isScriptFile(path) ? !options.includeScripts : !matchesGlobs(path, options.include)) - return false; + if (matchesGlobs(path, options.ignore) || (!options.includeTests && isTestFile(path))) return false; + if (isScriptFile(path) ? !options.includeScripts : !matchesGlobs(path, options.include)) return false; return !(options.trustPorts && matchesGlobs(path, options.portFiles)); } type Definition = import('@oxlint/plugins').Variable['defs'][number]; -function unseenDefinition( - context: Context, - node: ESTree.Node, - seen: Set, -): Definition | undefined { +function unseenDefinition(context: Context, node: ESTree.Node, seen: Set): Definition | undefined { const variable = lexicalVariable(context, node); if (variable === null || seen.has(variable) || variable.defs.length !== 1) return undefined; seen.add(variable); @@ -183,8 +172,7 @@ function memberPolicy(member: string | null): Policy | null { return RETRY_MEMBERS.has(member) ? { timeout: false, retry: true } : null; } function constantInitializer(def: Definition | undefined): ESTree.Expression | null { - if (def?.type !== 'Variable' || (def.parent as ESTree.VariableDeclaration)?.kind !== 'const') - return null; + if (def?.type !== 'Variable' || (def.parent as ESTree.VariableDeclaration)?.kind !== 'const') return null; return (def.node as ESTree.VariableDeclarator).init; } function importedPolicy(def: Definition, localName: string, pattern: RegExp): Policy | null { @@ -194,10 +182,7 @@ function importedPolicy(def: Definition, localName: string, pattern: RegExp): Po const name = specifier.type === 'ImportSpecifier' ? importedName(specifier) : localName; return pattern.test(name) ? { timeout: true, retry: true } : null; } -function isHttpNamespace( - context: Context, - name: Extract, -): boolean { +function isHttpNamespace(context: Context, name: Extract): boolean { const imported = lexicalVariable(context, name)?.defs[0]; if (imported?.type !== 'ImportBinding') return false; const source = (imported.parent as ESTree.ImportDeclaration).source.value; @@ -213,14 +198,9 @@ function isHttpNamespace( function hasHttpAnnotation(context: Context, binding: ESTree.Node | undefined): boolean { if (binding?.type !== 'Identifier') return false; const annotation = binding.typeAnnotation?.typeAnnotation; - if (annotation?.type !== 'TSTypeReference' || annotation.typeName.type !== 'TSQualifiedName') - return false; + if (annotation?.type !== 'TSTypeReference' || annotation.typeName.type !== 'TSQualifiedName') return false; const name = annotation.typeName; - return ( - name.left.type === 'Identifier' && - name.right.name === 'HttpClient' && - isHttpNamespace(context, name.left) - ); + return name.left.type === 'Identifier' && name.right.name === 'HttpClient' && isHttpNamespace(context, name.left); } function tryProperty(property: ESTree.ObjectExpression['properties'][number]): boolean { if (property.type !== 'Property') return false; @@ -238,8 +218,7 @@ function bridgeThunk(call: ESTree.CallExpression): ESTree.Node | null { return property?.type === 'Property' ? property.value : null; } function returnedBody(thunk: ESTree.Node | null): ESTree.Node | null { - if (thunk?.type !== 'ArrowFunctionExpression' && thunk?.type !== 'FunctionExpression') - return null; + if (thunk?.type !== 'ArrowFunctionExpression' && thunk?.type !== 'FunctionExpression') return null; const body = thunk.body; if (body?.type !== 'BlockStatement') return body; if (body.body.length !== 1 || body.body[0]?.type !== 'ReturnStatement') return null; @@ -318,9 +297,7 @@ export const rule = defineRule({ return identity?.length === 2 && identity[0] === 'Effect' ? (identity[1] ?? null) : null; }; const memberKey = (node: ESTree.MemberExpression): string | null => - !node.computed && node.property.type === 'Identifier' - ? node.property.name - : staticString(node.property); + !node.computed && node.property.type === 'Identifier' ? node.property.name : staticString(node.property); const effectCallee = (call: ESTree.CallExpression): string | null => { const callee = identityUnwrap(call.callee); if (callee.type === 'CallExpression' && effectMemberOf(callee.callee) === 'fn') return 'fn'; @@ -348,11 +325,7 @@ export const rule = defineRule({ }; const outerExpression = (node: ESTree.Node): ESTree.Node => { let current = node; - while ( - current.parent !== null && - current.parent !== undefined && - identityUnwrap(current.parent) === current - ) + while (current.parent !== null && current.parent !== undefined && identityUnwrap(current.parent) === current) current = current.parent; return current; }; @@ -379,8 +352,7 @@ export const rule = defineRule({ (member === 'acquireRelease' && index === 1) || (member === 'acquireUseRelease' && index === 2) || (member === 'addFinalizer' && index === 0) || - (['ensuring', 'onExit', 'onInterrupt'].includes(member ?? '') && - index === call.arguments.length - 1) + (['ensuring', 'onExit', 'onInterrupt'].includes(member ?? '') && index === call.arguments.length - 1) ); }; type AncestorStep = 'continue' | 'stop' | 'finalizer'; @@ -391,36 +363,18 @@ export const rule = defineRule({ if (finalizerArgument(owner, outer)) return 'finalizer'; const member = effectCallee(owner); if (member === null) return 'stop'; - return effectCallbacks.has(member) || - (options.crossEffectGen && EFFECT_PROGRAM_WRAPPERS.has(member)) + return effectCallbacks.has(member) || (options.crossEffectGen && EFFECT_PROGRAM_WRAPPERS.has(member)) ? 'continue' : 'stop'; }; - const canCrossCall = ( - call: ESTree.CallExpression, - member: string | null, - index: number, - ): boolean => { - const separateLifetime = [ - 'map', - 'sync', - 'succeed', - 'as', - 'forkChild', - 'forkScoped', - 'forkDaemon', - 'cached', - ]; + const canCrossCall = (call: ESTree.CallExpression, member: string | null, index: number): boolean => { + const separateLifetime = ['map', 'sync', 'succeed', 'as', 'forkChild', 'forkScoped', 'forkDaemon', 'cached']; if (member !== null && !separateLifetime.includes(member)) return true; // Native Array.map builds the Effect collection; do not infer arbitrary helpers. const callee = identityUnwrap(call.callee); return callee.type === 'MemberExpression' && memberKey(callee) === 'map' && index >= 0; }; - const mergeFollowing = ( - call: ESTree.CallExpression, - start: number, - merge: (value: ESTree.Node) => void, - ): void => { + const mergeFollowing = (call: ESTree.CallExpression, start: number, merge: (value: ESTree.Node) => void): void => { for (const argument of call.arguments.slice(start)) merge(argument); }; const isPolicyMember = (member: string | null): boolean => @@ -510,14 +464,9 @@ export const rule = defineRule({ if (body === null) return false; body = identityUnwrap(body); if (body.type === 'AwaitExpression') body = identityUnwrap(body.argument); - return ( - body.type === 'ImportExpression' && /^\.{1,2}\//u.test(staticString(body.source) ?? '') - ); + return body.type === 'ImportExpression' && /^\.{1,2}\//u.test(staticString(body.source) ?? ''); }; - const report = ( - node: ESTree.Node, - messageId: 'unboundedPromiseBridge' | 'unboundedHttpCall', - ): void => { + const report = (node: ESTree.Node, messageId: 'unboundedPromiseBridge' | 'unboundedHttpCall'): void => { const found = inspectAncestors(node); if (found.finalizer || satisfied(found.policy)) return; context.report({ @@ -547,20 +496,14 @@ export const rule = defineRule({ if (!options.promiseBridges.includes(effectMemberOf(node) ?? '')) return; const outer = outerExpression(node), parent = outer.parent; - if ( - parent?.type === 'CallExpression' && - parent.arguments.includes(outer as ESTree.Argument) - ) + if (parent?.type === 'CallExpression' && parent.arguments.includes(outer as ESTree.Argument)) report(node, 'unboundedPromiseBridge'); }, Identifier(node) { if (!options.promiseBridges.includes(effectMemberOf(node) ?? '')) return; const outer = outerExpression(node), parent = outer.parent; - if ( - parent?.type === 'CallExpression' && - parent.arguments.includes(outer as ESTree.Argument) - ) + if (parent?.type === 'CallExpression' && parent.arguments.includes(outer as ESTree.Argument)) report(node, 'unboundedPromiseBridge'); }, }; diff --git a/app/tools/oxlint/effect-native/shared/ast.ts b/app/tools/oxlint/effect-native/shared/ast.ts index a8b747e3d..8108b033b 100644 --- a/app/tools/oxlint/effect-native/shared/ast.ts +++ b/app/tools/oxlint/effect-native/shared/ast.ts @@ -19,11 +19,7 @@ export const FUNCTION_TYPES: ReadonlySet = new Set([ ]); export function isNode(value: unknown): value is Syntax { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); + return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string'; } /** requireStart preserves the stricter generator-walker node guard. */ @@ -53,8 +49,7 @@ export interface UnwrapOptions { function innerExpression(node: Syntax, options: UnwrapOptions): Syntax | null { if (options.sequence && node.type === 'SequenceExpression') return asNode(node.expressions.at(-1), options.requireStart); - if (options.await && node.type === 'AwaitExpression') - return asNode(node.argument, options.requireStart); + if (options.await && node.type === 'AwaitExpression') return asNode(node.argument, options.requireStart); if (!(options.wrappers ?? EXPRESSION_WRAPPERS).has(node.type)) return null; return asNode(options.argumentFallback ? (node.expression ?? node.argument) : node.expression); } @@ -118,10 +113,7 @@ function stringNode(value: unknown, options: StringOptions): Syntax | null { return node && options.unwrap ? unwrapNode(node, options.unwrap) : node; } function isStringLiteral(node: Syntax, options: StringOptions): boolean { - return ( - node.type === 'Literal' || - (options.babelStrings === true && (node.type as string) === 'StringLiteral') - ); + return node.type === 'Literal' || (options.babelStrings === true && (node.type as string) === 'StringLiteral'); } /** String literals and, by default, interpolation-free cooked templates; never dynamic keys. */ @@ -131,11 +123,7 @@ export function staticString(value: unknown, options: StringOptions = {}): strin if (isStringLiteral(node, options)) { return typeof node.value === 'string' ? node.value : null; } - if ( - options.templates !== false && - node.type === 'TemplateLiteral' && - node.expressions.length === 0 - ) { + if (options.templates !== false && node.type === 'TemplateLiteral' && node.expressions.length === 0) { return templateText(node, options.rawTemplates === true, options.singleQuasi === true); } return null; @@ -145,11 +133,7 @@ export function literalText(value: unknown): string | null { return staticString(syntax(value)); } -export function keyName( - value: unknown, - computed = false, - options: StringOptions = {}, -): string | null { +export function keyName(value: unknown, computed = false, options: StringOptions = {}): string | null { const input = asNode(value); const key = input && options.unwrap ? unwrapNode(input, options.unwrap) : input; if (!computed && key?.type === 'Identifier') return key.name; @@ -157,10 +141,7 @@ export function keyName( } /** Defaults to literal-only computed keys; opt into templates/unwrap to preserve wider copies. */ -export function memberName( - node: unknown, - options: StringOptions = { templates: false }, -): string | null { +export function memberName(node: unknown, options: StringOptions = { templates: false }): string | null { const member = asNode(node); return member ? keyName(member.property, member.computed === true, options) : null; } @@ -200,8 +181,7 @@ export function childrenOf( requireStart = true, ): Syntax[] { const record = node as Syntax; - const names = - visitorKeys[node.type] ?? Object.keys(node).filter((key) => key !== 'parent' && key !== 'type'); + const names = visitorKeys[node.type] ?? Object.keys(node).filter((key) => key !== 'parent' && key !== 'type'); return names.flatMap((name) => { const value = record[name]; const values: unknown[] = Array.isArray(value) ? value : [value]; @@ -245,12 +225,9 @@ export interface TypeUnwrapOptions { readonly elementTypeFallback?: boolean; } function innerType(node: Syntax, options: TypeUnwrapOptions): Syntax | null { - const readonly = - options.readonlyOperator && node.type === 'TSTypeOperator' && node.operator === 'readonly'; + const readonly = options.readonlyOperator && node.type === 'TSTypeOperator' && node.operator === 'readonly'; if (!readonly && !(options.wrappers ?? TYPE_WRAPPERS).has(node.type)) return null; - return asNode( - options.elementTypeFallback ? (node.typeAnnotation ?? node.elementType) : node.typeAnnotation, - ); + return asNode(options.elementTypeFallback ? (node.typeAnnotation ?? node.elementType) : node.typeAnnotation); } const TYPE_WRAPPERS: ReadonlySet = new Set(['TSParenthesizedType']); export function unwrapType(node: ESTree.Node, options: TypeUnwrapOptions = {}): Syntax { @@ -284,8 +261,7 @@ export function asNamedMember( ): ESTree.MemberExpression | null { const node = unwrapNode(input, options); if (node.type !== 'MemberExpression') return null; - if (!node.computed) - return node.property.type === 'Identifier' && node.property.name === name ? node : null; + if (!node.computed) return node.property.type === 'Identifier' && node.property.name === name ? node : null; if ((node.property.type as string) === 'PrivateIdentifier') return null; return resolveComputed(node.property) === name ? node : null; } diff --git a/app/tools/oxlint/effect-native/shared/bindings.ts b/app/tools/oxlint/effect-native/shared/bindings.ts index 3485b363f..37be9d08d 100644 --- a/app/tools/oxlint/effect-native/shared/bindings.ts +++ b/app/tools/oxlint/effect-native/shared/bindings.ts @@ -1,13 +1,10 @@ import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; + import { asNode } from './ast.ts'; type Definition = Variable['defs'][number]; -export function resolveVariable( - context: Context, - name: string, - from: ESTree.Node, -): Variable | null { +export function resolveVariable(context: Context, name: string, from: ESTree.Node): Variable | null { let scope: Scope | null = context.sourceCode.getScope(from); while (scope !== null) { const variable = scope.set.get(name); @@ -17,9 +14,7 @@ export function resolveVariable( return null; } export function lookupVariable(context: Context, identifier: ESTree.Node): Variable | null { - return identifier.type === 'Identifier' - ? resolveVariable(context, identifier.name, identifier) - : null; + return identifier.type === 'Identifier' ? resolveVariable(context, identifier.name, identifier) : null; } function isValueImport(definition: Definition): boolean { if (definition.type !== 'ImportBinding') return false; @@ -32,18 +27,10 @@ function isValueImport(definition: Definition): boolean { function isValueDefinition(definition: Definition): boolean { if ((definition.type as string) === 'Type') return false; if (definition.type !== 'ImportBinding') return true; - return ( - asNode(definition.node)?.importKind !== 'type' && - asNode(definition.parent)?.importKind !== 'type' - ); + return asNode(definition.node)?.importKind !== 'type' && asNode(definition.parent)?.importKind !== 'type'; } /** ignoreTypeOnly=false preserves the older all-definitions shadow check; JSON rules use true. */ -export function isUnshadowedGlobal( - context: Context, - node: ESTree.Node, - name: string, - ignoreTypeOnly = false, -): boolean { +export function isUnshadowedGlobal(context: Context, node: ESTree.Node, name: string, ignoreTypeOnly = false): boolean { if (node.type !== 'Identifier' || node.name !== name) return false; if (!ignoreTypeOnly) { const variable = resolveVariable(context, name, node); @@ -57,16 +44,10 @@ export function isUnshadowedGlobal( return true; } /** Unresolved identifiers remain true: callers must already have established a module import. */ -export function resolvesToImport( - context: Context, - identifier: ESTree.Node, - valueOnly = false, -): boolean { +export function resolvesToImport(context: Context, identifier: ESTree.Node, valueOnly = false): boolean { const variable = lookupVariable(context, identifier); if (variable === null || variable.defs.length === 0) return true; - return variable.defs.some( - valueOnly ? isValueImport : (definition) => definition.type === 'ImportBinding', - ); + return variable.defs.some(valueOnly ? isValueImport : (definition) => definition.type === 'ImportBinding'); } /** Declaration-based identity. A caller chooses object identity or span equality explicitly. */ diff --git a/app/tools/oxlint/effect-native/shared/discover-rules.ts b/app/tools/oxlint/effect-native/shared/discover-rules.ts index 76d90c6a4..ffde1286b 100644 --- a/app/tools/oxlint/effect-native/shared/discover-rules.ts +++ b/app/tools/oxlint/effect-native/shared/discover-rules.ts @@ -15,9 +15,7 @@ export function listRuleNames(): readonly string[] { } /** Import selected rules; isolated fixture runs need not load unrelated modules under repair. */ -export async function discoverRules( - names: readonly string[] = listRuleNames(), -): Promise> { +export async function discoverRules(names: readonly string[] = listRuleNames()): Promise> { const available = new Set(listRuleNames()); const rules: Record = {}; for (const name of names) { diff --git a/app/tools/oxlint/effect-native/shared/effect-identity.ts b/app/tools/oxlint/effect-native/shared/effect-identity.ts index 3eaf6502f..ac4a738a9 100644 --- a/app/tools/oxlint/effect-native/shared/effect-identity.ts +++ b/app/tools/oxlint/effect-native/shared/effect-identity.ts @@ -1,4 +1,5 @@ import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; + import { asNode, identityUnwrap, keyName, unwrapNode, type Syntax } from './ast.ts'; import { lookupVariable } from './bindings.ts'; import { matchesGlobs } from './paths.ts'; @@ -13,11 +14,7 @@ interface OriginState { readonly seen: Set; readonly depth: number; } -const TYPE_DECLARATIONS = new Set([ - 'TSInterfaceDeclaration', - 'TSTypeAliasDeclaration', - 'TSTypeParameter', -]); +const TYPE_DECLARATIONS = new Set(['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter']); function valueDefinitions(variable: Variable): Definition[] { return variable.defs.filter((definition) => !TYPE_DECLARATIONS.has(definition.node.type)); } @@ -53,19 +50,14 @@ function moduleBase(source: string, policy: OriginPolicy): string[] | null { function importPath(definition: Definition, policy: OriginPolicy): readonly string[] | null { const spec = definition.node; const parent = definition.parent; - const declaration = - policy.legacyOrigin && parent?.type !== 'ImportDeclaration' ? spec.parent : parent; + const declaration = policy.legacyOrigin && parent?.type !== 'ImportDeclaration' ? spec.parent : parent; if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; if (asNode(spec)?.importKind === 'type') return null; const base = moduleBase(declaration.source.value, policy); if (base === null) return null; return importedPath(spec, base, policy.legacyOrigin); } -function importedPath( - spec: ESTree.Node, - base: readonly string[], - legacy: boolean, -): readonly string[] | null { +function importedPath(spec: ESTree.Node, base: readonly string[], legacy: boolean): readonly string[] | null { if (spec.type === 'ImportNamespaceSpecifier') return base; if (spec.type === 'ImportDefaultSpecifier') return legacy ? base : null; if (spec.type !== 'ImportSpecifier') return null; @@ -76,12 +68,7 @@ function importedPath( function flatBindingKey(pattern: ESTree.Node, name: string): string | null { if (pattern.type !== 'ObjectPattern') return null; for (const property of pattern.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; + if (property.type !== 'Property' || property.value.type !== 'Identifier' || property.value.name !== name) continue; return keyName(property.key, property.computed); } return null; @@ -120,11 +107,7 @@ function aliasPath( const key = flatBindingKey(declaration.id, node.name); return key === null ? null : [...base, key]; } -function identifierPath( - context: Context, - node: Syntax, - state: OriginState, -): readonly string[] | null { +function identifierPath(context: Context, node: Syntax, state: OriginState): readonly string[] | null { const found = originVariable(context, node, state.policy.legacyOrigin); if (!found || found.definitions.length !== 1) return null; if (!state.policy.legacyOrigin && state.seen.has(found.variable)) return null; @@ -134,11 +117,7 @@ function identifierPath( ? importPath(definition, state.policy) : aliasPath(context, node, definition, found.variable, state); } -function resolveOrigin( - context: Context, - input: ESTree.Node, - state: OriginState, -): readonly string[] | null { +function resolveOrigin(context: Context, input: ESTree.Node, state: OriginState): readonly string[] | null { if (state.policy.legacyOrigin && state.depth > 24) return null; const node = state.policy.legacyOrigin ? unwrapNode(input) : identityUnwrap(input); if (node.type === 'MemberExpression') { @@ -190,8 +169,7 @@ export function isGenCallee( ): boolean { if (input === null) return false; const target = identityUnwrap(input); - if (target.type === 'CallExpression') - return isGenCallee(context, target.callee, members, extraModules); + if (target.type === 'CallExpression') return isGenCallee(context, target.callee, members, extraModules); const path = bindingPath(context, target, extraModules); return path?.length === 2 && path[0] === 'Effect' && members.includes(path[1] ?? ''); } diff --git a/app/tools/oxlint/effect-native/shared/effect-imports.ts b/app/tools/oxlint/effect-native/shared/effect-imports.ts index 7119283ba..cf334d62f 100644 --- a/app/tools/oxlint/effect-native/shared/effect-imports.ts +++ b/app/tools/oxlint/effect-native/shared/effect-imports.ts @@ -34,16 +34,9 @@ function addEffectSpecifiers( ): void { for (const specifier of specifiers) { if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + const imported = specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; namespaces.set(specifier.local.name, imported); - } else if ( - specifier.type === 'ImportNamespaceSpecifier' && - submodule !== undefined && - submodule !== 'effect' - ) { + } else if (specifier.type === 'ImportNamespaceSpecifier' && submodule !== undefined && submodule !== 'effect') { namespaces.set(specifier.local.name, submodule); } } diff --git a/app/tools/oxlint/effect-native/shared/imports.ts b/app/tools/oxlint/effect-native/shared/imports.ts index 0a058c7d5..c7c77d439 100644 --- a/app/tools/oxlint/effect-native/shared/imports.ts +++ b/app/tools/oxlint/effect-native/shared/imports.ts @@ -1,11 +1,10 @@ import type { ESTree } from '@oxlint/plugins'; + import { collectEffectBindings, type EffectBindings } from './effect-imports.ts'; import { matchesGlobs } from './paths.ts'; export function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; } export interface ImportPolicy { @@ -29,16 +28,9 @@ export function importDeclarations( ); } -function allowedSpecifier( - specifier: ESTree.ImportDeclaration['specifiers'][number], - policy: ImportPolicy, -): boolean { +function allowedSpecifier(specifier: ESTree.ImportDeclaration['specifiers'][number], policy: ImportPolicy): boolean { if (policy.excludedLocals?.has(specifier.local.name)) return false; - return !( - policy.valueOnly && - specifier.type === 'ImportSpecifier' && - specifier.importKind === 'type' - ); + return !(policy.valueOnly && specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'); } /** Namespace locals for caller-selected root sources; no default imports or automatic submodule matching. */ @@ -143,27 +135,20 @@ export function collectNamespaceLocals( reexportModules: readonly string[], policy: ImportPolicy = {}, ): { namespaced: Map; barrel: Set } { - const namespaced = new Map( - [...bindings.namespaces].filter(([, namespace]) => watched.has(namespace)), - ); + const namespaced = new Map([...bindings.namespaces].filter(([, namespace]) => watched.has(namespace))); const accepts = (source: string) => source === 'effect' || matchesGlobs(source, reexportModules); - for (const [local, name] of collectNamedImports(program, accepts, watched, policy)) - namespaced.set(local, name); - return { namespaced, barrel: collectRootNamespaces(program, accepts, policy) }; + for (const [local, name] of collectNamedImports(program, accepts, watched, policy)) namespaced.set(local, name); + return { + namespaced, + barrel: collectRootNamespaces(program, accepts, policy), + }; } /** Generator-family exact barrels add only named Effect exports; retains original type-import policy. */ -export function bindingsWithExtraModules( - program: ESTree.Program, - modules: readonly string[], -): EffectBindings { +export function bindingsWithExtraModules(program: ESTree.Program, modules: readonly string[]): EffectBindings { const base = collectEffectBindings(program); if (modules.length === 0) return base; - const extra = collectNamedImports( - program, - (source) => modules.includes(source), - new Set(['Effect']), - ); + const extra = collectNamedImports(program, (source) => modules.includes(source), new Set(['Effect'])); return { namespaces: new Map([...base.namespaces, ...extra]), importsEffect: base.importsEffect || extra.size > 0, @@ -183,13 +168,10 @@ export function collectSchemaLocals( const isRoot = (source: string) => !isSchema(source) && (source === 'effect' || matchesGlobs(source, reexportModules)); const schema = new Set( - [...bindings.namespaces] - .filter(([, namespace]) => namespace === 'Schema') - .map(([local]) => local), + [...bindings.namespaces].filter(([, namespace]) => namespace === 'Schema').map(([local]) => local), ); for (const local of collectRootNamespaces(program, isSchema, policy)) schema.add(local); - for (const local of collectNamedImports(program, isRoot, new Set(['Schema']), policy).keys()) - schema.add(local); + for (const local of collectNamedImports(program, isRoot, new Set(['Schema']), policy).keys()) schema.add(local); return { schema, barrel: collectRootNamespaces(program, isRoot, policy), diff --git a/app/tools/oxlint/effect-native/shared/json-rule-scope.ts b/app/tools/oxlint/effect-native/shared/json-rule-scope.ts index f7545f27a..e793a9167 100644 --- a/app/tools/oxlint/effect-native/shared/json-rule-scope.ts +++ b/app/tools/oxlint/effect-native/shared/json-rule-scope.ts @@ -2,11 +2,7 @@ import { booleanOption, stringList } from './options.ts'; import { isTestFile, matchesAny, workspacePath } from './paths.ts'; /** Both native JSON rules share option semantics but retain their own default include paths. */ -export function inJsonRuleScope( - filename: string, - raw: unknown, - defaultIncludePaths: readonly string[], -): boolean { +export function inJsonRuleScope(filename: string, raw: unknown, defaultIncludePaths: readonly string[]): boolean { const given = (raw ?? {}) as Partial<{ includePaths: unknown; allowPaths: unknown; diff --git a/app/tools/oxlint/effect-native/shared/no-promise-port-types.ts b/app/tools/oxlint/effect-native/shared/no-promise-port-types.ts index 58f331e7c..e7eb59cff 100644 --- a/app/tools/oxlint/effect-native/shared/no-promise-port-types.ts +++ b/app/tools/oxlint/effect-native/shared/no-promise-port-types.ts @@ -18,11 +18,11 @@ type VariableLookup = (node: any, name: string) => any; /** Same-file type resolution: applied arguments belong to the caller's environment, * while defaults and constraints belong to the progressively bound declaration. */ -export function createPromisePortTypeResolver( - variableFor: VariableLookup, - promiseTypes: readonly string[], -) { - const branch = (state: Resolution): Resolution => ({ ...state, seen: new Set(state.seen) }); +export function createPromisePortTypeResolver(variableFor: VariableLookup, promiseTypes: readonly string[]) { + const branch = (state: Resolution): Resolution => ({ + ...state, + seen: new Set(state.seen), + }); const promiseName = (name: string, state: Resolution): string | null => state.functionAliasOnly && !state.insideFunction ? null : `${name}<…>`; @@ -74,15 +74,14 @@ export function createPromisePortTypeResolver( return own ?? resolveFirst(alias.extends ?? [], inheritedState); }; - const resolveLocalReference = ( - raw: any, - typeName: any, - name: string, - state: Resolution, - ): string | null => { + const resolveLocalReference = (raw: any, typeName: any, name: string, state: Resolution): string | null => { const variable = variableFor(typeName, name); const bound = state.substitutions.get(variable); - if (bound) return resolve(bound.node, { ...state, substitutions: bound.substitutions }); + if (bound) + return resolve(bound.node, { + ...state, + substitutions: bound.substitutions, + }); const alias = variable?.defs.find((def: any) => ['TSTypeAliasDeclaration', 'TSInterfaceDeclaration'].includes(def.node.type), )?.node; @@ -111,13 +110,9 @@ export function createPromisePortTypeResolver( const resolveShape = (raw: any, state: Resolution): string | null => { if (raw.type === 'TSFunctionType') - return state.substitutions.size === 0 - ? null - : resolve(raw.returnType, { ...state, insideFunction: true }); - if (raw.type === 'TSTypeLiteral' || raw.type === 'TSInterfaceBody') - return resolveMembers(raw, state); - if (raw.type === 'TSTypeReference' || raw.type === 'TSInterfaceHeritage') - return resolveReference(raw, state); + return state.substitutions.size === 0 ? null : resolve(raw.returnType, { ...state, insideFunction: true }); + if (raw.type === 'TSTypeLiteral' || raw.type === 'TSInterfaceBody') return resolveMembers(raw, state); + if (raw.type === 'TSTypeReference' || raw.type === 'TSInterfaceHeritage') return resolveReference(raw, state); return null; }; @@ -127,18 +122,12 @@ export function createPromisePortTypeResolver( if (raw.type === 'TSTypeAnnotation' || raw.type === 'TSParenthesizedType') return resolve(raw.typeAnnotation, state); if (raw.type === 'TSTypeParameter') return resolveFirst([raw.constraint, raw.default], state); - if (raw.type === 'TSUnionType' || raw.type === 'TSIntersectionType') - return resolveFirst(raw.types, state); + if (raw.type === 'TSUnionType' || raw.type === 'TSIntersectionType') return resolveFirst(raw.types, state); return resolveShape(raw, state); }; return function promiseReference( - annotation: - | ESTree.TSTypeAnnotation - | ESTree.TSTypeReference - | ESTree.TSInterfaceHeritage - | null - | undefined, + annotation: ESTree.TSTypeAnnotation | ESTree.TSTypeReference | ESTree.TSInterfaceHeritage | null | undefined, functionAliasOnly = false, ): string | null { return resolve(annotation, { diff --git a/app/tools/oxlint/effect-native/shared/options.ts b/app/tools/oxlint/effect-native/shared/options.ts index 88c50575d..2045a6fce 100644 --- a/app/tools/oxlint/effect-native/shared/options.ts +++ b/app/tools/oxlint/effect-native/shared/options.ts @@ -7,23 +7,17 @@ export function stringArray(value: unknown, fallback: readonly string[]): readon /** every-mode preserves the old stringList helper's treatment of sparse arrays. */ export function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? value - : fallback; + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') ? value : fallback; } export function optionRecord(value: unknown): Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; } export function booleanOption(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } export function positiveInteger(value: unknown, fallback: number, minimum = 1): number { - return typeof value === 'number' && Number.isInteger(value) && value >= minimum - ? value - : fallback; + return typeof value === 'number' && Number.isInteger(value) && value >= minimum ? value : fallback; } export function stringOption(value: unknown, fallback: string, allowEmpty = true): string { return typeof value === 'string' && (allowEmpty || value.length > 0) ? value : fallback; diff --git a/app/tools/oxlint/effect-native/shared/paths.ts b/app/tools/oxlint/effect-native/shared/paths.ts index 0c26fb5ee..b54042bb3 100644 --- a/app/tools/oxlint/effect-native/shared/paths.ts +++ b/app/tools/oxlint/effect-native/shared/paths.ts @@ -76,12 +76,9 @@ export function matchesGlobs(path: string, globs: readonly string[]): boolean { /** Strip fixture scaffolding first; never renormalize a relative script path around inner markers. */ export function scriptScope(filename: string): string { const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); + const fixture = unified.match(/(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u); if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//u, ''); + if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) return unified.replace(/^\.\//u, ''); const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); return match?.[1] ?? unified; } @@ -104,13 +101,10 @@ export function workspacePath( /** Fixture-first normalization with an explicit repository root, retaining nested markers. */ export function rootedScopePath(filename: string, root: string): string { const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); + const fixture = /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); if (fixture?.[1]) return fixture[1]; const normalizedRoot = root.replaceAll('\\', '/'); - return unified.startsWith(normalizedRoot) - ? unified.slice(normalizedRoot.length) - : scopePath(unified); + return unified.startsWith(normalizedRoot) ? unified.slice(normalizedRoot.length) : scopePath(unified); } /** Common source-rule policy; retain fixture-aware and legacy glob normalization. */ diff --git a/app/tools/oxlint/effect-native/shared/provenance.ts b/app/tools/oxlint/effect-native/shared/provenance.ts index 66a55daf7..7f89b5a55 100644 --- a/app/tools/oxlint/effect-native/shared/provenance.ts +++ b/app/tools/oxlint/effect-native/shared/provenance.ts @@ -1,4 +1,5 @@ import type { Context, ESTree, Variable } from '@oxlint/plugins'; + import { asNode, literalText, parentOf, propertyText, syntax, type Syntax } from './ast.ts'; import { lookupVariable } from './bindings.ts'; @@ -19,8 +20,7 @@ const CONTAINER_MEMBERS = new Set(['process', 'console', 'Bun']); const DEFAULT_MODULES = new Set(['process', 'console', 'util', 'module']); function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/u.test(source)) - return source.replace(/^node:/u, ''); + if (/^(?:node:)?(?:process|console|util|module)$/u.test(source)) return source.replace(/^node:/u, ''); if (source === 'effect/Effect') return 'Effect'; if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; return source; @@ -50,18 +50,13 @@ function importOrigin(definition: Variable['defs'][number]): string | null { return importedOrigin(spec, base); } function importedOrigin(spec: Syntax, base: string): string | null { - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; + if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') return base; const name = spec.imported?.name ?? spec.imported?.value; if (name === 'default') return base; return base === 'effect' ? name : `${base}.${name}`; } -function variableOrigin( - context: Context, - node: Syntax, - seen: ReadonlySet, -): string | null { +function variableOrigin(context: Context, node: Syntax, seen: ReadonlySet): string | null { const variable = lookupVariable(context, node); if (!variable || variable.defs.length === 0) return GLOBALS.has(node.name) ? node.name : null; if (seen.has(variable) || variable.defs.length !== 1) return null; @@ -78,8 +73,7 @@ function aliasOrigin( seen: ReadonlySet, ): string | null { if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return null; - if (variable.references.some((reference) => reference.init !== true && reference.isWrite())) - return null; + if (variable.references.some((reference) => reference.init !== true && reference.isWrite())) return null; const base = provenance(context, definition.node.init, seen); const path = destructuringPath(definition.node.id, name); return base !== null && path !== null ? [base, ...path].join('.') : null; @@ -107,11 +101,7 @@ function callOrigin(context: Context, node: Syntax, seen: ReadonlySet) /** Script runtime identity: imports, require/createRequire, globals, immutable aliases and destructuring. * Unlike Effect bindingPath, await is transparent and unwritten let aliases are accepted. */ -export function provenance( - context: Context, - input: unknown, - seen: ReadonlySet = new Set(), -): string | null { +export function provenance(context: Context, input: unknown, seen: ReadonlySet = new Set()): string | null { const node = syntax(input); if (!node) return null; switch (node.type) { @@ -145,8 +135,7 @@ const TS_VALUES = new Set([ ]); function nonReferenceName(node: Syntax, parent: Syntax): boolean { if (parent.type.startsWith('Import') || parent.type === 'ExportSpecifier') return true; - if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) - return true; + if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return true; if (LABEL_PARENTS.has(parent.type)) return true; return nonReferenceKey(node, parent); } @@ -162,16 +151,8 @@ function typePosition(node: Syntax, parent: Syntax): boolean { let child = node; let current: Syntax | null = parent; while (current) { - if ( - current.type.startsWith('TS') && - !(TS_VALUES.has(current.type) && current.expression === child) - ) - return true; - if ( - current.type.endsWith('Statement') || - current.type.endsWith('Declaration') || - current.type.includes('Function') - ) + if (current.type.startsWith('TS') && !(TS_VALUES.has(current.type) && current.expression === child)) return true; + if (current.type.endsWith('Statement') || current.type.endsWith('Declaration') || current.type.includes('Function')) break; child = current; current = parentOf(current); @@ -183,13 +164,14 @@ function typePosition(node: Syntax, parent: Syntax): boolean { export function valueReference(context: Context, input: unknown): boolean { const node = asNode(input); const parent = parentOf(node); - if (!node || !parent || nonReferenceName(node, parent) || typePosition(node, parent)) - return false; + if (!node || !parent || nonReferenceName(node, parent) || typePosition(node, parent)) return false; const variable = lookupVariable(context, node); return ( !variable || variable.references.some((reference) => { - const value = reference as typeof reference & { isValueReference?: () => boolean }; + const value = reference as typeof reference & { + isValueReference?: () => boolean; + }; return ( reference.identifier === node && reference.isRead() && diff --git a/app/tools/oxlint/effect-native/shared/reference-positions.ts b/app/tools/oxlint/effect-native/shared/reference-positions.ts index fbd20f888..9d8329d1b 100644 --- a/app/tools/oxlint/effect-native/shared/reference-positions.ts +++ b/app/tools/oxlint/effect-native/shared/reference-positions.ts @@ -1,4 +1,5 @@ import type { ESTree } from '@oxlint/plugins'; + import { asNode, parentOf, type Syntax } from './ast.ts'; const IMPORT_NAMES = new Set([ @@ -18,19 +19,12 @@ export interface ReferencePositionPolicy { /** Some legacy key tests use !== true rather than falsiness; the default retains falsiness. */ readonly strictComputed?: boolean; } -function isPropertyKey( - node: ESTree.Node, - parent: Syntax, - policy: ReferencePositionPolicy, -): boolean { +function isPropertyKey(node: ESTree.Node, parent: Syntax, policy: ReferencePositionPolicy): boolean { if (!(policy.keyParents ?? PROPERTY_KEYS).has(parent.type) || parent.key !== node) return false; return policy.strictComputed ? parent.computed !== true : !parent.computed; } /** Immediate-parent name/binding test only; type ancestry is a separate, explicitly configured test. */ -export function isNonReferencePosition( - node: ESTree.Node, - policy: ReferencePositionPolicy = {}, -): boolean { +export function isNonReferencePosition(node: ESTree.Node, policy: ReferencePositionPolicy = {}): boolean { const parent = parentOf(node); if (!parent) return policy.detached ?? true; if (IMPORT_NAMES.has(parent.type) || policy.nonReferenceParents?.has(parent.type)) return true; diff --git a/app/tools/oxlint/effect-native/shared/reporting.ts b/app/tools/oxlint/effect-native/shared/reporting.ts index b9245af58..4180280aa 100644 --- a/app/tools/oxlint/effect-native/shared/reporting.ts +++ b/app/tools/oxlint/effect-native/shared/reporting.ts @@ -1,19 +1,11 @@ import type { ESTree } from '@oxlint/plugins'; /** Independent threshold and slice length preserve the differing existing diagnostic budgets. */ -export function snippet( - text: string, - limit: number, - sliceLength = limit - 1, - ellipsis = '…', -): string { +export function snippet(text: string, limit: number, sliceLength = limit - 1, ellipsis = '…'): string { const flat = text.replace(/\s+/gu, ' ').trim(); return flat.length > limit ? `${flat.slice(0, sliceLength)}${ellipsis}` : flat; } -export function sameNode( - left: ESTree.Node | null | undefined, - right: ESTree.Node | null | undefined, -): boolean { +export function sameNode(left: ESTree.Node | null | undefined, right: ESTree.Node | null | undefined): boolean { if (!left || !right) return false; return left.type === right.type && left.start === right.start && left.end === right.end; } @@ -23,9 +15,7 @@ export function nodeKey(node: ESTree.Node, kindSeparator?: ':' | '@'): string { return kindSeparator === undefined ? span : `${node.type}${kindSeparator}${span}`; } /** Use node.start/end directly when null checking is not required. */ -export function spanOf( - node: ESTree.Node | null | undefined, -): { readonly start: number; readonly end: number } | null { +export function spanOf(node: ESTree.Node | null | undefined): { readonly start: number; readonly end: number } | null { if (!node || typeof node.start !== 'number' || typeof node.end !== 'number') return null; return { start: node.start, end: node.end }; } diff --git a/app/tools/oxlint/effect-native/shared/scaffold-text.ts b/app/tools/oxlint/effect-native/shared/scaffold-text.ts index d7ebf80cc..4c63a8cba 100644 --- a/app/tools/oxlint/effect-native/shared/scaffold-text.ts +++ b/app/tools/oxlint/effect-native/shared/scaffold-text.ts @@ -7,41 +7,27 @@ const MODULE_PARENTS = new Set([ 'ExportNamedDeclaration', 'ExportAllDeclaration', ]); -const DRIVER_BOUNDARIES = new Set([ - 'VariableDeclarator', - 'ReturnStatement', - 'TemplateLiteral', - 'Program', -]); +const DRIVER_BOUNDARIES = new Set(['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program']); /** Lexical masking keeps offsets/newlines; regex literals and dynamic fragments remain opaque. */ export function maskText(text: string, strings = false): string { return text.replace( /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`/gu, (value) => - value.startsWith('/') || strings - ? value.replace(/[^\r\n]+/gu, (segment) => ' '.repeat(segment.length)) - : value, + value.startsWith('/') || strings ? value.replace(/[^\r\n]+/gu, (segment) => ' '.repeat(segment.length)) : value, ); } function driverCallee(callee: ESTree.Node): boolean { if (callee.type === 'Identifier') - return /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/u.test( - callee.name, - ); - return ( - callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - callee.object.name === 'console' - ); + return /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/u.test(callee.name); + return callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === 'console'; } /** Excludes generator-driver prose/logging/shell arguments, not text emitted into source files. */ export function driverText(node: ESTree.Node): boolean { if (node.parent && MODULE_PARENTS.has(node.parent.type)) return true; let current = node.parent; while (current) { - if (current.type === 'CallExpression' || current.type === 'NewExpression') - return driverCallee(current.callee); + if (current.type === 'CallExpression' || current.type === 'NewExpression') return driverCallee(current.callee); if (DRIVER_BOUNDARIES.has(current.type)) return false; current = current.parent; } diff --git a/app/tools/oxlint/effect-native/shared/schema-identity.ts b/app/tools/oxlint/effect-native/shared/schema-identity.ts index 0e4aaa9fb..6bad0c617 100644 --- a/app/tools/oxlint/effect-native/shared/schema-identity.ts +++ b/app/tools/oxlint/effect-native/shared/schema-identity.ts @@ -1,4 +1,5 @@ import type { Context, ESTree, Variable } from '@oxlint/plugins'; + import type { StringOptions } from './ast.ts'; import { keyName, memberName, unwrapNode } from './ast.ts'; import { lookupVariable } from './bindings.ts'; @@ -12,21 +13,15 @@ function schemaMember(host: string | null, member: string | null): string | null if (host === '@schema') return member; return host === '@effect' && member === 'Schema' ? '@schema' : null; } -function submoduleIdentity( - specifier: ESTree.ImportDeclaration['specifiers'][number], -): string | null { +function submoduleIdentity(specifier: ESTree.ImportDeclaration['specifiers'][number]): string | null { if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; } function rootIdentity(specifier: ESTree.ImportDeclaration['specifiers'][number]): string | null { if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; - return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' - ? '@schema' - : null; + return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' ? '@schema' : null; } -function isImportSpecifier( - node: ESTree.Node, -): node is ESTree.ImportDeclaration['specifiers'][number] { +function isImportSpecifier(node: ESTree.Node): node is ESTree.ImportDeclaration['specifiers'][number] { return ( node.type === 'ImportSpecifier' || node.type === 'ImportNamespaceSpecifier' || @@ -44,16 +39,10 @@ function importIdentity(definition: Definition, reexports: readonly string[]): s return source === 'effect' || matchesGlobs(source, reexports) ? rootIdentity(specifier) : null; } export function constSchemaAlias(definition: Definition): ESTree.VariableDeclarator | null { - if ( - definition.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - definition.node.init === null - ) + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator' || definition.node.init === null) return null; const declarator = definition.node; - return declarator.parent?.type === 'VariableDeclaration' && declarator.parent.kind === 'const' - ? declarator - : null; + return declarator.parent?.type === 'VariableDeclaration' && declarator.parent.kind === 'const' ? declarator : null; } function destructuredSchemaIdentity( pattern: ESTree.ObjectPattern, @@ -61,12 +50,7 @@ function destructuredSchemaIdentity( host: string | null, ): string | null | undefined { for (const property of pattern.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; + if (property.type !== 'Property' || property.value.type !== 'Identifier' || property.value.name !== name) continue; const identity = schemaMember(host, keyName(property.key, property.computed)); // A matching schema property returns even an unknown key, preserving the original first match. if (host === '@schema' || identity !== null) return identity; @@ -89,8 +73,7 @@ function identifierIdentity( } const alias = constSchemaAlias(definition); if (!alias?.init) continue; - if (alias.id.type === 'Identifier') - return schemaIdentity(context, alias.init, reexports, depth + 1, syntax); + if (alias.id.type === 'Identifier') return schemaIdentity(context, alias.init, reexports, depth + 1, syntax); if (alias.id.type !== 'ObjectPattern') continue; const host = schemaIdentity(context, alias.init, reexports, depth + 1, syntax); const identity = destructuredSchemaIdentity(alias.id, node.name, host); @@ -113,11 +96,6 @@ export function schemaIdentity( if (depth > 16) return null; const node = unwrapNode(input, syntax.unwrap); if (node.type === 'MemberExpression') - return schemaMember( - schemaIdentity(context, node.object, reexports, depth + 1, syntax), - memberName(node, syntax), - ); - return node.type === 'Identifier' - ? identifierIdentity(context, node, reexports, depth, syntax) - : null; + return schemaMember(schemaIdentity(context, node.object, reexports, depth + 1, syntax), memberName(node, syntax)); + return node.type === 'Identifier' ? identifierIdentity(context, node, reexports, depth, syntax) : null; } diff --git a/app/tools/oxlint/effect-native/shared/script-entry.ts b/app/tools/oxlint/effect-native/shared/script-entry.ts index a2773aa95..2d1ca84db 100644 --- a/app/tools/oxlint/effect-native/shared/script-entry.ts +++ b/app/tools/oxlint/effect-native/shared/script-entry.ts @@ -1,11 +1,6 @@ import type { Context, ESTree } from '@oxlint/plugins'; -import { - FUNCTION_TYPES, - nearestFunction as nearest, - parentOf, - skipWrappers, - type Syntax, -} from './ast.ts'; + +import { FUNCTION_TYPES, nearestFunction as nearest, parentOf, skipWrappers, type Syntax } from './ast.ts'; import { resolveVariable } from './bindings.ts'; const ENTRY_FUNCTION_TYPES = new Set([...FUNCTION_TYPES, 'StaticBlock']); @@ -20,21 +15,17 @@ export function isTopLevel(node: ESTree.Node): boolean { function isProgramLevelStatement(node: ESTree.Node): boolean { const parent = parentOf(node); if (parent?.type === 'Program') return true; - if (parent?.type !== 'ExportNamedDeclaration' && parent?.type !== 'ExportDefaultDeclaration') - return false; + if (parent?.type !== 'ExportNamedDeclaration' && parent?.type !== 'ExportDefaultDeclaration') return false; return parentOf(parent)?.type === 'Program'; } function programDeclarator(fn: ESTree.Node): ESTree.VariableDeclarator | null { const declarator = parentOf(fn); if (declarator?.type !== 'VariableDeclarator' || declarator.init !== fn) return null; const declaration = parentOf(declarator); - return declaration?.type === 'VariableDeclaration' && isProgramLevelStatement(declaration) - ? declarator - : null; + return declaration?.type === 'VariableDeclaration' && isProgramLevelStatement(declaration) ? declarator : null; } export function programLevelFunctionName(fn: ESTree.Node): string | null { - if (fn.type === 'FunctionDeclaration') - return isProgramLevelStatement(fn) ? (fn.id?.name ?? null) : null; + if (fn.type === 'FunctionDeclaration') return isProgramLevelStatement(fn) ? (fn.id?.name ?? null) : null; if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null; const declarator = programDeclarator(fn); return declarator?.id.type === 'Identifier' ? declarator.id.name : null; @@ -50,9 +41,7 @@ function isOnlyCalledFromTopLevel(context: Context, fn: ESTree.Node, name: strin const uses = variable.references.filter( (reference) => reference.init !== true && !offsets.has(reference.identifier.start), ); - return ( - uses.length > 0 && uses.every((reference) => isTopLevelImmediatelyInvoked(reference.identifier)) - ); + return uses.length > 0 && uses.every((reference) => isTopLevelImmediatelyInvoked(reference.identifier)); } /** Module evaluation, top-level IIFEs, or named Program functions used only by top-level calls. */ export function isEntryPosition(context: Context, site: ESTree.Node): boolean { diff --git a/app/tools/oxlint/effect-native/shared/test-restricted-imports.ts b/app/tools/oxlint/effect-native/shared/test-restricted-imports.ts index 710bf9771..b4c4a5765 100644 --- a/app/tools/oxlint/effect-native/shared/test-restricted-imports.ts +++ b/app/tools/oxlint/effect-native/shared/test-restricted-imports.ts @@ -1,6 +1,9 @@ /** Test APIs must use the Effect-native runner in both lint entrypoints. */ export const testRestrictedImports = [ - { message: 'Import test APIs from effect-rstest instead.', name: 'node:test' }, + { + message: 'Import test APIs from effect-rstest instead.', + name: 'node:test', + }, { message: 'Import assertions from effect-rstest instead.', name: 'node:assert', diff --git a/app/tools/oxlint/effect-native/tests/discover-rules.test.mts b/app/tools/oxlint/effect-native/tests/discover-rules.test.mts index 275136757..a0158f5ab 100644 --- a/app/tools/oxlint/effect-native/tests/discover-rules.test.mts +++ b/app/tools/oxlint/effect-native/tests/discover-rules.test.mts @@ -3,8 +3,8 @@ import { copyFileSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } fro import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { expect, it } from 'effect-rstest'; import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; import { discoverRules } from '../shared/discover-rules.ts'; import { pluginDirectory } from './oxlint.mts'; @@ -31,10 +31,7 @@ it('rule discovery uses file URLs in workspaces containing spaces, URL delimiter mkdirSync(shared, { recursive: true }); mkdirSync(rules, { recursive: true }); writeFileSync(path.join(workspace, 'package.json'), JSON.stringify({ type: 'module' })); - copyFileSync( - path.join(pluginDirectory, 'shared', discoveryFile), - path.join(shared, discoveryFile), - ); + copyFileSync(path.join(pluginDirectory, 'shared', discoveryFile), path.join(shared, discoveryFile)); writeFileSync(path.join(rules, selectedFile), 'export const rule = { marker: "selected" };'); writeFileSync( path.join(rules, 'unselected.ts'), diff --git a/app/tools/oxlint/effect-native/tests/fixtures.test.mts b/app/tools/oxlint/effect-native/tests/fixtures.test.mts index a8e88e2fd..c9d6bcb78 100644 --- a/app/tools/oxlint/effect-native/tests/fixtures.test.mts +++ b/app/tools/oxlint/effect-native/tests/fixtures.test.mts @@ -3,13 +3,7 @@ import path from 'node:path'; import { expect, it } from 'effect-rstest'; -import { - fixtureConfigPath, - fixturesDirectory, - listFilesRecursively, - listFixtureRules, - runOxlint, -} from './oxlint.mts'; +import { fixtureConfigPath, fixturesDirectory, listFilesRecursively, listFixtureRules, runOxlint } from './oxlint.mts'; const onlyRule = process.env.RULE; const rules = listFixtureRules().filter((rule) => onlyRule === undefined || rule === onlyRule); @@ -46,8 +40,7 @@ const fixtureFailures = ( for (const file of invalid) { const key = path.relative(fixtureDirectory, file).replaceAll('\\', '/'); const count = byFile.get(key) ?? 0; - const expected = /^\/\/\s*expect-count:\s*(?\d+)/u.exec(readFileSync(file, 'utf-8')) - ?.groups?.count; + const expected = /^\/\/\s*expect-count:\s*(?\d+)/u.exec(readFileSync(file, 'utf-8'))?.groups?.count; if (expected !== undefined) { if (Number(expected) <= 0 || count !== Number(expected)) { failures.push(`${key} expected ${expected} positive diagnostics, got ${count}`); @@ -74,19 +67,14 @@ for (const rule of rules) { const code = `effect-native(${rule})`; const byFile = new Map(); for (const diagnostic of run.diagnostics) { - expect( - diagnostic.code, - `unexpected diagnostic ${diagnostic.code} in ${diagnostic.filename}`, - ).toBe(code); + expect(diagnostic.code, `unexpected diagnostic ${diagnostic.code} in ${diagnostic.filename}`).toBe(code); const key = diagnostic.filename.replaceAll('\\', '/'); byFile.set(key, (byFile.get(key) ?? 0) + 1); } expect(invalid.length, `${rule}: add at least one file under invalid/`).toBeGreaterThan(0); expect(valid.length, `${rule}: add at least one file under valid/`).toBeGreaterThan(0); expect(run.exitCode, `${rule}: invalid fixtures must make Oxlint fail`).toBe(1); - expect(run.numberOfFiles, `${rule}: not every fixture was linted`).toBe( - invalid.length + valid.length, - ); + expect(run.numberOfFiles, `${rule}: not every fixture was linted`).toBe(invalid.length + valid.length); const failures = fixtureFailures(fixtureDirectory, invalid, valid, byFile); expect(failures, `${rule}:\n${failures.join('\n')}`).toStrictEqual([]); }); diff --git a/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts b/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts index d22b6c5b8..d3330f480 100644 --- a/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts +++ b/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts @@ -16,9 +16,7 @@ it('JSON rule scope preserves defaults, overrides, and exclusions', () => { expect(inJsonRuleScope(sourceFile, { allowPaths: ['apps/**'] }, includePaths)).toBe(false); expect(inJsonRuleScope(sourceFile, { includePaths: [] }, includePaths)).toBe(true); expect(inJsonRuleScope(sourceFile, { includePaths: [1] }, includePaths)).toBe(true); - expect( - inJsonRuleScope('packages/example/main.ts', { includePaths: ['packages/**'] }, includePaths), - ).toBe(true); + expect(inJsonRuleScope('packages/example/main.ts', { includePaths: ['packages/**'] }, includePaths)).toBe(true); expect(inJsonRuleScope(testFile, { ignoreTestFiles: 'false' }, includePaths)).toBe(false); }); diff --git a/app/tools/oxlint/effect-native/tests/launcher.test.mts b/app/tools/oxlint/effect-native/tests/launcher.test.mts index db1da5ef5..be9944c74 100644 --- a/app/tools/oxlint/effect-native/tests/launcher.test.mts +++ b/app/tools/oxlint/effect-native/tests/launcher.test.mts @@ -1,9 +1,10 @@ -import { expect, it, rstest } from 'effect-rstest'; -import { Schema } from 'effect'; import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; -import nodePath from 'node:path'; import { createRequire } from 'node:module'; +import nodePath from 'node:path'; + +import { Schema } from 'effect'; +import { expect, it, rstest } from 'effect-rstest'; import { appRoot, runOxlint } from './oxlint.mts'; import { withTemporaryWorkspace } from './temporary-workspace.mts'; @@ -38,10 +39,7 @@ it('Oxlint launches its JavaScript entry point through Node without a platform s throw new TypeError('Expected spawn arguments array'); } expect(args[1][0]).toBe( - nodePath.join( - nodePath.dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), - 'bin/oxlint', - ), + nodePath.join(nodePath.dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), 'bin/oxlint'), ); expect(args[1].includes(input)).toBe(true); expect(args[1].includes(config)).toBe(true); @@ -52,9 +50,7 @@ it('Oxlint launches its JavaScript entry point through Node without a platform s }); it('lint and lint:fix cover the same directories without changing reporting-only commands', () => { - const { scripts } = decodePackageScripts( - readFileSync(nodePath.join(appRoot, 'package.json'), 'utf-8'), - ); + const { scripts } = decodePackageScripts(readFileSync(nodePath.join(appRoot, 'package.json'), 'utf-8')); expect(scripts.lint).toBeDefined(); expect(scripts['lint:fix']).toBeDefined(); const lint = (scripts.lint ?? '').split(/\s+/u); @@ -63,8 +59,6 @@ it('lint and lint:fix cover the same directories without changing reporting-only expect(fix.filter((argument) => argument === '--fix').length).toBe(1); expect(lint.includes('scripts')).toBe(true); for (const name of ['lint', 'lint:effect', 'test:lint-rules', 'check']) { - expect(!(scripts[name] ?? '').includes('--fix'), `${name} must remain reporting-only`).toBe( - true, - ); + expect(!(scripts[name] ?? '').includes('--fix'), `${name} must remain reporting-only`).toBe(true); } }); diff --git a/app/tools/oxlint/effect-native/tests/native-sorting-plugins.test.mts b/app/tools/oxlint/effect-native/tests/native-sorting-plugins.test.mts new file mode 100644 index 000000000..49ec0d18e --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/native-sorting-plugins.test.mts @@ -0,0 +1,112 @@ +import { writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { it, expect } from 'effect-rstest'; + +import { appRoot, runOxlint } from './oxlint.mts'; +import { withTemporaryWorkspace } from './temporary-workspace.mts'; + +const applicationRequire = createRequire(path.join(appRoot, 'package.json')); +const plugin = applicationRequire.resolve('eslint-plugin-perfectionist'); +const cases = [ + { + invalid: 'enum Status { Alpha = 20, Zulu = 1 }', + options: { partitionByComment: true, sortByValue: 'always' }, + rule: 'sort-enums', + valid: 'enum Status { Zulu = 1, Alpha = 20 }', + }, + { + invalid: 'interface View extends Zebra, Alpha {}', + rule: 'sort-heritage-clauses', + valid: 'interface View extends Alpha, Zebra {}', + }, + { + invalid: 'interface View { zebra: string; alpha: string }', + rule: 'sort-interfaces', + valid: 'interface View { alpha: string; zebra: string }', + }, + { + invalid: 'const view = ;', + rule: 'sort-jsx-props', + valid: 'const view = ;', + }, + { + invalid: 'type View = { zebra: string; alpha: string };', + rule: 'sort-object-types', + valid: 'type View = { alpha: string; zebra: string };', + }, + { + invalid: 'const view = { zebra: 1, alpha: 2 };', + options: { partitionByComment: true }, + rule: 'sort-objects', + valid: 'const view = { alpha: 2, zebra: 1 };', + }, +]; + +it('native sorting integration does not resolve the ESLint runner', () => { + expect(() => applicationRequire.resolve('eslint')).toThrow(/Cannot find module 'eslint'/u); +}); + +for (const fixture of cases) { + it(`Oxlint executes ${fixture.rule} positives and negatives without ESLint`, () => { + withTemporaryWorkspace((directory) => { + const config = path.join(directory, 'oxlint.json'); + writeFileSync( + config, + JSON.stringify({ + // Isolate each actual plugin rule; the application rule configuration is untouched. + categories: { correctness: 'off' }, + jsPlugins: [{ name: 'perfectionist', specifier: plugin }], + rules: { + [`perfectionist/${fixture.rule}`]: ['error', fixture.options ?? {}], + }, + }), + ); + const source = path.join(directory, 'fixture.tsx'); + writeFileSync(source, fixture.invalid); + const negative = runOxlint(config, [source], directory); + expect(negative.exitCode).toBe(1); + expect(negative.diagnostics.some(({ code }) => code === `perfectionist(${fixture.rule})`)).toBeTruthy(); + writeFileSync(source, fixture.valid); + const positive = runOxlint(config, [source], directory); + expect(positive.exitCode, JSON.stringify(positive.diagnostics)).toBe(0); + expect(positive.diagnostics).toEqual([]); + }); + }); +} + +it('native enum and object sorting preserves explicit comment partitions', () => { + withTemporaryWorkspace((directory) => { + const config = path.join(directory, 'oxlint.json'); + writeFileSync( + config, + JSON.stringify({ + categories: { correctness: 'off' }, + jsPlugins: [{ name: 'perfectionist', specifier: plugin }], + rules: { + 'perfectionist/sort-enums': ['error', { partitionByComment: true, sortByValue: 'always' }], + 'perfectionist/sort-objects': ['error', { partitionByComment: true }], + }, + }), + ); + const source = path.join(directory, 'fixture.ts'); + writeFileSync( + source, + `enum Status { + Alpha = 20, + // separate partition + Zulu = 1, +} +const value = { + zebra: 1, + // separate partition + alpha: 2, +}; +`, + ); + const result = runOxlint(config, [source], directory); + expect(result.exitCode, JSON.stringify(result.diagnostics)).toBe(0); + expect(result.diagnostics).toEqual([]); + }); +}); diff --git a/app/tools/oxlint/effect-native/tests/oxlint.mts b/app/tools/oxlint/effect-native/tests/oxlint.mts index fe4fbd872..cc285a765 100644 --- a/app/tools/oxlint/effect-native/tests/oxlint.mts +++ b/app/tools/oxlint/effect-native/tests/oxlint.mts @@ -9,10 +9,7 @@ export const pluginDirectory = resolve(testsDirectory, '..'); export const appRoot = resolve(pluginDirectory, '..', '..', '..'); export const fixturesDirectory = join(testsDirectory, 'fixtures'); // Rstest bundles this harness through Rspack, which has no `import.meta.resolve`. -const oxlintEntryPoint = join( - dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), - 'bin/oxlint', -); +const oxlintEntryPoint = join(dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), 'bin/oxlint'); interface Diagnostic { readonly code: string; @@ -87,17 +84,9 @@ export function parseOxlintOutput(stdout: string, stderr: string, status: number } /** Run oxlint with a fixture config against the given paths (relative to `cwd`). */ -export function runOxlint( - configPath: string, - paths: readonly string[], - cwd: string, - selectedRule?: string, -): LintRun { +export function runOxlint(configPath: string, paths: readonly string[], cwd: string, selectedRule?: string): LintRun { const fixtureRule = - selectedRule ?? - (basename(dirname(dirname(configPath))) === 'fixtures' - ? basename(dirname(configPath)) - : undefined); + selectedRule ?? (basename(dirname(dirname(configPath))) === 'fixtures' ? basename(dirname(configPath)) : undefined); const result = spawnSync( process.execPath, [oxlintEntryPoint, '-c', configPath, '--format=json', '--disable-nested-config', ...paths], diff --git a/app/tools/oxlint/effect-native/tests/oxlint.test.mts b/app/tools/oxlint/effect-native/tests/oxlint.test.mts index b8aa8acbf..5ecdcc565 100644 --- a/app/tools/oxlint/effect-native/tests/oxlint.test.mts +++ b/app/tools/oxlint/effect-native/tests/oxlint.test.mts @@ -9,8 +9,7 @@ const diagnostic = { message: 'Example violation', severity: 'error', }; -const report = (diagnostics: unknown[] = [], files = 1) => - JSON.stringify({ diagnostics, number_of_files: files }); +const report = (diagnostics: unknown[] = [], files = 1) => JSON.stringify({ diagnostics, number_of_files: files }); it('accepts a successful clean lint run', () => { const run = parseOxlintOutput(report(), '', 0); @@ -26,25 +25,13 @@ it('accepts actual lint failures as diagnostics, not a loader crash', () => { }); it('rejects loader failures on stdout, including a JSON-looking suffix', () => { - for (const stdout of [ - 'Failed to load plugin', - `Failed to load plugin\n${report()}`, - '', - '{bad', - 'null', - ]) { + for (const stdout of ['Failed to load plugin', `Failed to load plugin\n${report()}`, '', '{bad', 'null']) { expect(() => parseOxlintOutput(stdout, '', 1)).toThrow(); } }); it('rejects empty-file runs and missing report fields', () => { - for (const stdout of [ - report([], 0), - report([], -1), - report([], 1.5), - '{}', - '{"diagnostics":[]}', - ]) { + for (const stdout of [report([], 0), report([], -1), report([], 1.5), '{}', '{"diagnostics":[]}']) { expect(() => parseOxlintOutput(stdout, '', 0)).toThrow(/incomplete or empty-file/u); } }); @@ -58,12 +45,7 @@ it('rejects crashes, stderr failures, and inconsistent exit statuses', () => { }); it('rejects malformed diagnostics rather than hiding them', () => { - for (const entry of [ - null, - {}, - { ...diagnostic, severity: 'unknown' }, - { ...diagnostic, labels: null }, - ]) { + for (const entry of [null, {}, { ...diagnostic, severity: 'unknown' }, { ...diagnostic, labels: null }]) { expect(() => parseOxlintOutput(report([entry]), '', 1)).toThrow(/malformed diagnostic/u); } }); diff --git a/app/tools/oxlint/effect-native/tests/paths.test.mts b/app/tools/oxlint/effect-native/tests/paths.test.mts index 1d0c128ce..ca203e27a 100644 --- a/app/tools/oxlint/effect-native/tests/paths.test.mts +++ b/app/tools/oxlint/effect-native/tests/paths.test.mts @@ -36,11 +36,7 @@ it('script classification requires a complete scripts directory segment', () => 'packages/core-runtime/scripts-backup/verify.mts', 'packages/core-runtime/scripts', ]) { - for (const filename of [ - path, - `/workspace/app/${path}`, - `C:\\workspace\\app\\${path.replaceAll('/', '\\')}`, - ]) { + for (const filename of [path, `/workspace/app/${path}`, `C:\\workspace\\app\\${path.replaceAll('/', '\\')}`]) { expect(isScriptFile(filename), filename).toBe(false); expect(isScriptFile(normalisePath(filename)), filename).toBe(false); } diff --git a/app/tools/oxlint/effect-native/tests/production-fixture.config.ts b/app/tools/oxlint/effect-native/tests/production-fixture.config.ts index 1572a6d71..0551ff502 100644 --- a/app/tools/oxlint/effect-native/tests/production-fixture.config.ts +++ b/app/tools/oxlint/effect-native/tests/production-fixture.config.ts @@ -1,6 +1,4 @@ -const { default: config } = await import( - new URL('../../../../oxlint.config.ts', import.meta.url).href -); +const { default: config } = await import(new URL('../../../../oxlint.config.ts', import.meta.url).href); const name = process.env.EFFECT_NATIVE_FIXTURE_RULE; if (!name) throw new Error('Production fixture config requires an explicit rule name'); const key = `effect-native/${name}`; diff --git a/app/tools/oxlint/effect-native/tests/production-options.test.mts b/app/tools/oxlint/effect-native/tests/production-options.test.mts index 625d97c72..7ee282388 100644 --- a/app/tools/oxlint/effect-native/tests/production-options.test.mts +++ b/app/tools/oxlint/effect-native/tests/production-options.test.mts @@ -1,26 +1,19 @@ -import { expect, it } from 'effect-rstest'; -import { Schema } from 'effect'; import { cpSync, readFileSync } from 'node:fs'; import nodePath from 'node:path'; +import { Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { listRuleNames } from '../shared/discover-rules.ts'; import { globToRegExp } from '../shared/paths.ts'; -import { - fixtureConfigPath, - fixturesDirectory, - listFilesRecursively, - runOxlint, - testsDirectory, -} from './oxlint.mts'; +import { fixtureConfigPath, fixturesDirectory, listFilesRecursively, runOxlint, testsDirectory } from './oxlint.mts'; import { withTemporaryWorkspace } from './temporary-workspace.mts'; const RuleSetting = Schema.Union([Schema.String, Schema.Array(Schema.Unknown)]); const RuleMap = Schema.Record(Schema.String, RuleSetting); const FixtureConfig = Schema.fromJsonString( Schema.Struct({ - overrides: Schema.optional( - Schema.Array(Schema.Struct({ files: Schema.Array(Schema.String), rules: RuleMap })), - ), + overrides: Schema.optional(Schema.Array(Schema.Struct({ files: Schema.Array(Schema.String), rules: RuleMap }))), rules: RuleMap, }), ); @@ -35,15 +28,8 @@ for (const rule of listRuleNames()) { recursive: true, }); } - const paths = listFilesRecursively(directory).map((file) => - nodePath.relative(directory, file), - ); - const run = runOxlint( - nodePath.join(testsDirectory, 'production-fixture.config.ts'), - paths, - directory, - rule, - ); + const paths = listFilesRecursively(directory).map((file) => nodePath.relative(directory, file)); + const run = runOxlint(nodePath.join(testsDirectory, 'production-fixture.config.ts'), paths, directory, rule); expect(run.numberOfFiles, `${rule}: production run skipped fixture files`).toBe(paths.length); expect(run.exitCode, `${rule}: production defaults must have a positive fixture`).toBe(1); expect( @@ -56,8 +42,7 @@ for (const rule of listRuleNames()) { expect( run.diagnostics.filter( (diagnostic) => - diagnostic.filename.startsWith('valid/') && - diagnostic.filename.endsWith('/production-default.ts'), + diagnostic.filename.startsWith('valid/') && diagnostic.filename.endsWith('/production-default.ts'), ), `${rule}: explicit default negative reported`, ).toEqual([]); @@ -67,13 +52,11 @@ for (const rule of listRuleNames()) { // Non-default option fixtures remain owned by the ordinary fixture suite. const usesOverride = (file: string): boolean => fixture.overrides?.some( - (override) => - key in override.rules && override.files.some((glob) => globToRegExp(glob).test(file)), + (override) => key in override.rules && override.files.some((glob) => globToRegExp(glob).test(file)), ) ?? false; expect( run.diagnostics.filter( - (diagnostic) => - diagnostic.filename.startsWith('valid/') && !usesOverride(diagnostic.filename), + (diagnostic) => diagnostic.filename.startsWith('valid/') && !usesOverride(diagnostic.filename), ), `${rule}: production false positive`, ).toEqual([]); diff --git a/app/tools/oxlint/effect-native/tests/registration.test.mts b/app/tools/oxlint/effect-native/tests/registration.test.mts index fda91e4f0..52fbd6498 100644 --- a/app/tools/oxlint/effect-native/tests/registration.test.mts +++ b/app/tools/oxlint/effect-native/tests/registration.test.mts @@ -1,9 +1,10 @@ -import { expect, it } from 'effect-rstest'; -import { Schema } from 'effect'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import nodePath from 'node:path'; import { pathToFileURL } from 'node:url'; +import { Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import plugin from '../index.ts'; import { listRuleNames } from '../shared/discover-rules.ts'; import { appRoot, listFixtureRules, pluginDirectory, runOxlint } from './oxlint.mts'; @@ -38,7 +39,10 @@ const FixtureConfig = Schema.fromJsonString( rules: Schema.Record(Schema.String, RuleSetting), }), ); -const NamedPluginEntry = Schema.Struct({ name: Schema.String, specifier: Schema.String }); +const NamedPluginEntry = Schema.Struct({ + name: Schema.String, + specifier: Schema.String, +}); const isNamedPluginEntry = Schema.is(NamedPluginEntry); const decodeFixtureConfig = Schema.decodeUnknownSync(FixtureConfig); const { default: config } = Schema.decodeUnknownSync(ProductionConfigModule)( @@ -51,9 +55,7 @@ it('every rule is actually exported, enabled at error severity, and covered by f expect(rules.length > 0, 'the plugin cannot be empty').toBe(true); expect(Object.keys(plugin.rules).toSorted()).toEqual(rules); expect([...listFixtureRules()].toSorted()).toEqual(rules); - const configured = Object.keys(configuredRules).filter((name) => - name.startsWith('effect-native/'), - ); + const configured = Object.keys(configuredRules).filter((name) => name.startsWith('effect-native/')); expect(configured.toSorted()).toEqual(rules.map((name) => `effect-native/${name}`)); for (const rule of rules) { const setting = configuredRules[`effect-native/${rule}`]; @@ -83,35 +85,25 @@ it('every rule is reporting-only and declares diagnostic metadata', () => { } expect(Object.keys(rule.meta.messages ?? {}).length > 0, `${name} needs messages`).toBe(true); expect(rule.meta.fixable, `${name} must not advertise fixes`).toBe(undefined); - expect(!(rule.meta.hasSuggestions ?? false), `${name} must not advertise suggestions`).toBe( - true, - ); + expect(!(rule.meta.hasSuggestions ?? false), `${name} must not advertise suggestions`).toBe(true); } }); it('fixture configs enable only their owned rule without file-ignore shortcuts', () => { for (const rule of rules) { const fixture = decodeFixtureConfig( - readFileSync( - nodePath.join(pluginDirectory, 'tests', 'fixtures', rule, '.oxlintrc.json'), - 'utf-8', - ), + readFileSync(nodePath.join(pluginDirectory, 'tests', 'fixtures', rule, '.oxlintrc.json'), 'utf-8'), ); expect(Object.keys(fixture.rules)).toEqual([`effect-native/${rule}`]); const setting = fixture.rules[`effect-native/${rule}`]; expect(Array.isArray(setting) ? setting[0] : setting).toBe('error'); - expect( - (fixture.ignorePatterns ?? []).length === 0, - `${rule} must exercise fixtures, not ignore them`, - ).toBe(true); + expect((fixture.ignorePatterns ?? []).length === 0, `${rule} must exercise fixtures, not ignore them`).toBe(true); } }); it('production import policy rejects node:test in application tests but not e2e adapters', () => { withTemporaryWorkspace((directory) => { - const overrides = config.overrides.filter( - (override) => 'eslint/no-restricted-imports' in override.rules, - ); + const overrides = config.overrides.filter((override) => 'eslint/no-restricted-imports' in override.rules); expect(overrides.length).toBe(1); const configPath = nodePath.join(directory, '.oxlintrc.json'); writeFileSync(configPath, JSON.stringify({ categories: { correctness: 'off' }, overrides })); diff --git a/app/tools/oxlint/effect-native/tests/repository-policy.test.mts b/app/tools/oxlint/effect-native/tests/repository-policy.test.mts index 8f3023fc9..fb7b7fe33 100644 --- a/app/tools/oxlint/effect-native/tests/repository-policy.test.mts +++ b/app/tools/oxlint/effect-native/tests/repository-policy.test.mts @@ -1,5 +1,7 @@ -import { expect, it } from 'effect-rstest'; import nodePath from 'node:path'; + +import { expect, it } from 'effect-rstest'; + import { appRoot, pluginDirectory, runOxlint } from './oxlint.mts'; it('all repository source, including tools and root configuration, follows the Effect discrimination policy', () => { @@ -8,10 +10,8 @@ it('all repository source, including tools and root configuration, follows the E ['.', '--ignore-pattern', 'tools/oxlint/**/tests/fixtures/**'], appRoot, ); - expect( - run.diagnostics.map( - ({ code, filename, labels }) => `${filename}:${labels[0]?.span.line} ${code}`, - ), - ).toEqual([]); + expect(run.diagnostics.map(({ code, filename, labels }) => `${filename}:${labels[0]?.span.line} ${code}`)).toEqual( + [], + ); expect(run.exitCode).toBe(0); }); diff --git a/app/tools/oxlint/effect-native/tests/run-on-repo.mts b/app/tools/oxlint/effect-native/tests/run-on-repo.mts index dfeaa4363..54b7495da 100644 --- a/app/tools/oxlint/effect-native/tests/run-on-repo.mts +++ b/app/tools/oxlint/effect-native/tests/run-on-repo.mts @@ -10,30 +10,20 @@ if (!rule) { process.exit(2); } const showAll = flags.includes('--all'); -if (flags.some((flag) => flag !== '--all' && !/^--limit=\d+$/u.test(flag))) - throw new Error('Unknown report flag'); -const limit = Number( - flags.find((flag) => flag.startsWith('--limit='))?.slice('--limit='.length) ?? 40, -); -if (!Number.isSafeInteger(limit) || limit < 1) - throw new Error('--limit must be a positive integer'); -const run = runOxlint( - fixtureConfigPath(rule), - ['apps', 'verticals', 'packages', 'scripts'], - appRoot, -); +if (flags.some((flag) => flag !== '--all' && !/^--limit=\d+$/u.test(flag))) throw new Error('Unknown report flag'); +const limit = Number(flags.find((flag) => flag.startsWith('--limit='))?.slice('--limit='.length) ?? 40); +if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('--limit must be a positive integer'); +const run = runOxlint(fixtureConfigPath(rule), ['apps', 'verticals', 'packages', 'scripts'], appRoot); if (run.stderr.trim()) console.error(run.stderr.trim()); for (const diagnostic of run.diagnostics) { - if (diagnostic.code !== `effect-native(${rule})`) - throw new Error(`Unexpected diagnostic: ${diagnostic.code}`); + if (diagnostic.code !== `effect-native(${rule})`) throw new Error(`Unexpected diagnostic: ${diagnostic.code}`); } const hits = run.diagnostics; const perFile = new Map(); for (const hit of hits) perFile.set(hit.filename, (perFile.get(hit.filename) ?? 0) + 1); const groups = { scripts: 0, src: 0, tests: 0 }; for (const [file, count] of perFile) { - if (/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(file)) - groups.tests += count; + if (/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(file)) groups.tests += count; else if (/(?:^|\/)scripts\//u.test(file)) groups.scripts += count; else groups.src += count; } diff --git a/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts b/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts index 2a766ff3d..30bebc120 100644 --- a/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts +++ b/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts @@ -27,10 +27,7 @@ it('manual configuration rule detects access after supplementary Unicode but ign const positive = 'scripts/scaffolding/unicode-positive.mts'; const negative = 'scripts/scaffolding/unicode-negative.mts'; const prefix = `const label="${supplementaryCharacter.repeat(20)}"; `; - writeFileSync( - path.join(directory, positive), - `export const source = \`${prefix}process.env.X${' '.repeat(30)}\`;`, - ); + writeFileSync(path.join(directory, positive), `export const source = \`${prefix}process.env.X${' '.repeat(30)}\`;`); writeFileSync( path.join(directory, negative), `export const source = \`${prefix}const example = "process.env.X";\`;`, @@ -51,18 +48,11 @@ it('manual configuration rule detects access after supplementary Unicode but ign }, }), ); - const result = runOxlint( - config, - [positive, negative], - directory, - 'no-manual-config-in-scaffold-templates', - ); + const result = runOxlint(config, [positive, negative], directory, 'no-manual-config-in-scaffold-templates'); expect(result.numberOfFiles).toBe(2); expect(result.exitCode).toBe(1); expect(result.diagnostics.length).toBe(1); - expect(result.diagnostics[0]?.code).toBe( - 'effect-native(no-manual-config-in-scaffold-templates)', - ); + expect(result.diagnostics[0]?.code).toBe('effect-native(no-manual-config-in-scaffold-templates)'); expect(result.diagnostics[0]?.filename.replaceAll('\\', '/')).toBe(positive); }); }); diff --git a/app/tools/oxlint/effect-native/tests/script-scope.test.mts b/app/tools/oxlint/effect-native/tests/script-scope.test.mts index 5e4b892ae..d47591cc9 100644 --- a/app/tools/oxlint/effect-native/tests/script-scope.test.mts +++ b/app/tools/oxlint/effect-native/tests/script-scope.test.mts @@ -54,9 +54,7 @@ for (const { rule, source } of cases) { }, ], rules: { - [`effect-native/${rule}`]: includeScripts - ? ['error', { includeScripts: true }] - : 'error', + [`effect-native/${rule}`]: includeScripts ? ['error', { includeScripts: true }] : 'error', }, }), ); @@ -65,9 +63,7 @@ for (const { rule, source } of cases) { config, pathMode === 'relative' ? paths - : paths.map((path) => - nodePath.join(pathMode === 'symlink' ? alias : directory, path), - ), + : paths.map((path) => nodePath.join(pathMode === 'symlink' ? alias : directory, path)), directory, rule, ); @@ -79,18 +75,11 @@ for (const { rule, source } of cases) { const reported = [ ...new Set( // Oxlint may retain absolute spellings when input paths cross a symlink. - run.diagnostics.map((diagnostic) => - realpathSync(nodePath.resolve(directory, diagnostic.filename)), - ), + run.diagnostics.map((diagnostic) => realpathSync(nodePath.resolve(directory, diagnostic.filename))), ), ]; - expect( - reported.toSorted(), - `${rule}: includeScripts=${includeScripts}, pathMode=${pathMode}`, - ).toEqual( - (includeScripts ? paths : sources) - .map((path) => realpathSync(nodePath.join(directory, path))) - .toSorted(), + expect(reported.toSorted(), `${rule}: includeScripts=${includeScripts}, pathMode=${pathMode}`).toEqual( + (includeScripts ? paths : sources).map((path) => realpathSync(nodePath.join(directory, path))).toSorted(), ); } } diff --git a/app/tools/oxlint/effect-native/tests/shared-helpers-probe.ts b/app/tools/oxlint/effect-native/tests/shared-helpers-probe.ts index 358a7a30e..9b33d1812 100644 --- a/app/tools/oxlint/effect-native/tests/shared-helpers-probe.ts +++ b/app/tools/oxlint/effect-native/tests/shared-helpers-probe.ts @@ -7,17 +7,9 @@ import type { Syntax } from '../shared/ast.ts'; import { isUnshadowedGlobal, resolvesToImport } from '../shared/bindings.ts'; import { bindingPath, effectOrigin, isGenCallee } from '../shared/effect-identity.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { - collectDirectMemberImports, - collectRootNamespaces, - collectSchemaLocals, -} from '../shared/imports.ts'; +import { collectDirectMemberImports, collectRootNamespaces, collectSchemaLocals } from '../shared/imports.ts'; import { provenance } from '../shared/provenance.ts'; -import { - isInErasedTypePosition, - isInTypePosition, - isNonReferencePosition, -} from '../shared/reference-positions.ts'; +import { isInErasedTypePosition, isInTypePosition, isNonReferencePosition } from '../shared/reference-positions.ts'; import { snippet } from '../shared/reporting.ts'; import { emittedText, maskText, reportNode } from '../shared/scaffold-text.ts'; import { schemaIdentity } from '../shared/schema-identity.ts'; @@ -54,21 +46,14 @@ const probes = new Map([ 'wrappers', (_context, program) => { const node = expression(program); - return [ - unwrapNode(node, { wrappers: new Set() }) === node, - asNode({ type: 'Identifier' }, true) === null, - ]; + return [unwrapNode(node, { wrappers: new Set() }) === node, asNode({ type: 'Identifier' }, true) === null]; }, ], [ 'members', (_context, program) => { const node = expression(program); - return [ - memberName(node), - memberName(node, { templates: true }), - memberName(node, { unwrap: {} }), - ]; + return [memberName(node), memberName(node, { templates: true }), memberName(node, { unwrap: {} })]; }, ], [ @@ -96,10 +81,7 @@ const probes = new Map([ ], [ 'origin', - (context, program) => [ - bindingPath(context, expression(program)), - effectOrigin(context, expression(program), []), - ], + (context, program) => [bindingPath(context, expression(program)), effectOrigin(context, expression(program), [])], ], [ 'barrel', @@ -110,10 +92,7 @@ const probes = new Map([ ], [ 'provenance', - (context, program) => [ - provenance(context, expression(program)), - bindingPath(context, expression(program)), - ], + (context, program) => [provenance(context, expression(program)), bindingPath(context, expression(program))], ], [ 'globals', diff --git a/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts index 458d2b953..1bc8a893c 100644 --- a/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts +++ b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts @@ -12,13 +12,7 @@ import { stringArray, stringList, } from '../shared/options.ts'; -import { - globToRegExp, - inScriptScope, - scopePath, - scriptScope, - workspacePath, -} from '../shared/paths.ts'; +import { globToRegExp, inScriptScope, scopePath, scriptScope, workspacePath } from '../shared/paths.ts'; import { runOxlint, testsDirectory } from './oxlint.mts'; import { withTemporaryWorkspace } from './temporary-workspace.mts'; @@ -41,8 +35,7 @@ it('shared option parsers preserve rejection, sparse arrays and regex flags', () it('shared path policies distinguish earliest and latest markers and script scope', () => { const nestedScript = 'packages/p/scripts/apps/demo.ts'; - const fixture = - '/repo/tools/oxlint/effect-native/tests/fixtures/x/invalid/packages/p/scripts/apps/demo.ts'; + const fixture = '/repo/tools/oxlint/effect-native/tests/fixtures/x/invalid/packages/p/scripts/apps/demo.ts'; expect(scopePath(fixture)).toBe(nestedScript); expect(scriptScope(fixture)).toBe(nestedScript); expect(workspacePath(fixture)).toBe('apps/demo.ts'); @@ -132,8 +125,7 @@ const cases = [ expected: [null, null], name: 'script provenance rejects later writes', probe: 'provenance', - source: - 'import * as p from "node:process"; let { stderr: sink } = p; sink = p.stdout; sink.write;', + source: 'import * as p from "node:process"; let { stderr: sink } = p; sink = p.stdout; sink.write;', }, { expected: ['process', null], @@ -163,8 +155,7 @@ const cases = [ expected: ['decodeUnknownSync'], name: 'Schema identity follows aliases', probe: 'schema', - source: - 'import * as E from "effect"; const S = E.Schema; const { decodeUnknownSync: decode } = S; decode;', + source: 'import * as E from "effect"; const S = E.Schema; const { decodeUnknownSync: decode } = S; decode;', }, { expected: [null], diff --git a/app/tools/oxlint/effect-native/tests/temporary-workspace.mts b/app/tools/oxlint/effect-native/tests/temporary-workspace.mts index 7f1829d96..4e2e9aeac 100644 --- a/app/tools/oxlint/effect-native/tests/temporary-workspace.mts +++ b/app/tools/oxlint/effect-native/tests/temporary-workspace.mts @@ -36,11 +36,9 @@ export function withTemporaryWorkspace( try { release(directory); } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `Fixture run failed and workspace remains: ${directory}`, - { cause: error }, - ); + throw new AggregateError([error, cleanupError], `Fixture run failed and workspace remains: ${directory}`, { + cause: error, + }); } throw error; } diff --git a/app/tools/oxlint/effect-native/tests/temporary-workspace.test.mts b/app/tools/oxlint/effect-native/tests/temporary-workspace.test.mts index 041ab2bf7..237277993 100644 --- a/app/tools/oxlint/effect-native/tests/temporary-workspace.test.mts +++ b/app/tools/oxlint/effect-native/tests/temporary-workspace.test.mts @@ -1,7 +1,9 @@ -import { expect, it } from 'effect-rstest'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; import nodePath from 'node:path'; + +import { expect, it } from 'effect-rstest'; + import { withTemporaryWorkspace } from './temporary-workspace.mts'; const callerOwned = 'caller-owned'; diff --git a/app/topology/local-overlays/development.json b/app/topology/local-overlays/development.json index bd6428c71..6cec3ad7a 100644 --- a/app/topology/local-overlays/development.json +++ b/app/topology/local-overlays/development.json @@ -1,4 +1,7 @@ { + "ontosModuleManifests": { + "party-registry": "http://localhost:4102/.well-known/ontos-module-manifest.json" + }, "schemaVersion": 1, "environment": "development", "preset": "presetUltramodern", @@ -9,9 +12,6 @@ "manifests": { "party-registry": "http://localhost:4102/mf-manifest.json" }, - "ontosModuleManifests": { - "party-registry": "http://localhost:4102/.well-known/ontos-module-manifest.json" - }, "serverExecution": { "party-registry": { "apiBaseUrl": "http://localhost:4102/party-registry-api", @@ -27,9 +27,9 @@ "ssr": { "workerEntry": ".output/server/index.mjs", "workerManifest": ".output/server/modern-worker-manifest.json", + "effectBffBundle": ".output/worker/__modern_bff_effect.js", "routeManifest": ".output/server/route.json", "ssrBundle": ".output/worker/index.js", - "effectBffBundle": ".output/worker/__modern_bff_effect.js", "assetsBinding": "ASSETS" }, "zephyr": { diff --git a/app/topology/reference-topology.json b/app/topology/reference-topology.json index 351db5771..7ef90159d 100644 --- a/app/topology/reference-topology.json +++ b/app/topology/reference-topology.json @@ -260,9 +260,9 @@ "ssr": { "workerEntry": ".output/server/index.mjs", "workerManifest": ".output/server/modern-worker-manifest.json", + "effectBffBundle": ".output/worker/__modern_bff_effect.js", "routeManifest": ".output/server/route.json", "ssrBundle": ".output/worker/index.js", - "effectBffBundle": ".output/worker/__modern_bff_effect.js", "assetsBinding": "ASSETS" }, "zephyr": { @@ -300,8 +300,8 @@ "compatibility": { "contractVersion": "microvertical-server-effect-v1", "packageName": "@app/party-registry", - "effectVersion": "4.0.0-beta.107", - "moduleFederationVersion": "2.8.0" + "effectVersion": "4.0.0-rc.112", + "moduleFederationVersion": "2.9.0" }, "cache": { "cloudflareSnapshot": "immutable", diff --git a/app/tsconfig.base.json b/app/tsconfig.base.json index 19efc15af..b1eec731f 100644 --- a/app/tsconfig.base.json +++ b/app/tsconfig.base.json @@ -109,6 +109,9 @@ "unnecessaryPipeChain": "error" } } + ], + "types": [ + "node" ] } } diff --git a/app/verticals/party-registry/api/action-http-runner.ts b/app/verticals/party-registry/api/action-http-runner.ts index 842f5b245..d2221fa59 100644 --- a/app/verticals/party-registry/api/action-http-runner.ts +++ b/app/verticals/party-registry/api/action-http-runner.ts @@ -2,6 +2,7 @@ // @ontos-action-boundary-owner party-registry import { bindGovernedActionHttp } from '@app/core-runtime/http/action-runner'; import type { PrincipalAuthenticationProblems } from '@app/core-runtime/http/principal-authentication'; + import { authenticateOperationPrincipal } from './auth/action-principal.ts'; /** diff --git a/app/verticals/party-registry/api/ares-lookup-read-server.ts b/app/verticals/party-registry/api/ares-lookup-read-server.ts index 35184b39e..c3446cf2a 100644 --- a/app/verticals/party-registry/api/ares-lookup-read-server.ts +++ b/app/verticals/party-registry/api/ares-lookup-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { AresLookupAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: AresLookupUnavailableProblemSchema, }); -export const aresLookupReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'aresLookup', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: aresLookupRead, - }), - ), +export const aresLookupReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'aresLookup', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: aresLookupRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/auth/action-principal.ts b/app/verticals/party-registry/api/auth/action-principal.ts index 85492e45e..6d0b2c321 100644 --- a/app/verticals/party-registry/api/auth/action-principal.ts +++ b/app/verticals/party-registry/api/auth/action-principal.ts @@ -14,11 +14,8 @@ const principalVerifier = bindGatewayPrincipalVerifier(ACTION_GATEWAY_AUDIENCE); const verifyOperationPrincipal = (authorization: Redacted.Redacted) => GatewayAssertionRedemptionService.pipe( - Effect.flatMap((redemption) => - principalVerifier.verifyAndRedeem(authorization, { redemption }), - ), + Effect.flatMap((redemption) => principalVerifier.verifyAndRedeem(authorization, { redemption })), ); /** Shared HTTP acquisition bound to this deployment's audience-specific verifier. */ -export const authenticateOperationPrincipal = - makeMicroverticalHttpPrincipalAuthentication(verifyOperationPrincipal); +export const authenticateOperationPrincipal = makeMicroverticalHttpPrincipalAuthentication(verifyOperationPrincipal); diff --git a/app/verticals/party-registry/api/counterparties-search-server.ts b/app/verticals/party-registry/api/counterparties-search-server.ts index e97215764..d4ff9f208 100644 --- a/app/verticals/party-registry/api/counterparties-search-server.ts +++ b/app/verticals/party-registry/api/counterparties-search-server.ts @@ -3,6 +3,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartiesProviderAuthenticationProblemSchema, @@ -28,16 +29,13 @@ const problems = makeGovernedReadProblems({ unavailable: CounterpartiesProviderUnavailableProblemSchema, }); -export const counterpartiesReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'counterpartiesSearch', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: counterpartiesRead, - }), - ), +export const counterpartiesReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'counterpartiesSearch', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: counterpartiesRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/counterparty-read-read-server.ts b/app/verticals/party-registry/api/counterparty-read-read-server.ts index 821d82b69..51f59b754 100644 --- a/app/verticals/party-registry/api/counterparty-read-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-read-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyReadAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: CounterpartyReadUnavailableProblemSchema, }); -export const counterpartyReadReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'counterpartyRead', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: counterpartyReadRead, - }), - ), +export const counterpartyReadReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'counterpartyRead', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: counterpartyReadRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts index 35b2d7607..28f8b9355 100644 --- a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyRoleHistoryAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts index 6fc31c5a4..b47b12de1 100644 --- a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts +++ b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { DuplicateCandidateDetailAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/engagement-profile-problems.ts b/app/verticals/party-registry/api/engagement-profile-problems.ts index 2a35e7707..6b53eec91 100644 --- a/app/verticals/party-registry/api/engagement-profile-problems.ts +++ b/app/verticals/party-registry/api/engagement-profile-problems.ts @@ -1,7 +1,12 @@ -import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; import type { ActionCoreError } from '@app/core-runtime'; import { Match, Result, Schema } from 'effect'; +import type { + EngagementProfileConflict, + EngagementProfileNotFound, + EngagementProfilePersistenceUnavailable, + PartyRegistryReferenceUnavailable, +} from '../shared/domain/engagement-profile.ts'; import { ContactsAuthenticationProblemSchema, ContactsConflictProblemSchema, @@ -13,12 +18,7 @@ import { ContactsUnavailableProblemSchema, } from '../shared/engagement-profile-api.ts'; import type { ContactsProblem } from '../shared/engagement-profile-api.ts'; -import type { - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, - PartyRegistryReferenceUnavailable, -} from '../shared/domain/engagement-profile.ts'; +import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; export type EngagementActionError = | ActionCoreError @@ -26,15 +26,12 @@ export type EngagementActionError = | EngagementProfileNotFound | EngagementProfilePersistenceUnavailable | PartyRegistryReferenceUnavailable; -export type EngagementAttachProblem = Exclude< - ContactsProblem, - { readonly _tag: 'ContactsNotFoundProblem' } ->; +export type EngagementAttachProblem = Exclude; export const engagementProblem = { authentication: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsAuthenticationProblemSchema)({ + Schema.decodeResult(ContactsAuthenticationProblemSchema)({ _tag: 'ContactsAuthenticationProblem', detail: 'A valid audience-scoped Bearer assertion is required.', status: 401, @@ -42,11 +39,9 @@ export const engagementProblem = { type: 'https://ontos.dev/problems/operation-authentication-required', }), ), - conflict: ( - code: Extract['code'], - ) => + conflict: (code: Extract['code']) => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsConflictProblemSchema)({ + Schema.decodeResult(ContactsConflictProblemSchema)({ _tag: 'ContactsConflictProblem', code, detail: 'The engagement profile operation conflicts with the current state.', @@ -57,7 +52,7 @@ export const engagementProblem = { ), forbidden: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsForbiddenProblemSchema)({ + Schema.decodeResult(ContactsForbiddenProblemSchema)({ _tag: 'ContactsForbiddenProblem', detail: 'The principal is not permitted to perform this Party Registry operation.', status: 403, @@ -67,7 +62,7 @@ export const engagementProblem = { ), internal: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsInternalProblemSchema)({ + Schema.decodeResult(ContactsInternalProblemSchema)({ _tag: 'ContactsInternalProblem', detail: 'The engagement profile operation could not be completed.', status: 500, @@ -77,7 +72,7 @@ export const engagementProblem = { ), invalid: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsInvalidRequestProblemSchema)({ + Schema.decodeResult(ContactsInvalidRequestProblemSchema)({ _tag: 'ContactsInvalidRequestProblem', detail: 'The engagement profile operation request is invalid.', status: 400, @@ -87,7 +82,7 @@ export const engagementProblem = { ), notFound: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsNotFoundProblemSchema)({ + Schema.decodeResult(ContactsNotFoundProblemSchema)({ _tag: 'ContactsNotFoundProblem', detail: 'The requested engagement profile was not found.', status: 404, @@ -97,7 +92,7 @@ export const engagementProblem = { ), precondition: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsPreconditionRequiredProblemSchema)({ + Schema.decodeResult(ContactsPreconditionRequiredProblemSchema)({ _tag: 'ContactsPreconditionRequiredProblem', detail: 'An Idempotency-Key header is required.', status: 428, @@ -107,7 +102,7 @@ export const engagementProblem = { ), unavailable: () => Result.getOrThrow( - Schema.decodeUnknownResult(ContactsUnavailableProblemSchema)({ + Schema.decodeResult(ContactsUnavailableProblemSchema)({ _tag: 'ContactsUnavailableProblem', detail: 'The engagement profile operation is temporarily unavailable.', retryable: true, @@ -125,23 +120,20 @@ export const failEngagementProblem = (mapped: P export const mapEngagementActionProblem = (error: EngagementActionError): ContactsProblem => Match.value(error).pipe( Match.tags({ - ActionAlreadyCommitted: () => - engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), + ActionAlreadyCommitted: () => engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), ActionCollectorError: engagementProblem.internal, ActionCommitIndeterminate: engagementProblem.unavailable, ActionHandlerExecutionError: engagementProblem.internal, ActionIdempotencyKeyRequired: engagementProblem.precondition, ActionInvocationNotFound: engagementProblem.notFound, ActionInvocationPersistenceError: engagementProblem.unavailable, - ActionInvocationStateError: () => - engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), + ActionInvocationStateError: () => engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), ActionPayloadValidationError: engagementProblem.invalid, ActionPermissionCheckError: engagementProblem.unavailable, ActionPermissionDenied: engagementProblem.forbidden, ActionPolicyDenied: engagementProblem.internal, ActionPolicyEvaluationError: engagementProblem.unavailable, - ActionRequestHashConflict: () => - engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), + ActionRequestHashConflict: () => engagementProblem.conflict('contacts_engagement_profile_lifecycle_conflict'), ActionResultValidationError: engagementProblem.internal, ActionTransactionError: engagementProblem.unavailable, ActionTrustedContextValidationError: engagementProblem.authentication, diff --git a/app/verticals/party-registry/api/engagement-profile-server.ts b/app/verticals/party-registry/api/engagement-profile-server.ts index 674b656f5..473fe97aa 100644 --- a/app/verticals/party-registry/api/engagement-profile-server.ts +++ b/app/verticals/party-registry/api/engagement-profile-server.ts @@ -1,11 +1,9 @@ import type { ActionRegistration, DomainEventContractMap } from '@app/core-runtime'; import { Effect, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { Redacted, Schema } from 'effect'; + import { partyRegistryApi } from '../shared/api.ts'; -import type { - ContactsMutationHeadersSchema, - ContactsProblem, -} from '../shared/engagement-profile-api.ts'; +import type { ContactsMutationHeadersSchema, ContactsProblem } from '../shared/engagement-profile-api.ts'; import { archiveOrganizationEngagementAction } from '../src/actions/archive-organization-engagement.action.ts'; import { archivePersonEngagementAction } from '../src/actions/archive-person-engagement.action.ts'; import { attachOrganizationEngagementAction } from '../src/actions/attach-organization-engagement.action.ts'; @@ -20,20 +18,14 @@ import { isEngagementAuthenticationProblem, mapEngagementActionProblem, } from './engagement-profile-problems.ts'; -import type { - EngagementActionError, - EngagementAttachProblem, -} from './engagement-profile-problems.ts'; +import type { EngagementActionError, EngagementAttachProblem } from './engagement-profile-problems.ts'; const runActionHttp = bindActionHttpRunner({ authentication: engagementProblem.authentication, unavailable: engagementProblem.unavailable, }); -const RequestHeadersSchema = Schema.Record( - Schema.String, - Schema.Union([Schema.String, Schema.Undefined]), -); +const RequestHeadersSchema = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Undefined])); type RequestHeaders = Schema.Schema.Type; type ContactsMutationHeaders = Schema.Schema.Type; @@ -96,34 +88,16 @@ export const organizationEngagementMutationsLive = HttpApiBuilder.group( 'organizationEngagementMutations', (handlers) => handlers - .handle( - 'attach', - engagementActionHandler(attachOrganizationEngagementAction, attachActionProblem), - ) - .handle( - 'archive', - engagementActionHandler(archiveOrganizationEngagementAction, mapEngagementActionProblem), - ) - .handle( - 'unarchive', - engagementActionHandler(unarchiveOrganizationEngagementAction, mapEngagementActionProblem), - ), + .handle('attach', engagementActionHandler(attachOrganizationEngagementAction, attachActionProblem)) + .handle('archive', engagementActionHandler(archiveOrganizationEngagementAction, mapEngagementActionProblem)) + .handle('unarchive', engagementActionHandler(unarchiveOrganizationEngagementAction, mapEngagementActionProblem)), ); -const personEngagementMutationsLive = HttpApiBuilder.group( - partyRegistryApi, - 'personEngagementMutations', - (handlers) => - handlers - .handle('attach', engagementActionHandler(attachPersonEngagementAction, attachActionProblem)) - .handle( - 'archive', - engagementActionHandler(archivePersonEngagementAction, mapEngagementActionProblem), - ) - .handle( - 'unarchive', - engagementActionHandler(unarchivePersonEngagementAction, mapEngagementActionProblem), - ), +const personEngagementMutationsLive = HttpApiBuilder.group(partyRegistryApi, 'personEngagementMutations', (handlers) => + handlers + .handle('attach', engagementActionHandler(attachPersonEngagementAction, attachActionProblem)) + .handle('archive', engagementActionHandler(archivePersonEngagementAction, mapEngagementActionProblem)) + .handle('unarchive', engagementActionHandler(unarchivePersonEngagementAction, mapEngagementActionProblem)), ); export const engagementProfileApiHandlersLive = Layer.mergeAll( diff --git a/app/verticals/party-registry/api/fail-authenticated-problem.ts b/app/verticals/party-registry/api/fail-authenticated-problem.ts index 659acc5ad..39599d0c5 100644 --- a/app/verticals/party-registry/api/fail-authenticated-problem.ts +++ b/app/verticals/party-registry/api/fail-authenticated-problem.ts @@ -4,10 +4,5 @@ const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), ); -export const failAuthenticatedProblem = ( - mapped: Problem, - isAuthentication: (problem: Problem) => boolean, -) => - (isAuthentication(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); +export const failAuthenticatedProblem = (mapped: Problem, isAuthentication: (problem: Problem) => boolean) => + (isAuthentication(mapped) ? bearerChallenge : Effect.void).pipe(Effect.andThen(Effect.fail(mapped))); diff --git a/app/verticals/party-registry/api/index.ts b/app/verticals/party-registry/api/index.ts index 11706c298..2bfe5d951 100644 --- a/app/verticals/party-registry/api/index.ts +++ b/app/verticals/party-registry/api/index.ts @@ -1,34 +1,29 @@ import { DatabaseConfigLive } from '@app/core-runtime'; -import type { - ActionRuntime, - ReadRuntime, - GatewayAssertionRedemptionService, -} from '@app/core-runtime'; -import { HttpRouter, Layer } from '@modern-js/plugin-bff/effect-edge'; +import type { ActionRuntime, ReadRuntime, GatewayAssertionRedemptionService } from '@app/core-runtime'; import { assembleEffectBffRuntime } from '@app/shared-contracts/server/effect-bff-runtime'; -import type { - EffectBffDefinition, - EffectBffRuntime, - EffectRuntimeLayer, -} from '@modern-js/plugin-bff/effect-edge'; +import { HttpRouter, Layer } from '@modern-js/plugin-bff/effect-edge'; +import type { EffectBffDefinition, EffectBffRuntime, EffectRuntimeLayer } from '@modern-js/plugin-bff/effect-edge'; import { Layer as GovernedReadLayer, Logger, References, Schema, Tracer } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import type { PartySearchProjectionGateway } from '../shared/domain/search-projection-gateway.ts'; import type { AresSubjectService } from '../src/integrations/ares/ares-subject.service.ts'; +// +import { aresLookupReadApiLive } from './ares-lookup-read-server.ts'; import { ActionPrincipalVerifierLive } from './auth/action-principal.ts'; import { GatewayAssertionRedemptionDatabaseLive, GatewayAssertionRedemptionLive, } from './auth/gateway-assertion-redemption.ts'; -// -import { aresLookupReadApiLive } from './ares-lookup-read-server.ts'; import { counterpartiesReadApiLive } from './counterparties-search-server.ts'; import { counterpartyReadReadApiLive } from './counterparty-read-read-server.ts'; import { counterpartyRoleHistoryReadApiLive } from './counterparty-role-history-read-server.ts'; import { duplicateCandidateDetailReadApiLive } from './duplicate-candidate-detail-read-server.ts'; +// +import { engagementProfileApiHandlersLive } from './engagement-profile-server.ts'; import { organizationEngagementProfileReadApiLive } from './organization-engagement-profile-read-server.ts'; import { partiesReadApiLive } from './parties-search-server.ts'; +import { partyRegistryCommandRecoveryLive, partyRegistryCommandsLive } from './party-command-server.ts'; import { partyContactPointDetailReadApiLive } from './party-contact-point-detail-read-server.ts'; import { partyContactPointsReadApiLive } from './party-contact-points-read-server.ts'; import { partyCorrectionReadApiLive } from './party-correction-read-server.ts'; @@ -38,27 +33,21 @@ import { partyMatchReadApiLive } from './party-match-read-server.ts'; import { partyMergeReadinessReadApiLive } from './party-merge-readiness-read-server.ts'; import { partyOfficialIdentifierDetailReadApiLive } from './party-official-identifier-detail-read-server.ts'; import { partyOfficialIdentifierHistoryReadApiLive } from './party-official-identifier-history-read-server.ts'; +import { partyRegistryFoundationLive } from './party-registry-foundation.ts'; +import { + partyRegistryActionRuntimeLive, + partyRegistryAresSubjectServiceLive, + partyRegistryReadRuntimeLive, + partyRegistrySearchProjectionGatewayLive, +} from './party-registry-production-layers.ts'; import { partyRelationshipDetailReadApiLive } from './party-relationship-detail-read-server.ts'; import { personEngagementProfileReadApiLive } from './person-engagement-profile-read-server.ts'; -// -import { engagementProfileApiHandlersLive } from './engagement-profile-server.ts'; -import { - partyRegistryCommandRecoveryLive, - partyRegistryCommandsLive, -} from './party-command-server.ts'; import { partyRegistryCorsAllowedHeaders, partyRegistryCorsAllowedMethods, partyRegistryCorsAllowedOrigins, resolvePartyRegistryShellOrigin, } from './read-server-support.ts'; -import { partyRegistryFoundationLive } from './party-registry-foundation.ts'; -import { - partyRegistryActionRuntimeLive, - partyRegistryAresSubjectServiceLive, - partyRegistryReadRuntimeLive, - partyRegistrySearchProjectionGatewayLive, -} from './party-registry-production-layers.ts'; export { partyRegistryFoundationLive } from './party-registry-foundation.ts'; @@ -87,22 +76,15 @@ const runtimeObservabilityLive = Layer.mergeAll( Layer.succeed(References.MinimumLogLevel, 'Info'), ); -const productionReadRuntimeLive = partyRegistryReadRuntimeLive.pipe( - Layer.provide(DatabaseConfigLive), -); +const productionReadRuntimeLive = partyRegistryReadRuntimeLive.pipe(Layer.provide(DatabaseConfigLive)); const productionSearchProjectionGatewayLive = partyRegistrySearchProjectionGatewayLive.pipe( Layer.provide(DatabaseConfigLive), ); -const productionActionRuntimeLive = partyRegistryActionRuntimeLive.pipe( - Layer.provide(DatabaseConfigLive), -); +const productionActionRuntimeLive = partyRegistryActionRuntimeLive.pipe(Layer.provide(DatabaseConfigLive)); type PartyRegistryApiRuntimeArguments = readonly [ readRuntime: Layer.Layer>, - aresSubjectService: Layer.Layer< - AresSubjectService, - Layer.Error - >, + aresSubjectService: Layer.Layer>, searchProjectionGateway: Layer.Layer< PartySearchProjectionGateway, Layer.Error @@ -125,9 +107,7 @@ export const makePartyRegistryApiRuntime = ( actionRuntime, gatewayAssertionRedemption, ] = args; - const actionPrincipalVerifierLive = ActionPrincipalVerifierLive.pipe( - Layer.provide(actionRuntime), - ); + const actionPrincipalVerifierLive = ActionPrincipalVerifierLive.pipe(Layer.provide(actionRuntime)); const apiHandlersLive = Layer.mergeAll( partyRegistryFoundationLive, partyRegistryCommandsLive.pipe(Layer.provide(actionRuntime)), @@ -137,9 +117,7 @@ export const makePartyRegistryApiRuntime = ( counterpartyReadReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), counterpartyRoleHistoryReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), duplicateCandidateDetailReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), - organizationEngagementProfileReadApiLive.pipe( - GovernedReadLayer.provide(governedReadRuntimeLive), - ), + organizationEngagementProfileReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyContactPointDetailReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyContactPointsReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyCorrectionReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), @@ -147,32 +125,19 @@ export const makePartyRegistryApiRuntime = ( partyMatchDecisionReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyMatchReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyMergeReadinessReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), - partyOfficialIdentifierDetailReadApiLive.pipe( - GovernedReadLayer.provide(governedReadRuntimeLive), - ), - partyOfficialIdentifierHistoryReadApiLive.pipe( - GovernedReadLayer.provide(governedReadRuntimeLive), - ), + partyOfficialIdentifierDetailReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), + partyOfficialIdentifierHistoryReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), partyRelationshipDetailReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), personEngagementProfileReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive)), - aresLookupReadApiLive.pipe( - GovernedReadLayer.provide(governedReadRuntimeLive), - Layer.provide(aresSubjectService), - ), - partiesReadApiLive.pipe( - GovernedReadLayer.provide(governedReadRuntimeLive), - Layer.provide(searchProjectionGateway), - ), + aresLookupReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive), Layer.provide(aresSubjectService)), + partiesReadApiLive.pipe(GovernedReadLayer.provide(governedReadRuntimeLive), Layer.provide(searchProjectionGateway)), counterpartiesReadApiLive.pipe( GovernedReadLayer.provide(governedReadRuntimeLive), Layer.provide(searchProjectionGateway), ), // ).pipe(Layer.provide(Layer.mergeAll(actionPrincipalVerifierLive, gatewayAssertionRedemption))); - const resolvedApiHandlersLive = apiHandlersLive.pipe( - Layer.provide(runtimeObservabilityLive), - Layer.orDie, - ); + const resolvedApiHandlersLive = apiHandlersLive.pipe(Layer.provide(runtimeObservabilityLive), Layer.orDie); const transportLive = HttpRouter.cors({ allowedHeaders: [...partyRegistryCorsAllowedHeaders], allowedMethods: [...partyRegistryCorsAllowedMethods], diff --git a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts index 55576c4de..a76f6d82e 100644 --- a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { OrganizationEngagementProfileAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/parties-search-server.ts b/app/verticals/party-registry/api/parties-search-server.ts index ec64cecbf..f688b1f4c 100644 --- a/app/verticals/party-registry/api/parties-search-server.ts +++ b/app/verticals/party-registry/api/parties-search-server.ts @@ -3,6 +3,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartiesProviderAuthenticationProblemSchema, @@ -28,16 +29,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartiesProviderUnavailableProblemSchema, }); -export const partiesReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partiesSearch', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partiesRead, - }), - ), +export const partiesReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partiesSearch', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partiesRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-command-problems.ts b/app/verticals/party-registry/api/party-command-problems.ts index 7ee787a01..f6f9ad153 100644 --- a/app/verticals/party-registry/api/party-command-problems.ts +++ b/app/verticals/party-registry/api/party-command-problems.ts @@ -1,4 +1,3 @@ -import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; import type { ActionCoreError } from '@app/core-runtime'; import { Effect, HttpApiMiddleware } from '@modern-js/plugin-bff/effect-edge'; import { Match, Schema } from 'effect'; @@ -20,15 +19,13 @@ import { } from '../shared/command-api.ts'; import type { PartyCommandProblem } from '../shared/command-api.ts'; import { ActionInvocationIdSchema } from '../shared/domain/correction-contracts.ts'; +import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; import type { partyCommandRegistrations } from './party-command-registrations.ts'; export type PartyActionError = | ActionCoreError | (typeof partyCommandRegistrations)[keyof typeof partyCommandRegistrations]['descriptor']['domainErrorSchema']['Type']; -type ProblemOf = Extract< - PartyCommandProblem, - { readonly _tag: Tag } ->; +type ProblemOf = Extract; const problemStatus = { authentication: 401, @@ -71,13 +68,10 @@ export const partyCommandProblem = { title: 'Party Registry resource not found', type: 'https://ontos.dev/problems/party-command-not-found', }), - conflict: ( - code: ProblemOf<'PartyCommandConflictProblem'>['code'], - ): ProblemOf<'PartyCommandConflictProblem'> => + conflict: (code: ProblemOf<'PartyCommandConflictProblem'>['code']): ProblemOf<'PartyCommandConflictProblem'> => PartyCommandConflictProblemSchema.make({ code, - detail: - 'The command conflicts with the current state. Review the resource before trying again.', + detail: 'The command conflicts with the current state. Review the resource before trying again.', status: problemStatus.conflict, title: 'Party Registry command conflict', type: 'https://ontos.dev/problems/party-command-conflict', @@ -109,8 +103,7 @@ export const partyCommandProblem = { }), indeterminate: (invocationId: string): ProblemOf<'PartyCommandCommitIndeterminateProblem'> => PartyCommandCommitIndeterminateProblemSchema.make({ - detail: - 'The command commit is uncertain. Resolve this invocation before considering any further command.', + detail: 'The command commit is uncertain. Resolve this invocation before considering any further command.', invocationId: ActionInvocationIdSchema.make(invocationId), resolution: 'RESOLVE_COMMIT', retryCommand: false, @@ -127,9 +120,7 @@ export const partyCommandProblem = { }), }; -export const isPartyCommandAuthenticationProblem = Schema.is( - PartyCommandAuthenticationProblemSchema, -); +export const isPartyCommandAuthenticationProblem = Schema.is(PartyCommandAuthenticationProblemSchema); export const failPartyCommandProblem = (mapped: Problem) => failAuthenticatedProblem(mapped, isPartyCommandAuthenticationProblem); @@ -144,8 +135,7 @@ export const mapPartyActionProblem = (error: PartyActionError): PartyCommandProb ActionAlreadyCommitted: (failure) => PartyCommandAlreadyCommittedProblemSchema.make({ code: failure.code, - detail: - 'This command is already committed. Refresh governed reads to retrieve its outcome.', + detail: 'This command is already committed. Refresh governed reads to retrieve its outcome.', invocationId: ActionInvocationIdSchema.make(failure.invocationId), resolution: 'REFRESH_GOVERNED_READS', retryCommand: false, @@ -154,8 +144,7 @@ export const mapPartyActionProblem = (error: PartyActionError): PartyCommandProb type: 'https://ontos.dev/problems/party-command-already-committed', }), ActionCollectorError: partyCommandProblem.internal, - ActionCommitIndeterminate: (failure) => - partyCommandProblem.indeterminate(failure.invocationId), + ActionCommitIndeterminate: (failure) => partyCommandProblem.indeterminate(failure.invocationId), ActionHandlerExecutionError: partyCommandProblem.internal, ActionIdempotencyKeyRequired: partyCommandProblem.precondition, ActionInvocationNotFound: partyCommandProblem.notFound, @@ -199,8 +188,7 @@ export const mapPartyActionProblem = (error: PartyActionError): PartyCommandProb aliasPartyRef: failure.aliasPartyRef, canonicalPartyRef: failure.canonicalPartyRef, code: failure.code, - detail: - 'This Party is an alias. Review the canonical Party before issuing a new command.', + detail: 'This Party is an alias. Review the canonical Party before issuing a new command.', status: problemStatus.conflict, title: 'Alias write rejected', type: 'https://ontos.dev/problems/party-alias-write-rejected', @@ -218,13 +206,11 @@ export const mapPartyActionProblem = (error: PartyActionError): PartyCommandProb PartyLifecycleConflict: (failure) => partyCommandProblem.conflict(failure.code), PartyNotFound: partyCommandProblem.notFound, PartyOfficialIdentifierNotFound: partyCommandProblem.notFound, - PartyOfficialIdentifierUpdateConflict: (failure) => - partyCommandProblem.conflict(failure.code), + PartyOfficialIdentifierUpdateConflict: (failure) => partyCommandProblem.conflict(failure.code), PartyPersistenceUnavailable: partyCommandProblem.unavailable, PartyRelationshipCorrectionRequired: (failure) => partyCommandProblem.conflict(failure.code), PartyRelationshipEndpointNotFound: partyCommandProblem.notFound, - PartyRelationshipEndpointTypeMismatch: (failure) => - partyCommandProblem.ineligible(failure.code), + PartyRelationshipEndpointTypeMismatch: (failure) => partyCommandProblem.ineligible(failure.code), PartyRelationshipInvalidInterval: (failure) => partyCommandProblem.ineligible(failure.code), PartyRelationshipNotFound: partyCommandProblem.notFound, PartyRelationshipOverlapConflict: (failure) => partyCommandProblem.conflict(failure.code), diff --git a/app/verticals/party-registry/api/party-command-registrations.ts b/app/verticals/party-registry/api/party-command-registrations.ts index 4a38293fd..e198022ce 100644 --- a/app/verticals/party-registry/api/party-command-registrations.ts +++ b/app/verticals/party-registry/api/party-command-registrations.ts @@ -6,8 +6,8 @@ import { correctPartyFactAction } from '../src/actions/correct-party-fact.action import { counterpartyCreateAction } from '../src/actions/counterparty-create.action.ts'; import { counterpartyRoleAddAction } from '../src/actions/counterparty-role-add.action.ts'; import { counterpartyRoleEndAction } from '../src/actions/counterparty-role-end.action.ts'; -import { createPartyAction } from '../src/actions/create-party.action.ts'; import { createPartyRelationshipAction } from '../src/actions/create-party-relationship.action.ts'; +import { createPartyAction } from '../src/actions/create-party.action.ts'; import { dismissDuplicateCandidateAction } from '../src/actions/dismiss-duplicate-candidate.action.ts'; import { endContactPointAction } from '../src/actions/end-contact-point.action.ts'; import { endPartyOfficialIdentifierAction } from '../src/actions/end-party-official-identifier.action.ts'; @@ -19,9 +19,9 @@ import { resolveDuplicateCandidateCreateAction } from '../src/actions/resolve-du import { resolveDuplicateCandidateMatchAction } from '../src/actions/resolve-duplicate-candidate-match.action.ts'; import { unarchivePartyAction } from '../src/actions/unarchive-party.action.ts'; import { updateContactPointAction } from '../src/actions/update-contact-point.action.ts'; -import { updatePartyAction } from '../src/actions/update-party.action.ts'; import { updatePartyOfficialIdentifierAction } from '../src/actions/update-party-official-identifier.action.ts'; import { updatePartyRelationshipAction } from '../src/actions/update-party-relationship.action.ts'; +import { updatePartyAction } from '../src/actions/update-party.action.ts'; // New generated registrations have one sorted owner-local composition slot. The HTTP group stays // explicit in party-command-server.ts; problem typing derives from this same catalog. diff --git a/app/verticals/party-registry/api/party-command-server.ts b/app/verticals/party-registry/api/party-command-server.ts index 715dedb0d..bd4f3d9c7 100644 --- a/app/verticals/party-registry/api/party-command-server.ts +++ b/app/verticals/party-registry/api/party-command-server.ts @@ -1,18 +1,14 @@ import { ActionRuntime } from '@app/core-runtime'; import type { ActionRegistration, DomainEventContractMap } from '@app/core-runtime'; import { Effect, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; -import type { HttpServerRequest } from 'effect/unstable/http'; import { Redacted, Schema } from 'effect'; +import type { HttpServerRequest } from 'effect/unstable/http'; import { partyRegistryApi } from '../shared/api.ts'; -import type { - ResolvePartyCommandCommitPayload, - ResolvePartyCommandCommitResult, -} from '../shared/command-api.ts'; +import type { ResolvePartyCommandCommitPayload, ResolvePartyCommandCommitResult } from '../shared/command-api.ts'; import { ActionInvocationIdSchema } from '../shared/domain/correction-contracts.ts'; import { bindActionHttpRunner } from './action-http-runner.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -import { partyCommandRegistrations } from './party-command-registrations.ts'; import { failPartyCommandProblem, isPartyCommandAuthenticationProblem, @@ -21,6 +17,7 @@ import { partyCommandSchemaErrorLive, } from './party-command-problems.ts'; import type { PartyActionError } from './party-command-problems.ts'; +import { partyCommandRegistrations } from './party-command-registrations.ts'; const verifyPrincipal = (authorization: Redacted.Redacted) => authenticateOperationPrincipal(authorization, { @@ -33,62 +30,60 @@ const runActionHttp = bindActionHttpRunner({ unavailable: partyCommandProblem.unavailable, }); -const runPartyCommand = Effect.fn('PartyCommandServer.runPartyCommand')( - function* runPartyCommandEffect< - PayloadSchema extends Schema.ConstraintDecoder & Schema.ConstraintEncoder, - ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder, - DomainEvents extends DomainEventContractMap, - Owner extends string, +const runPartyCommand = Effect.fn('PartyCommandServer.runPartyCommand')(function* runPartyCommandEffect< + PayloadSchema extends Schema.ConstraintDecoder & Schema.ConstraintEncoder, + ResultSchema extends Schema.ConstraintDecoder, + DomainErrorSchema extends Schema.ConstraintDecoder, + DomainEvents extends DomainEventContractMap, + Owner extends string, + Services, + Requirements, +>( + registration: ActionRegistration< + PayloadSchema, + ResultSchema, + DomainErrorSchema, + DomainEvents, + Owner, + Services, + Requirements + >, + payload: PayloadSchema['Type'], + idempotencyKey: string | undefined, + request: HttpServerRequest.HttpServerRequest, +) { + const correlationId = request.headers['x-correlation-id']; + if (correlationId !== undefined && correlationId.length > 200) { + return yield* Effect.fail(partyCommandProblem.invalid()); + } + const traceId = request.headers['x-trace-id']; + return yield* runActionHttp< + PayloadSchema, + ResultSchema, + DomainErrorSchema, + DomainEvents, + Owner, Services, Requirements, - >( - registration: ActionRegistration< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Owner, - Services, - Requirements - >, - payload: PayloadSchema['Type'], - idempotencyKey: string | undefined, - request: HttpServerRequest.HttpServerRequest, - ) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId !== undefined && correlationId.length > 200) { - return yield* Effect.fail(partyCommandProblem.invalid()); - } - const traceId = request.headers['x-trace-id']; - return yield* runActionHttp< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Owner, - Services, - Requirements, - ReturnType, - ReturnType, - ReturnType - >({ - endpointHeaders: { - idempotencyKey, - traceId, - }, - internalProblem: partyCommandProblem.internal, - invalidCorrelationProblem: partyCommandProblem.invalid, - mapError: mapPartyActionProblem, - payload, - registration, - requestHeaders: { - authorization: Redacted.make(request.headers['authorization']), - 'x-correlation-id': request.headers['x-correlation-id'], - }, - }).pipe(Effect.catchIf(isPartyCommandAuthenticationProblem, failPartyCommandProblem)); - }, -); + ReturnType, + ReturnType, + ReturnType + >({ + endpointHeaders: { + idempotencyKey, + traceId, + }, + internalProblem: partyCommandProblem.internal, + invalidCorrelationProblem: partyCommandProblem.invalid, + mapError: mapPartyActionProblem, + payload, + registration, + requestHeaders: { + authorization: Redacted.make(request.headers['authorization']), + 'x-correlation-id': request.headers['x-correlation-id'], + }, + }).pipe(Effect.catchIf(isPartyCommandAuthenticationProblem, failPartyCommandProblem)); +}); const runWirePayloadPartyCommand = Effect.fn('PartyCommandServer.runWirePayloadPartyCommand')( function* runWirePayloadPartyCommandEffect< @@ -113,9 +108,9 @@ const runWirePayloadPartyCommand = Effect.fn('PartyCommandServer.runWirePayloadP idempotencyKey: string | undefined, request: HttpServerRequest.HttpServerRequest, ) { - const decodedPayload = yield* Schema.decodeUnknownEffect(registration.descriptor.payloadSchema)( - payload, - ).pipe(Effect.mapError(partyCommandProblem.invalid)); + const decodedPayload = yield* Schema.decodeUnknownEffect(registration.descriptor.payloadSchema)(payload).pipe( + Effect.mapError(partyCommandProblem.invalid), + ); return yield* runPartyCommand< PayloadSchema, ResultSchema, @@ -128,203 +123,120 @@ const runWirePayloadPartyCommand = Effect.fn('PartyCommandServer.runWirePayloadP }, ); -export const partyRegistryCommandsLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyCommands', - (handlers) => - handlers - .handle('addContactPoint', ({ payload, headers, request }) => - runWirePayloadPartyCommand( - partyCommandRegistrations.addContactPoint, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('addPartyOfficialIdentifier', ({ payload, headers, request }) => - runWirePayloadPartyCommand( - partyCommandRegistrations.addPartyOfficialIdentifier, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('archiveParty', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.archiveParty, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('confirmDuplicateParties', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.confirmDuplicateParties, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('correctPartyFact', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.correctPartyFact, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('counterpartyCreate', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.counterpartyCreate, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('counterpartyRoleAdd', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.counterpartyRoleAdd, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('counterpartyRoleEnd', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.counterpartyRoleEnd, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('createParty', ({ payload, headers, request }) => - runWirePayloadPartyCommand( - partyCommandRegistrations.createParty, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('createPartyRelationship', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.createPartyRelationship, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('dismissDuplicateCandidate', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.dismissDuplicateCandidate, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('endContactPoint', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.endContactPoint, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('endPartyOfficialIdentifier', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.endPartyOfficialIdentifier, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('endPartyRelationship', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.endPartyRelationship, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('markDuplicateCandidateNeedsEvidence', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.markDuplicateCandidateNeedsEvidence, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('matchParty', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.matchParty, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('requestSearchRebuild', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.requestSearchRebuild, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('resolveDuplicateCandidateCreate', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.resolveDuplicateCandidateCreate, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('resolveDuplicateCandidateMatch', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.resolveDuplicateCandidateMatch, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('unarchiveParty', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.unarchiveParty, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('updateContactPoint', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.updateContactPoint, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('updateParty', ({ payload, headers, request }) => - runWirePayloadPartyCommand( - partyCommandRegistrations.updateParty, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('updatePartyOfficialIdentifier', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.updatePartyOfficialIdentifier, - payload, - headers['idempotency-key'], - request, - ), - ) - .handle('updatePartyRelationship', ({ payload, headers, request }) => - runPartyCommand( - partyCommandRegistrations.updatePartyRelationship, - payload, - headers['idempotency-key'], - request, - ), +export const partyRegistryCommandsLive = HttpApiBuilder.group(partyRegistryApi, 'partyCommands', (handlers) => + handlers + .handle('addContactPoint', ({ payload, headers, request }) => + runWirePayloadPartyCommand( + partyCommandRegistrations.addContactPoint, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('addPartyOfficialIdentifier', ({ payload, headers, request }) => + runWirePayloadPartyCommand( + partyCommandRegistrations.addPartyOfficialIdentifier, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('archiveParty', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.archiveParty, payload, headers['idempotency-key'], request), + ) + .handle('confirmDuplicateParties', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.confirmDuplicateParties, payload, headers['idempotency-key'], request), + ) + .handle('correctPartyFact', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.correctPartyFact, payload, headers['idempotency-key'], request), + ) + .handle('counterpartyCreate', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.counterpartyCreate, payload, headers['idempotency-key'], request), + ) + .handle('counterpartyRoleAdd', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.counterpartyRoleAdd, payload, headers['idempotency-key'], request), + ) + .handle('counterpartyRoleEnd', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.counterpartyRoleEnd, payload, headers['idempotency-key'], request), + ) + .handle('createParty', ({ payload, headers, request }) => + runWirePayloadPartyCommand(partyCommandRegistrations.createParty, payload, headers['idempotency-key'], request), + ) + .handle('createPartyRelationship', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.createPartyRelationship, payload, headers['idempotency-key'], request), + ) + .handle('dismissDuplicateCandidate', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.dismissDuplicateCandidate, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('endContactPoint', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.endContactPoint, payload, headers['idempotency-key'], request), + ) + .handle('endPartyOfficialIdentifier', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.endPartyOfficialIdentifier, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('endPartyRelationship', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.endPartyRelationship, payload, headers['idempotency-key'], request), + ) + .handle('markDuplicateCandidateNeedsEvidence', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.markDuplicateCandidateNeedsEvidence, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('matchParty', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.matchParty, payload, headers['idempotency-key'], request), + ) + .handle('requestSearchRebuild', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.requestSearchRebuild, payload, headers['idempotency-key'], request), + ) + .handle('resolveDuplicateCandidateCreate', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.resolveDuplicateCandidateCreate, + payload, + headers['idempotency-key'], + request, ), + ) + .handle('resolveDuplicateCandidateMatch', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.resolveDuplicateCandidateMatch, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('unarchiveParty', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.unarchiveParty, payload, headers['idempotency-key'], request), + ) + .handle('updateContactPoint', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.updateContactPoint, payload, headers['idempotency-key'], request), + ) + .handle('updateParty', ({ payload, headers, request }) => + runWirePayloadPartyCommand(partyCommandRegistrations.updateParty, payload, headers['idempotency-key'], request), + ) + .handle('updatePartyOfficialIdentifier', ({ payload, headers, request }) => + runPartyCommand( + partyCommandRegistrations.updatePartyOfficialIdentifier, + payload, + headers['idempotency-key'], + request, + ), + ) + .handle('updatePartyRelationship', ({ payload, headers, request }) => + runPartyCommand(partyCommandRegistrations.updatePartyRelationship, payload, headers['idempotency-key'], request), + ), ).pipe(Layer.provide(partyCommandSchemaErrorLive)); const resolvePartyCommandCommit = Effect.fn('PartyCommandServer.resolvePartyCommandCommit')( @@ -333,52 +245,40 @@ const resolvePartyCommandCommit = Effect.fn('PartyCommandServer.resolvePartyComm request: HttpServerRequest.HttpServerRequest, ) { const correlationId = request.headers['x-correlation-id']; - if ( - correlationId === undefined || - correlationId.trim().length === 0 || - correlationId.length > 200 - ) { + if (correlationId === undefined || correlationId.trim().length === 0 || correlationId.length > 200) { return yield* failPartyCommandProblem(partyCommandProblem.invalid()); } const principal = yield* verifyPrincipal(Redacted.make(request.headers['authorization'])); const runtime = yield* ActionRuntime; - return yield* runtime - .resolveActionCommit({ invocationId: payload.invocationId, principal }) - .pipe( - Effect.map((resolution): ResolvePartyCommandCommitResult => ({ + return yield* runtime.resolveActionCommit({ invocationId: payload.invocationId, principal }).pipe( + Effect.map((resolution): ResolvePartyCommandCommitResult => ({ + _tag: 'PartyCommandCommitResolution', + invocationId: resolution.invocationId, + retryCommand: false, + state: 'OPEN', + })), + Effect.catchTag('ActionAlreadyCommitted', (committed) => + Effect.succeed({ _tag: 'PartyCommandCommitResolution', - invocationId: resolution.invocationId, + invocationId: ActionInvocationIdSchema.make(committed.invocationId), retryCommand: false, - state: 'OPEN', - })), - Effect.catchTag('ActionAlreadyCommitted', (committed) => - Effect.succeed({ - _tag: 'PartyCommandCommitResolution', - invocationId: ActionInvocationIdSchema.make(committed.invocationId), - retryCommand: false, - state: 'COMMITTED', - }), - ), - Effect.catchTags({ - ActionCommitIndeterminate: (failure) => - failPartyCommandProblem(partyCommandProblem.indeterminate(failure.invocationId)), - ActionInvocationNotFound: () => failPartyCommandProblem(partyCommandProblem.notFound()), - ActionInvocationStateError: (failure) => - failPartyCommandProblem(partyCommandProblem.conflict(failure.code)), - ActionPayloadValidationError: () => - failPartyCommandProblem(partyCommandProblem.invalid()), - ActionTrustedContextValidationError: () => - failPartyCommandProblem(partyCommandProblem.authentication()), + state: 'COMMITTED', }), - ); + ), + Effect.catchTags({ + ActionCommitIndeterminate: (failure) => + failPartyCommandProblem(partyCommandProblem.indeterminate(failure.invocationId)), + ActionInvocationNotFound: () => failPartyCommandProblem(partyCommandProblem.notFound()), + ActionInvocationStateError: (failure) => failPartyCommandProblem(partyCommandProblem.conflict(failure.code)), + ActionPayloadValidationError: () => failPartyCommandProblem(partyCommandProblem.invalid()), + ActionTrustedContextValidationError: () => failPartyCommandProblem(partyCommandProblem.authentication()), + }), + ); }, ); export const partyRegistryCommandRecoveryLive = HttpApiBuilder.group( partyRegistryApi, 'partyCommandRecovery', - (handlers) => - handlers.handle('resolve', ({ payload, request }) => - resolvePartyCommandCommit(payload, request), - ), + (handlers) => handlers.handle('resolve', ({ payload, request }) => resolvePartyCommandCommit(payload, request)), ).pipe(Layer.provide(partyCommandSchemaErrorLive)); diff --git a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts index 1ef9b9b5b..ae26f3634 100644 --- a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyContactPointDetailAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/party-contact-points-read-server.ts b/app/verticals/party-registry/api/party-contact-points-read-server.ts index f4377280d..2709a728e 100644 --- a/app/verticals/party-registry/api/party-contact-points-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-points-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyContactPointsAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartyContactPointsUnavailableProblemSchema, }); -export const partyContactPointsReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyContactPoints', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partyContactPointsRead, - }), - ), +export const partyContactPointsReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partyContactPoints', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partyContactPointsRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-correction-read-server.ts b/app/verticals/party-registry/api/party-correction-read-server.ts index e19a1cb21..c9679bb17 100644 --- a/app/verticals/party-registry/api/party-correction-read-server.ts +++ b/app/verticals/party-registry/api/party-correction-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyCorrectionAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartyCorrectionUnavailableProblemSchema, }); -export const partyCorrectionReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyCorrection', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partyCorrectionRead, - }), - ), +export const partyCorrectionReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partyCorrection', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partyCorrectionRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-detail-read-server.ts b/app/verticals/party-registry/api/party-detail-read-server.ts index e51377396..4058bb995 100644 --- a/app/verticals/party-registry/api/party-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-detail-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyDetailAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartyDetailUnavailableProblemSchema, }); -export const partyDetailReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyDetail', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partyDetailRead, - }), - ), +export const partyDetailReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partyDetail', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partyDetailRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-match-decision-read-server.ts b/app/verticals/party-registry/api/party-match-decision-read-server.ts index 2d9a70f62..1e4db56b0 100644 --- a/app/verticals/party-registry/api/party-match-decision-read-server.ts +++ b/app/verticals/party-registry/api/party-match-decision-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchDecisionAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartyMatchDecisionUnavailableProblemSchema, }); -export const partyMatchDecisionReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyMatchDecision', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partyMatchDecisionRead, - }), - ), +export const partyMatchDecisionReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partyMatchDecision', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partyMatchDecisionRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-match-read-server.ts b/app/verticals/party-registry/api/party-match-read-server.ts index e974d0ded..fce210e97 100644 --- a/app/verticals/party-registry/api/party-match-read-server.ts +++ b/app/verticals/party-registry/api/party-match-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchAuthenticationProblemSchema, @@ -27,16 +28,13 @@ const problems = makeGovernedReadProblems({ unavailable: PartyMatchUnavailableProblemSchema, }); -export const partyMatchReadApiLive = HttpApiBuilder.group( - partyRegistryApi, - 'partyMatch', - (handlers) => - handlers.handle( - 'execute', - makeGovernedReadHttpHandler({ - authenticatePrincipal: authenticateOperationPrincipal, - problems, - registration: partyMatchRead, - }), - ), +export const partyMatchReadApiLive = HttpApiBuilder.group(partyRegistryApi, 'partyMatch', (handlers) => + handlers.handle( + 'execute', + makeGovernedReadHttpHandler({ + authenticatePrincipal: authenticateOperationPrincipal, + problems, + registration: partyMatchRead, + }), + ), ); diff --git a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts index ee0161538..7f8d74721 100644 --- a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts +++ b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyMergeReadinessAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts index 4c7e87703..1efc4c4db 100644 --- a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierDetailAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts index b126512e9..63a911a80 100644 --- a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierHistoryAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/party-registry-foundation.ts b/app/verticals/party-registry/api/party-registry-foundation.ts index 14829c0bd..f633163e5 100644 --- a/app/verticals/party-registry/api/party-registry-foundation.ts +++ b/app/verticals/party-registry/api/party-registry-foundation.ts @@ -9,32 +9,31 @@ import { import { ultramodernApiMarker } from '../shared/ultramodern-build.ts'; import { operationAttributes } from './read-server-support.ts'; -export const partyRegistryFoundationLive = HttpApiBuilder.group( - partyRegistryApi, - 'foundation', - (handlers) => - handlers.handle('readiness', () => - Effect.withSpan( - Effect.succeed({ - checks: { - api: 'ready' as const, - moduleFederation: 'ready' as const, - ssr: 'ready' as const, - translations: 'ready' as const, - }, - marker: { - ...ultramodernApiMarker, - appId: partyRegistryAppIdFromString(ultramodernApiMarker.appId), - unitId: partyRegistryUnitIdFromString(ultramodernApiMarker.unitId), - }, - status: 'ready' as const, - versionSkew: 'none' as const, - }), - 'ultramodern.api.partyRegistry.readiness', - { - attributes: { ...operationAttributes(partyRegistryOperationContexts.readiness) }, - kind: 'server', +export const partyRegistryFoundationLive = HttpApiBuilder.group(partyRegistryApi, 'foundation', (handlers) => + handlers.handle('readiness', () => + Effect.withSpan( + Effect.succeed({ + checks: { + api: 'ready' as const, + moduleFederation: 'ready' as const, + ssr: 'ready' as const, + translations: 'ready' as const, + }, + marker: { + ...ultramodernApiMarker, + appId: partyRegistryAppIdFromString(ultramodernApiMarker.appId), + unitId: partyRegistryUnitIdFromString(ultramodernApiMarker.unitId), + }, + status: 'ready' as const, + versionSkew: 'none' as const, + }), + 'ultramodern.api.partyRegistry.readiness', + { + attributes: { + ...operationAttributes(partyRegistryOperationContexts.readiness), }, - ), + kind: 'server', + }, ), + ), ); diff --git a/app/verticals/party-registry/api/party-registry-production-layers.ts b/app/verticals/party-registry/api/party-registry-production-layers.ts index ce857a54d..be2042739 100644 --- a/app/verticals/party-registry/api/party-registry-production-layers.ts +++ b/app/verticals/party-registry/api/party-registry-production-layers.ts @@ -2,6 +2,7 @@ import { ActionRuntimeLive, ContextAccessLive, CorePersistenceLive, + CoreSearchProjectionStoreLive, CoreSearchQueryRuntimeLive, ReadRuntimeLive, TenantModuleStateServiceLive, @@ -19,21 +20,15 @@ import { FetchHttpClient } from 'effect/unstable/http'; import { AresSubjectServiceLive } from '../src/integrations/ares/ares-subject.service.ts'; import { PartySearchProjectionGatewayLive } from '../src/search/parties.provider.ts'; -const tenantModuleStateServiceLive = TenantModuleStateServiceLive.pipe( - Layer.provide(CorePersistenceLive), -); +const tenantModuleStateServiceLive = TenantModuleStateServiceLive.pipe(Layer.provide(CorePersistenceLive)); const moduleStateGateLive = ModuleStateGateLive.pipe(Layer.provide(tenantModuleStateServiceLive)); const readRuntimeDependenciesLive = Layer.mergeAll( CorePersistenceLive, ContextAccessLive, ModuleEntrypointGatewayLive.pipe(Layer.provide(moduleStateGateLive)), - OperationalScopeResolverLive.pipe( - Layer.provide(Layer.mergeAll(CorePersistenceLive, ContextAccessLive)), - ), -); -export const partyRegistryReadRuntimeLive = ReadRuntimeLive.pipe( - Layer.provide(readRuntimeDependenciesLive), + OperationalScopeResolverLive.pipe(Layer.provide(Layer.mergeAll(CorePersistenceLive, ContextAccessLive))), ); +export const partyRegistryReadRuntimeLive = ReadRuntimeLive.pipe(Layer.provide(readRuntimeDependenciesLive)); const actionRuntimeDependenciesLive = Layer.mergeAll( CorePersistenceLive, @@ -42,19 +37,14 @@ const actionRuntimeDependenciesLive = Layer.mergeAll( ContextAccessLive, moduleStateGateLive, ModuleEntrypointGatewayLive.pipe(Layer.provide(moduleStateGateLive)), - OperationalScopeResolverLive.pipe( - Layer.provide(Layer.mergeAll(CorePersistenceLive, ContextAccessLive)), - ), -); -export const partyRegistryActionRuntimeLive = ActionRuntimeLive.pipe( - Layer.provide(actionRuntimeDependenciesLive), + OperationalScopeResolverLive.pipe(Layer.provide(Layer.mergeAll(CorePersistenceLive, ContextAccessLive))), ); +export const partyRegistryActionRuntimeLive = ActionRuntimeLive.pipe(Layer.provide(actionRuntimeDependenciesLive)); -export const partyRegistryAresSubjectServiceLive = AresSubjectServiceLive.pipe( - Layer.provide(FetchHttpClient.layer), -); +export const partyRegistryAresSubjectServiceLive = AresSubjectServiceLive.pipe(Layer.provide(FetchHttpClient.layer)); const coreSearchQueryRuntimeLive = CoreSearchQueryRuntimeLive.pipe( + Layer.provide(CoreSearchProjectionStoreLive), Layer.provide(CorePersistenceLive), ); export const partyRegistrySearchProjectionGatewayLive = PartySearchProjectionGatewayLive.pipe( diff --git a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts index 7681d8c42..dd66bb07f 100644 --- a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PartyRelationshipDetailAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts index a70a511c1..b1f51c447 100644 --- a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts @@ -2,6 +2,7 @@ import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; + import { partyRegistryApi } from '../shared/api.ts'; import { PersonEngagementProfileAuthenticationProblemSchema, diff --git a/app/verticals/party-registry/backend-federation.config.ts b/app/verticals/party-registry/backend-federation.config.ts index d8cd111ab..b76ae38e9 100644 --- a/app/verticals/party-registry/backend-federation.config.ts +++ b/app/verticals/party-registry/backend-federation.config.ts @@ -12,34 +12,33 @@ const packageVersion = (specifier: string): string => const bffVersion = packageVersion('@modern-js/plugin-bff/package.json'); const effectVersion = packageVersion('effect/package.json'); -const moduleFederationConfig: Parameters[0] = - createModuleFederationConfig({ - dts: false, - exposes: { - './effect-api': './api/effect-api.ts', +const moduleFederationConfig: Parameters[0] = createModuleFederationConfig({ + dts: false, + exposes: { + './effect-api': './api/effect-api.ts', + }, + filename: 'backendRemoteEntry.cjs', + library: { + type: 'commonjs-module', + }, + name: 'verticalPartyRegistryBackend', + shared: { + '@modern-js/plugin-bff': { + requiredVersion: bffVersion, + singleton: true, + treeShaking: false, }, - filename: 'backendRemoteEntry.cjs', - library: { - type: 'commonjs-module', + '@module-federation/runtime': { + requiredVersion: dependencies['@module-federation/runtime'], + singleton: true, + treeShaking: false, }, - name: 'verticalPartyRegistryBackend', - shared: { - '@modern-js/plugin-bff': { - requiredVersion: bffVersion, - singleton: true, - treeShaking: false, - }, - '@module-federation/runtime': { - requiredVersion: dependencies['@module-federation/runtime'], - singleton: true, - treeShaking: false, - }, - effect: { - requiredVersion: effectVersion, - singleton: true, - treeShaking: false, - }, + effect: { + requiredVersion: effectVersion, + singleton: true, + treeShaking: false, }, - }); + }, +}); export default moduleFederationConfig; diff --git a/app/verticals/party-registry/modern.config.ts b/app/verticals/party-registry/modern.config.ts index 9900accde..87cb23970 100644 --- a/app/verticals/party-registry/modern.config.ts +++ b/app/verticals/party-registry/modern.config.ts @@ -1,13 +1,8 @@ import { readFileSync } from 'node:fs'; -import { - createCloudflareWorkerSecurity, - createWorkerSsrPlugins, - createZephyrRspackPlugin, - resolveCloudflareExternal, -} from '../../packages/shared-contracts/tooling/modern-config.ts'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import { appTools, defineConfig, presetUltramodern } from '@modern-js/app-tools'; + +import { appTools, defineConfig, presetUltramodern, ultramodernReleaseEnvelopePlugin } from '@modern-js/app-tools'; import type { AppTools, AppToolsUserConfig, CliPlugin } from '@modern-js/app-tools'; import { getBuildConfigEnvironment, withBuildConfigEnvironment } from '@modern-js/app-tools/config'; import { bffPlugin } from '@modern-js/plugin-bff'; @@ -18,6 +13,12 @@ import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss'; import { Config, Option, Result, Schema } from 'effect'; import { withZephyr as withZephyrRspack } from 'zephyr-rspack-plugin'; +import { + createCloudflareWorkerSecurity, + createWorkerSsrPlugins, + createZephyrRspackPlugin, + resolveCloudflareExternal, +} from '../../packages/shared-contracts/tooling/modern-config.ts'; import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadata'; const localisedUrls = ultramodernLocalisedUrls; @@ -29,18 +30,16 @@ const resolveDevelopmentModuleContractPath = () => const nonEmptyBuildStringSchema = Schema.Trim.pipe(Schema.check(Schema.isMinLength(1))); const getOptionalBuildConfig = (name: string): string | undefined => { - const decoded = Schema.decodeUnknownResult( - Schema.OptionFromUndefinedOr(nonEmptyBuildStringSchema), - )(getBuildConfigEnvironment(name)); + const decoded = Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(nonEmptyBuildStringSchema))( + getBuildConfigEnvironment(name), + ); return Result.isSuccess(decoded) ? Option.getOrUndefined(decoded.success) : undefined; }; const envValue = getOptionalBuildConfig; const getBuildBoolean = (name: string): boolean => Option.getOrElse( Result.getOrThrow( - Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Config.Boolean))( - getBuildConfigEnvironment(name), - ), + Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Config.Boolean))(getBuildConfigEnvironment(name)), ), () => false, ); @@ -52,8 +51,7 @@ const cloudflareDeployMode = Result.getOrThrow( const cloudflareDeployEnabled = Option.contains(cloudflareDeployMode, 'cloudflare'); const resolvePostgresProtocolCommonJsEntry = () => fileURLToPath(new URL('../pg-protocol/dist/index.js', import.meta.resolve('pg/package.json'))); -const resolvePostgresPoolCommonJsEntry = () => - createRequire(import.meta.resolve('pg/package.json')).resolve('pg-pool'); +const resolvePostgresPoolCommonJsEntry = () => createRequire(import.meta.resolve('pg/package.json')).resolve('pg-pool'); const resolveEffectApiSourceDirectory = () => fileURLToPath(new URL('api/', import.meta.url)); /* oxlint-disable promise/prefer-await-to-callbacks -- Rspack externals use a callback API. expires: 2026-12-31. */ const cloudflareRuntimeExternal = ( @@ -76,9 +74,7 @@ const port = Option.getOrElse( Result.getOrThrow( Schema.decodeUnknownResult( Schema.OptionFromUndefinedOr( - Schema.NumberFromString.pipe( - Schema.check(Schema.isInt(), Schema.isBetween({ maximum: 65_535, minimum: 1 })), - ), + Schema.NumberFromString.pipe(Schema.check(Schema.isInt(), Schema.isBetween({ maximum: 65_535, minimum: 1 }))), ), )(getBuildConfigEnvironment('VERTICAL_PARTY_REGISTRY_PORT')), ), @@ -88,8 +84,7 @@ const configuredSiteUrl = envValue('MODERN_PUBLIC_SITE_URL'); const configuredCloudflareUrl = envValue('ULTRAMODERN_PUBLIC_URL_PARTY_REGISTRY'); const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX'); const configuredModernAssetPrefix = envValue('MODERN_ASSET_PREFIX'); -const moduleFederationDevServerOrigin = - envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:3020'; +const moduleFederationDevServerOrigin = envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:3020'; const cloudflareWorkersDevSubdomain = envValue('ULTRAMODERN_CLOUDFLARE_WORKERS_DEV_SUBDOMAIN'); const inferredCloudflareUrl = cloudflareDeployEnabled && cloudflareWorkersDevSubdomain !== undefined @@ -97,28 +92,20 @@ const inferredCloudflareUrl = : undefined; // Site origin (SEO: canonical/hreflang URLs) prefers the site-wide public URL; // the per-app deployment URL only fills in when no site origin is configured. -const siteUrl = - configuredSiteUrl ?? - configuredCloudflareUrl ?? - inferredCloudflareUrl ?? - `http://localhost:${port}`; +const siteUrl = configuredSiteUrl ?? configuredCloudflareUrl ?? inferredCloudflareUrl ?? `http://localhost:${port}`; const remoteAssetOrigin = - configuredCloudflareUrl ?? - inferredCloudflareUrl ?? - (cloudflareDeployEnabled ? '' : `http://localhost:${port}`); + configuredCloudflareUrl ?? inferredCloudflareUrl ?? (cloudflareDeployEnabled ? '' : `http://localhost:${port}`); // When deploying to Cloudflare without a configured public URL, publish an // 'auto' publicPath so the remote resolves its chunks from the origin its // remoteEntry.js was loaded from (the vertical's Worker), not the host shell's // origin — otherwise cross-origin chunk loading 404s and MF reports an empty // moduleId. A configured/inferred URL still wins as an absolute prefix. -const defaultRemoteAssetPrefix = - remoteAssetOrigin.length > 0 ? `${remoteAssetOrigin.replace(/\/+$/u, '')}/` : 'auto'; +const defaultRemoteAssetPrefix = remoteAssetOrigin.length > 0 ? `${remoteAssetOrigin.replace(/\/+$/u, '')}/` : 'auto'; const defaultAssetPrefix = defaultRemoteAssetPrefix; // Asset loading is intentionally independent from the canonical site URL. // Module Federation remotes must publish an absolute publicPath so browsers // load remoteEntry.js and exposed chunks from the remote origin, not the host. -const assetPrefix = - configuredModernAssetPrefix ?? configuredUltramodernAssetPrefix ?? defaultAssetPrefix; +const assetPrefix = configuredModernAssetPrefix ?? configuredUltramodernAssetPrefix ?? defaultAssetPrefix; const buildTarget = cloudflareDeployEnabled ? 'cloudflare' : 'web'; const buildOutputRoot = cloudflareDeployEnabled ? 'dist-cloudflare' : 'dist'; const buildTempDirectory = `node_modules/.modern-js-${appId}-${buildTarget}`; @@ -139,9 +126,7 @@ if ( const whenEnabled = (enabled: boolean, configuration: Configuration) => enabled ? configuration : undefined; -const appDevServerHeaders: NonNullable< - NonNullable['server']>['headers'] -> = { +const appDevServerHeaders: NonNullable['server']>['headers']> = { 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, X-Requested-With', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Origin': moduleFederationDevServerOrigin, @@ -217,13 +202,10 @@ export default defineConfig( cacheDigest: [appId, buildTarget], cacheDirectory: buildCacheDirectory, }, - rsdoctor: { - disableClientServer: true, - enabled: getBuildBoolean('ULTRAMODERN_RSDOCTOR'), - }, }, plugins: [ appTools(), + ultramodernReleaseEnvelopePlugin(), tanstackRouterPlugin(), i18nPlugin({ backend: { @@ -255,7 +237,9 @@ export default defineConfig( reactI18next: false, }), bffPlugin(), - moduleFederationPlugin(), + moduleFederationPlugin({ + configPath: fileURLToPath(new URL('module-federation.config.ts', import.meta.url)), + }), zephyrRspackPlugin(), ], server: { @@ -267,8 +251,7 @@ export default defineConfig( '@modern-js/plugin-i18n/runtime': '@modern-js/plugin-i18n/runtime/no-react-i18next', }, globalVars: { - ULTRAMODERN_SHELL_ORIGIN: - envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:3020', + ULTRAMODERN_SHELL_ORIGIN: envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:3020', ULTRAMODERN_SITE_URL: siteUrl, }, mainEntryName: 'index', @@ -303,8 +286,7 @@ export default defineConfig( if (environment.name === 'workerSSR') { const effectApiSourceDirectory = resolveEffectApiSourceDirectory(); const configuredNode = config.node; - config.node = - configuredNode === false || configuredNode === undefined ? {} : configuredNode; + config.node = configuredNode === false || configuredNode === undefined ? {} : configuredNode; Object.assign(config.node, { __dirname: false, __filename: false, diff --git a/app/verticals/party-registry/module-federation.config.ts b/app/verticals/party-registry/module-federation.config.ts index 24013e242..7f688d41a 100644 --- a/app/verticals/party-registry/module-federation.config.ts +++ b/app/verticals/party-registry/module-federation.config.ts @@ -4,6 +4,7 @@ import { resolveEffectTsgoCompiler } from '@modern-js/app-tools/config'; import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; import * as Schema from 'effect/Schema'; +import { createSharedRuntimeConfig } from '../../module-federation.shared.ts'; import { dependencies } from './package.json'; const require = createRequire(import.meta.url); @@ -15,60 +16,45 @@ const runtimeVersion = packageVersion('@modern-js/runtime/package.json'); const reactVersion = packageVersion('react/package.json'); const reactDomVersion = packageVersion('react-dom/package.json'); -const tsgoCompilerInstance = resolveEffectTsgoCompiler({ from: import.meta.url }); -const moduleFederationConfig: Parameters[0] = - createModuleFederationConfig({ - dts: { - displayErrorInTerminal: true, - generateTypes: { compilerInstance: tsgoCompilerInstance }, - tsConfigPath: './tsconfig.mf-types.json', - }, - exposes: { - './PageContacts': './src/federation/page-contacts.tsx', - }, - filename: 'remoteEntry.js', - manifest: { - additionalData: ({ stats }) => ({ - ...stats, - exposes: stats.exposes.map((expose) => ({ - ...expose, - assets: { - ...expose.assets, - css: { - ...expose.assets.css, - async: expose.assets.css.async.filter((asset) => !asset.includes('/async-index.')), - }, +const tsgoCompilerInstance = resolveEffectTsgoCompiler({ + from: import.meta.url, +}); +const moduleFederationConfig: Parameters[0] = createModuleFederationConfig({ + bridge: { + enableBridgeRouter: false, + }, + dts: { + displayErrorInTerminal: true, + generateTypes: { compilerInstance: tsgoCompilerInstance }, + tsConfigPath: './tsconfig.mf-types.json', + }, + exposes: { + './PageContacts': './src/federation/page-contacts.tsx', + }, + filename: 'remoteEntry.js', + manifest: { + additionalData: ({ stats }) => ({ + ...stats, + exposes: stats.exposes.map((expose) => ({ + ...expose, + assets: { + ...expose.assets, + css: { + ...expose.assets.css, + async: expose.assets.css.async.filter((asset) => !asset.includes('/async-index.')), }, - })), - }), - }, - name: 'verticalPartyRegistry', - shared: { - '@modern-js/plugin-i18n/runtime': { - import: '@modern-js/plugin-i18n/runtime/no-react-i18next', - requiredVersion: i18nVersion, - singleton: true, - strictVersion: true, - treeShaking: false, - }, - '@modern-js/runtime': { - requiredVersion: runtimeVersion, - singleton: true, - treeShaking: false, - }, - '@tanstack/react-router': { - requiredVersion: dependencies['@tanstack/react-router'], - singleton: true, - treeShaking: false, - }, - react: { requiredVersion: reactVersion, singleton: true, treeShaking: false }, - 'react-dom': { requiredVersion: reactDomVersion, singleton: true, treeShaking: false }, - 'react-dom/client': { - requiredVersion: reactDomVersion, - singleton: true, - treeShaking: false, - }, - }, - }); + }, + })), + }), + }, + name: 'verticalPartyRegistry', + shared: createSharedRuntimeConfig({ + '@modern-js/plugin-i18n/runtime': i18nVersion, + '@modern-js/runtime': runtimeVersion, + '@tanstack/react-router': dependencies['@tanstack/react-router'], + react: reactVersion, + 'react-dom': reactDomVersion, + }), +}); export default moduleFederationConfig; diff --git a/app/verticals/party-registry/package.json b/app/verticals/party-registry/package.json index 7c56648e4..56a52d3a8 100644 --- a/app/verticals/party-registry/package.json +++ b/app/verticals/party-registry/package.json @@ -41,9 +41,9 @@ "./resources/person-engagement-profile": "./shared/resources/person-engagement-profile.ts" }, "scripts": { - "build": "modern build && node ../../scripts/generate-ontos-module-contract.mts --vertical party-registry --target dist && node ../../scripts/generate-public-surface-assets.mts --app party-registry --target dist && MODERNJS_DEPLOY=node modern deploy --skip-build", - "cloudflare:build": "MODERNJS_DEPLOY=cloudflare modern build && node ../../scripts/generate-ontos-module-contract.mts --vertical party-registry --target cloudflare-dist && node ../../scripts/generate-public-surface-assets.mts --app party-registry --target cloudflare-dist && MODERNJS_DEPLOY=cloudflare modern deploy --skip-build && node ../../scripts/verify-cloudflare-output.mts --app party-registry", - "cloudflare:deploy": "ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS=true pnpm run cloudflare:build && wrangler deploy --config .output/wrangler.json", + "build": "modern build && node ../../scripts/generate-ontos-module-contract.mts --vertical party-registry --target dist && node ../../scripts/generate-public-surface-assets.mts --app party-registry --target dist && cross-env MODERNJS_DEPLOY=node modern deploy --skip-build", + "cloudflare:build": "cross-env MODERNJS_DEPLOY=cloudflare modern build && node ../../scripts/generate-ontos-module-contract.mts --vertical party-registry --target cloudflare-dist && node ../../scripts/generate-public-surface-assets.mts --app party-registry --target cloudflare-dist && cross-env MODERNJS_DEPLOY=cloudflare modern deploy --skip-build && node ../../scripts/verify-cloudflare-output.mts --app party-registry", + "cloudflare:deploy": "cross-env ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS=true pnpm run cloudflare:build && wrangler deploy --config .output/wrangler.json", "cloudflare:preview": "pnpm run cloudflare:build && wrangler dev --config .output/wrangler.json", "cloudflare:proof": "node ../../scripts/proof-cloudflare-version.mts --app party-registry", "db:generate": "drizzle-kit generate --config drizzle.config.ts && drizzle-kit generate --config drizzle.contacts.config.ts", @@ -65,45 +65,46 @@ "@app/gateway-principal-verifier": "workspace:*", "@app/shared-contracts": "workspace:*", "@app/shared-design-tokens": "workspace:*", - "@effect/opentelemetry": "4.0.0-beta.107", - "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12", - "@modern-js/plugin-i18n": "npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12", - "@modern-js/plugin-tanstack": "npm:@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12", - "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12", - "@module-federation/modern-js-v3": "2.8.0", - "@module-federation/runtime": "2.8.0", - "@tanstack/react-router": "1.170.25", + "@effect/opentelemetry": "4.0.0-rc.112", + "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.9.0-ultramodern.4", + "@modern-js/plugin-i18n": "npm:@bleedingdev/modern-js-plugin-i18n@3.9.0-ultramodern.4", + "@modern-js/plugin-tanstack": "npm:@bleedingdev/modern-js-plugin-tanstack@3.9.0-ultramodern.4", + "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4", + "@module-federation/modern-js-v3": "2.9.0", + "@module-federation/runtime": "2.9.0", + "@tanstack/react-router": "1.170.33", "drizzle-orm": "1.0.0-rc.5-ab785fc", - "effect": "4.0.0-beta.107", - "i18next": "26.3.6", + "effect": "4.0.0-rc.112", + "i18next": "26.4.2", "pg": "8.22.0", "react": "19.2.8", "react-dom": "19.2.8", - "react-router": "7.18.1", - "@effect/sql-pg": "4.0.0-beta.107" + "@effect/sql-pg": "4.0.0-rc.112", + "@modern-js/runtime-extensions": "npm:@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.4" }, "devDependencies": { - "@effect/tsgo": "0.19.0", - "@modern-js/adapter-rstest": "npm:@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12", - "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12", + "@effect/tsgo": "0.41.0", + "@modern-js/adapter-rstest": "npm:@bleedingdev/modern-js-adapter-rstest@3.9.0-ultramodern.4", + "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.4", "@rsbuild/plugin-tailwindcss": "^2.0.3", "@rstest/core": "0.11.11", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", - "@types/node": "^20", + "@types/node": "^26.4.1", "@types/pg": "8.20.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", "@typescript/native": "npm:typescript@7.0.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "fast-check": "4.9.0", "happy-dom": "20.8.3", "jose": "6.2.5", - "tailwindcss": "^4.3.2", + "tailwindcss": "^4.3.3", "typescript": "7.0.2", - "wrangler": "4.110.0", + "wrangler": "4.116.0", "zephyr-rspack-plugin": "1.2.4", - "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc" + "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@79abbf684c7b150ee5f32694129a7caf969903bc", + "cross-env": "10.1.0" }, "modernjs": { "preset": "presetUltramodern", diff --git a/app/verticals/party-registry/rstest.config.ts b/app/verticals/party-registry/rstest.config.ts index 2a0274fbc..53a564f6c 100644 --- a/app/verticals/party-registry/rstest.config.ts +++ b/app/verticals/party-registry/rstest.config.ts @@ -1,11 +1,14 @@ import { createRequire } from 'node:module'; + import { withModernConfig } from '@modern-js/adapter-rstest'; import { defineConfig } from '@rstest/core'; Object.assign(globalThis, { require: createRequire(import.meta.url) }); // Migration scripts contain generic arrows in .mts files, supported by TypeScript. -const swc = { jsc: { parser: { disallowAmbiguousJsxLike: false, syntax: 'typescript' } } } as const; +const swc = { + jsc: { parser: { disallowAmbiguousJsxLike: false, syntax: 'typescript' } }, +} as const; export default defineConfig({ projects: [ diff --git a/app/verticals/party-registry/scripts/outbox-worker.ts b/app/verticals/party-registry/scripts/outbox-worker.ts index 4d124e7d9..e934af2b2 100644 --- a/app/verticals/party-registry/scripts/outbox-worker.ts +++ b/app/verticals/party-registry/scripts/outbox-worker.ts @@ -1,11 +1,8 @@ +import { extractOutboxWorkerSubscriptions, startOutboxWorkerProcess } from '@app/core-runtime/outbox/worker'; // @generated by scaffold:outbox-worker worker-host // @ontos-outbox-worker-host-owner party.registry import { Layer } from 'effect'; -import { - extractOutboxWorkerSubscriptions, - startOutboxWorkerProcess, -} from '@app/core-runtime/outbox/worker'; -import { outboxWorkers } from '../src/workers/index.ts'; + import { outboxWorkerCorePersistenceLive, outboxWorkerDatabaseConfigLive, @@ -13,6 +10,7 @@ import { outboxWorkerLayer as outboxWorkerDefinitionLayer, outboxWorkerRepositoryLive, } from '../src/worker-host/layer.ts'; +import { outboxWorkers } from '../src/workers/index.ts'; const outboxSubscriptions = extractOutboxWorkerSubscriptions(outboxWorkers); const outboxWorkerProcessLayer = outboxWorkerDefinitionLayer.pipe( diff --git a/app/verticals/party-registry/scripts/prepare-contacts-migration.mts b/app/verticals/party-registry/scripts/prepare-contacts-migration.mts index a5c35d355..7dcf7c258 100644 --- a/app/verticals/party-registry/scripts/prepare-contacts-migration.mts +++ b/app/verticals/party-registry/scripts/prepare-contacts-migration.mts @@ -1,32 +1,22 @@ import { pathToFileURL } from 'node:url'; + +import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; import { NodeFileSystem, NodeRuntime } from '@effect/platform-node'; import { Config, ConfigProvider, Duration, Effect, Layer, Redacted, Schema } from 'effect'; import { Client } from 'pg'; import type { QueryResult, QueryResultRow } from 'pg'; -import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; -export const ContactsJournalStateSchema = Schema.Literals([ - 'ambiguous', - 'contacts', - 'fresh', - 'legacy', -]); +export const ContactsJournalStateSchema = Schema.Literals(['ambiguous', 'contacts', 'fresh', 'legacy']); export type ContactsJournalState = typeof ContactsJournalStateSchema.Type; -class ContactsMigrationError extends Schema.TaggedError()( - 'ContactsMigrationError', - { - cause: Schema.Unknown, - message: Schema.String, - }, -) {} +class ContactsMigrationError extends Schema.TaggedError()('ContactsMigrationError', { + cause: Schema.Unknown, + message: Schema.String, +}) {} const POSTGRES_OPERATION_TIMEOUT = Duration.seconds(30); const requiredConnectionStringSchema = Schema.Trim.pipe(Schema.check(Schema.isMinLength(1))); -const databaseAdminUrl = Config.schema( - Schema.Redacted(requiredConnectionStringSchema), - 'DATABASE_ADMIN_URL', -); +const databaseAdminUrl = Config.schema(Schema.Redacted(requiredConnectionStringSchema), 'DATABASE_ADMIN_URL'); const fileConfigProvider = ConfigProvider.fromDotEnv({ path: APP_ENV_PATH, @@ -59,9 +49,7 @@ const query = ( }), ); -const connect = Effect.fn('ContactsMigration.connect')(function* connectEffect( - connectionString: Redacted.Redacted, -) { +const connect = Effect.fn('ContactsMigration.connect')(function* connectEffect(connectionString: Redacted.Redacted) { const client = yield* Effect.try({ catch: (cause) => databaseFailure('Unable to create the PostgreSQL client', cause), try: () => new Client({ connectionString: Redacted.value(connectionString) }), @@ -73,8 +61,7 @@ const connect = Effect.fn('ContactsMigration.connect')(function* connectEffect( }).pipe( Effect.timeoutOrElse({ duration: POSTGRES_OPERATION_TIMEOUT, - orElse: () => - Effect.fail(databaseFailure('PostgreSQL connection attempt timed out', 'timeout')), + orElse: () => Effect.fail(databaseFailure('PostgreSQL connection attempt timed out', 'timeout')), }), ); return client; @@ -88,15 +75,11 @@ const close = (client: Client) => }).pipe( Effect.timeoutOrElse({ duration: POSTGRES_OPERATION_TIMEOUT, - orElse: () => - Effect.fail(databaseFailure('PostgreSQL connection close timed out', 'timeout')), + orElse: () => Effect.fail(databaseFailure('PostgreSQL connection close timed out', 'timeout')), }), ); -export const classifyContactsJournalState = ( - legacy: boolean, - contacts: boolean, -): ContactsJournalState => { +export const classifyContactsJournalState = (legacy: boolean, contacts: boolean): ContactsJournalState => { if (legacy && contacts) { return 'ambiguous'; } @@ -109,37 +92,31 @@ export const classifyContactsJournalState = ( return 'fresh'; }; -export const prepareContactsMigration = Effect.fn('prepareContactsMigration')( - function* prepareContactsMigrationEffect(client: Client) { - return yield* Effect.gen(function* prepareContactsTransactionEffect() { - yield* query(client, 'begin'); - const result = yield* query<{ contacts: boolean; legacy: boolean }>( - client, - `select +export const prepareContactsMigration = Effect.fn('prepareContactsMigration')(function* prepareContactsMigrationEffect( + client: Client, +) { + return yield* Effect.gen(function* prepareContactsTransactionEffect() { + yield* query(client, 'begin'); + const result = yield* query<{ contacts: boolean; legacy: boolean }>( + client, + `select to_regclass('drizzle.__drizzle_migrations_crm') is not null as legacy, to_regclass('drizzle.__drizzle_migrations_contacts') is not null as contacts`, - ); - const state = classifyContactsJournalState( - result.rows[0]?.legacy === true, - result.rows[0]?.contacts === true, - ); - if (state === 'ambiguous') { - return yield* new ContactsMigrationError({ - cause: state, - message: 'Ambiguous Contacts migration state: both CRM and Contacts journals exist', - }); - } - if (state === 'legacy') { - yield* query( - client, - 'alter table drizzle.__drizzle_migrations_crm rename to __drizzle_migrations_contacts', - ); - } - yield* query(client, 'commit'); - return state; - }).pipe(Effect.tapError(() => query(client, 'rollback'))); - }, -); + ); + const state = classifyContactsJournalState(result.rows[0]?.legacy === true, result.rows[0]?.contacts === true); + if (state === 'ambiguous') { + return yield* new ContactsMigrationError({ + cause: state, + message: 'Ambiguous Contacts migration state: both CRM and Contacts journals exist', + }); + } + if (state === 'legacy') { + yield* query(client, 'alter table drizzle.__drizzle_migrations_crm rename to __drizzle_migrations_contacts'); + } + yield* query(client, 'commit'); + return state; + }).pipe(Effect.tapError(() => query(client, 'rollback'))); +}); const main = Effect.gen(function* mainEffect() { const connectionString = yield* databaseAdminUrl; diff --git a/app/verticals/party-registry/scripts/verify-db-schema.mts b/app/verticals/party-registry/scripts/verify-db-schema.mts index ca783de7f..e32b69e22 100644 --- a/app/verticals/party-registry/scripts/verify-db-schema.mts +++ b/app/verticals/party-registry/scripts/verify-db-schema.mts @@ -3,6 +3,7 @@ import { DatabaseConfig, loadDatabaseConfig, loadDatabaseConnectionPair } from ' import { sql } from 'drizzle-orm'; import { getTableConfig } from 'drizzle-orm/pg-core'; import { Effect, Layer, Schema } from 'effect'; + import { comparePartyCatalog } from '../src/db/catalog.ts'; import { PartyDatabase, PartyDatabaseLive } from '../src/db/client.ts'; import { PARTY_SCHEMA_NAME, PARTY_TABLES } from '../src/db/schema.ts'; @@ -107,9 +108,7 @@ const verification = Effect.gen(function* verifyPartyDatabase() { }), ), ); - const difference = comparePartyCatalog( - catalog.map((row) => `${PARTY_SCHEMA_NAME}.${row.table_name}`), - ); + const difference = comparePartyCatalog(catalog.map((row) => `${PARTY_SCHEMA_NAME}.${row.table_name}`)); if (difference.missing.length > 0 || difference.unexpected.length > 0) { return yield* new PartyDatabaseVerificationError({ reason: `Party Registry catalog mismatch; missing=[${difference.missing.join(', ')}], unexpected=[${difference.unexpected.join(', ')}]`, @@ -190,8 +189,7 @@ const verification = Effect.gen(function* verifyPartyDatabase() { ) ) { return yield* new PartyDatabaseVerificationError({ - reason: - 'Party Registry tables do not match their ownership, forced-RLS, policy, or runtime-grant contract', + reason: 'Party Registry tables do not match their ownership, forced-RLS, policy, or runtime-grant contract', }); } @@ -278,11 +276,7 @@ const verification = Effect.gen(function* verifyPartyDatabase() { return { typedTableCount: PARTY_TABLES.length }; }); -const runtime = PartyDatabaseLive.pipe( - Layer.provide(Layer.effect(DatabaseConfig, loadDatabaseConfig())), -); +const runtime = PartyDatabaseLive.pipe(Layer.provide(Layer.effect(DatabaseConfig, loadDatabaseConfig()))); const result = await Effect.runPromise(Effect.provide(verification, runtime)); -console.log( - `Verified ${result.typedTableCount} typed tables in PostgreSQL schema ${PARTY_SCHEMA_NAME}`, -); +console.log(`Verified ${result.typedTableCount} typed tables in PostgreSQL schema ${PARTY_SCHEMA_NAME}`); diff --git a/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts b/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts index 8a083031f..50e12b3ca 100644 --- a/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts +++ b/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts @@ -2,6 +2,7 @@ import { DatabaseConfig, loadDatabaseConfig, loadDatabaseConnectionPair } from '@app/core-runtime'; import { sql } from 'drizzle-orm'; import { Effect, Layer, Schema } from 'effect'; + import { PartyDatabase, PartyDatabaseLive } from '../src/db/client.ts'; import { compareContactsCatalog } from '../src/db/engagement-catalog.ts'; import { CONTACTS_SCHEMA_NAME, CONTACTS_TABLES } from '../src/db/engagement-schema.ts'; @@ -112,9 +113,7 @@ const verification = Effect.gen(function* verifyContactsDatabase() { }), ), ); - const difference = compareContactsCatalog( - catalog.map((row) => `${CONTACTS_SCHEMA_NAME}.${row.table_name}`), - ); + const difference = compareContactsCatalog(catalog.map((row) => `${CONTACTS_SCHEMA_NAME}.${row.table_name}`)); if (difference.missing.length > 0 || difference.unexpected.length > 0) { return yield* new ContactsDatabaseVerificationError({ reason: `Contacts catalog mismatch; missing=[${difference.missing.join(', ')}], unexpected=[${difference.unexpected.join(', ')}]`, @@ -135,7 +134,9 @@ const verification = Effect.gen(function* verifyContactsDatabase() { .pipe( Effect.mapError( () => - new ContactsDatabaseVerificationError({ reason: 'Unable to compare Contacts columns' }), + new ContactsDatabaseVerificationError({ + reason: 'Unable to compare Contacts columns', + }), ), ); const actualColumns = columns.map((row) => `${row.table_name}.${row.column_name}`); @@ -197,10 +198,6 @@ const verification = Effect.gen(function* verifyContactsDatabase() { return { typedTableCount: CONTACTS_TABLES.length }; }); -const runtime = PartyDatabaseLive.pipe( - Layer.provide(Layer.effect(DatabaseConfig, loadDatabaseConfig())), -); +const runtime = PartyDatabaseLive.pipe(Layer.provide(Layer.effect(DatabaseConfig, loadDatabaseConfig()))); const result = await Effect.runPromise(Effect.provide(verification, runtime)); -console.log( - `Verified ${result.typedTableCount} typed tables in PostgreSQL schema ${CONTACTS_SCHEMA_NAME}`, -); +console.log(`Verified ${result.typedTableCount} typed tables in PostgreSQL schema ${CONTACTS_SCHEMA_NAME}`); diff --git a/app/verticals/party-registry/shared/actions/add-contact-point.ts b/app/verticals/party-registry/shared/actions/add-contact-point.ts index 74658a5a7..c19d8543a 100644 --- a/app/verticals/party-registry/shared/actions/add-contact-point.ts +++ b/app/verticals/party-registry/shared/actions/add-contact-point.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated add-contact-point Action. import { Schema } from 'effect'; + import { ContactPointInputSchema, ContactPointPrivacyClassificationSchema, diff --git a/app/verticals/party-registry/shared/actions/add-party-official-identifier.ts b/app/verticals/party-registry/shared/actions/add-party-official-identifier.ts index 7acde35f6..02cdb78e9 100644 --- a/app/verticals/party-registry/shared/actions/add-party-official-identifier.ts +++ b/app/verticals/party-registry/shared/actions/add-party-official-identifier.ts @@ -1,8 +1,9 @@ // Canonical schema-only contract extracted from the generated add-party-official-identifier Action. import { Schema } from 'effect'; + import { AresAppliedEvidenceSchema } from '../domain/ares-application.ts'; -import { IsoTimestampSchema } from '../domain/identity-contracts.ts'; import { OfficialIdentifierInputSchema } from '../domain/identifier-contracts.ts'; +import { IsoTimestampSchema } from '../domain/identity-contracts.ts'; import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/actions/counterparty-create.ts b/app/verticals/party-registry/shared/actions/counterparty-create.ts index a60ac3043..b72b4bed9 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-create.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-create.ts @@ -1,9 +1,7 @@ // Canonical schema-only contract extracted from the generated counterparty-create Action. import { Schema } from 'effect'; -import { - CounterpartyCreationProvenanceSchema, - LegalEntityRefSchema, -} from '../domain/counterparty-contract.ts'; + +import { CounterpartyCreationProvenanceSchema, LegalEntityRefSchema } from '../domain/counterparty-contract.ts'; import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/actions/counterparty-role-add.ts b/app/verticals/party-registry/shared/actions/counterparty-role-add.ts index 254e3ff0c..99aef7a4d 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-role-add.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-role-add.ts @@ -1,12 +1,13 @@ // Canonical schema-only contract extracted from the generated counterparty-role-add Action. import { Schema } from 'effect'; + import { CounterpartyIsoTimestampSchema, CounterpartyProvenanceSchema, CounterpartyRoleTypeSchema, } from '../domain/counterparty-contract.ts'; -import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; +import { CounterpartyRefSchema } from '../resources/counterparty.ts'; export const CounterpartyRoleAddPayloadSchema = Schema.Struct({ counterpartyRef: CounterpartyRefSchema, diff --git a/app/verticals/party-registry/shared/actions/counterparty-role-end.ts b/app/verticals/party-registry/shared/actions/counterparty-role-end.ts index 67178a524..8b785827f 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-role-end.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-role-end.ts @@ -1,12 +1,13 @@ // Canonical schema-only contract extracted from the generated counterparty-role-end Action. import { Schema } from 'effect'; + import { CounterpartyIsoTimestampSchema, CounterpartyProvenanceSchema, CounterpartyRoleTypeSchema, } from '../domain/counterparty-contract.ts'; -import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; +import { CounterpartyRefSchema } from '../resources/counterparty.ts'; export const CounterpartyRoleEndPayloadSchema = Schema.Struct({ counterpartyRef: CounterpartyRefSchema, diff --git a/app/verticals/party-registry/shared/actions/create-party.ts b/app/verticals/party-registry/shared/actions/create-party.ts index e88142ec7..78df80bda 100644 --- a/app/verticals/party-registry/shared/actions/create-party.ts +++ b/app/verticals/party-registry/shared/actions/create-party.ts @@ -1,9 +1,12 @@ // Canonical schema-only contract extracted from the generated create-party Action. import { Schema } from 'effect'; + import { PartyCandidateSchema } from '../domain/identity-contracts.ts'; import type { PartyCandidate } from '../domain/identity-contracts.ts'; -export const CreatePartyPayloadSchema = Schema.Struct({ candidate: PartyCandidateSchema }); +export const CreatePartyPayloadSchema = Schema.Struct({ + candidate: PartyCandidateSchema, +}); export const CreatePartyPayloadJsonSchema = Schema.toEncoded(CreatePartyPayloadSchema); export interface CreatePartyPayload { readonly candidate: PartyCandidate; diff --git a/app/verticals/party-registry/shared/actions/end-contact-point.ts b/app/verticals/party-registry/shared/actions/end-contact-point.ts index cbae7cc4e..479ffaa4e 100644 --- a/app/verticals/party-registry/shared/actions/end-contact-point.ts +++ b/app/verticals/party-registry/shared/actions/end-contact-point.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated end-contact-point Action. import { Schema } from 'effect'; + import { AddressPurposeTargetSchema, ContactPointProvenanceSchema, @@ -9,7 +10,10 @@ import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts' const EndTargetSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal('WHOLE_CONTACT_POINT') }), - Schema.Struct({ target: AddressPurposeTargetSchema, type: Schema.Literal('ADDRESS_PURPOSE') }), + Schema.Struct({ + target: AddressPurposeTargetSchema, + type: Schema.Literal('ADDRESS_PURPOSE'), + }), ]); export const EndContactPointPayloadSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/actions/end-party-official-identifier.ts b/app/verticals/party-registry/shared/actions/end-party-official-identifier.ts index ae3d74ea0..b399d931f 100644 --- a/app/verticals/party-registry/shared/actions/end-party-official-identifier.ts +++ b/app/verticals/party-registry/shared/actions/end-party-official-identifier.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated end-party-official-identifier Action. import { Schema } from 'effect'; + import { IsoTimestampSchema } from '../domain/identity-contracts.ts'; import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/actions/match-party.ts b/app/verticals/party-registry/shared/actions/match-party.ts index 557d98054..c207d317a 100644 --- a/app/verticals/party-registry/shared/actions/match-party.ts +++ b/app/verticals/party-registry/shared/actions/match-party.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated match-party Action. import { Schema } from 'effect'; + import { PartyMatchRequestSchema } from '../domain/matching-contracts.ts'; import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; diff --git a/app/verticals/party-registry/shared/actions/request-search-rebuild.ts b/app/verticals/party-registry/shared/actions/request-search-rebuild.ts index a7f2e8541..af87dcc4a 100644 --- a/app/verticals/party-registry/shared/actions/request-search-rebuild.ts +++ b/app/verticals/party-registry/shared/actions/request-search-rebuild.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated request-search-rebuild Action. import { Schema } from 'effect'; + import { ActionInvocationIdSchema } from '../domain/correction-contracts.ts'; export const RequestSearchRebuildPayloadSchema = Schema.Struct({}); diff --git a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts index d1bf74cd4..51d21ab39 100644 --- a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts +++ b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts @@ -2,6 +2,5 @@ import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; export const ResolveDuplicateCandidateCreatePayloadSchema = DuplicateCaseResolutionPayloadSchema; -export type ResolveDuplicateCandidateCreatePayload = - typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; +export type ResolveDuplicateCandidateCreatePayload = typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; export { DuplicateCaseResolutionResultSchema as ResolveDuplicateCandidateCreateResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts index e64191039..672239bdf 100644 --- a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts +++ b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated resolve-duplicate-candidate-match Action. import { Schema } from 'effect'; + import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; import { PartyRefSchema } from '../resources/party.ts'; @@ -7,6 +8,5 @@ export const ResolveDuplicateCandidateMatchPayloadSchema = Schema.Struct({ ...DuplicateCaseResolutionPayloadSchema.fields, selectedPartyRef: PartyRefSchema, }); -export type ResolveDuplicateCandidateMatchPayload = - typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; +export type ResolveDuplicateCandidateMatchPayload = typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; export { DuplicateCaseResolutionResultSchema as ResolveDuplicateCandidateMatchResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/unarchive-party.ts b/app/verticals/party-registry/shared/actions/unarchive-party.ts index 94b60f297..a080d0ab7 100644 --- a/app/verticals/party-registry/shared/actions/unarchive-party.ts +++ b/app/verticals/party-registry/shared/actions/unarchive-party.ts @@ -1,9 +1,10 @@ // Canonical schema-only contract extracted from the generated unarchive-party Action. import { Schema } from 'effect'; + import { PartySchema } from '../domain/identity-contracts.ts'; -import { PartyRefSchema } from '../resources/party.ts'; import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; import { PartyMatchDecisionRefSchema } from '../resources/party-match-decision.ts'; +import { PartyRefSchema } from '../resources/party.ts'; export const UnarchivePartyPayloadSchema = Schema.Struct({ expectedRevision: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThan(0)), diff --git a/app/verticals/party-registry/shared/actions/update-contact-point.ts b/app/verticals/party-registry/shared/actions/update-contact-point.ts index d692b3b7f..8eb664395 100644 --- a/app/verticals/party-registry/shared/actions/update-contact-point.ts +++ b/app/verticals/party-registry/shared/actions/update-contact-point.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated update-contact-point Action. import { Schema } from 'effect'; + import { AddressPurposeAssignmentSchema, AddressPurposeTargetSchema, @@ -12,7 +13,10 @@ import { import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; const ContactPointMetadataChangeSchema = Schema.Union([ - Schema.Struct({ preferred: Schema.Boolean, type: Schema.Literal('SET_CHANNEL_PREFERRED') }), + Schema.Struct({ + preferred: Schema.Boolean, + type: Schema.Literal('SET_CHANNEL_PREFERRED'), + }), Schema.Struct({ assignment: AddressPurposeAssignmentSchema, type: Schema.Literal('SET_ADDRESS_PURPOSE'), @@ -32,9 +36,10 @@ const ContactPointMetadataChangeSchema = Schema.Union([ type: Schema.Literal('ADD_PROVENANCE'), }), Schema.Struct({ - evidenceReferences: Schema.Array( - Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)), - ).check(Schema.isMinLength(1), Schema.isMaxLength(20)), + evidenceReferences: Schema.Array(Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( + Schema.isMinLength(1), + Schema.isMaxLength(20), + ), reason: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)), replacement: Schema.optionalKey( Schema.Struct({ diff --git a/app/verticals/party-registry/shared/actions/update-party-official-identifier.ts b/app/verticals/party-registry/shared/actions/update-party-official-identifier.ts index 4e3cc9ae3..a7018e65a 100644 --- a/app/verticals/party-registry/shared/actions/update-party-official-identifier.ts +++ b/app/verticals/party-registry/shared/actions/update-party-official-identifier.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated update-party-official-identifier Action. import { Schema } from 'effect'; + import { IdentifierVerificationSchema, OfficialIdentifierAssertionStateSchema, @@ -15,11 +16,15 @@ export const UpdatePartyOfficialIdentifierPayloadSchema = Schema.Struct({ type: Schema.Literal('SET_VERIFICATION'), verification: IdentifierVerificationSchema, }), - Schema.Struct({ type: Schema.Literal('END_VALIDITY'), validTo: IsoTimestampSchema }), + Schema.Struct({ + type: Schema.Literal('END_VALIDITY'), + validTo: IsoTimestampSchema, + }), ]), - evidenceRefs: Schema.Array( - Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)), - ).check(Schema.isMinLength(1), Schema.isMaxLength(20)), + evidenceRefs: Schema.Array(Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( + Schema.isMinLength(1), + Schema.isMaxLength(20), + ), identifierType: Schema.optionalKey(Schema.Never), namespace: Schema.optionalKey(Schema.Never), normalizedValue: Schema.optionalKey(Schema.Never), @@ -28,8 +33,7 @@ export const UpdatePartyOfficialIdentifierPayloadSchema = Schema.Struct({ reason: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(1000)), value: Schema.optionalKey(Schema.Never), }); -export type UpdatePartyOfficialIdentifierPayload = - typeof UpdatePartyOfficialIdentifierPayloadSchema.Type; +export type UpdatePartyOfficialIdentifierPayload = typeof UpdatePartyOfficialIdentifierPayloadSchema.Type; export const UpdatePartyOfficialIdentifierResultSchema = Schema.Struct({ officialIdentifierRef: PartyOfficialIdentifierRefSchema, @@ -38,5 +42,4 @@ export const UpdatePartyOfficialIdentifierResultSchema = Schema.Struct({ validTo: Schema.OptionFromNullOr(IsoTimestampSchema), verification: IdentifierVerificationSchema, }); -export type UpdatePartyOfficialIdentifierResult = - typeof UpdatePartyOfficialIdentifierResultSchema.Type; +export type UpdatePartyOfficialIdentifierResult = typeof UpdatePartyOfficialIdentifierResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/update-party.ts b/app/verticals/party-registry/shared/actions/update-party.ts index 3f00d65fd..1df86bb5c 100644 --- a/app/verticals/party-registry/shared/actions/update-party.ts +++ b/app/verticals/party-registry/shared/actions/update-party.ts @@ -1,5 +1,6 @@ // Canonical schema-only contract extracted from the generated update-party Action. import { Schema } from 'effect'; + import { AresAppliedEvidenceSchema } from '../domain/ares-application.ts'; import { IsoTimestampSchema, diff --git a/app/verticals/party-registry/shared/api.ts b/app/verticals/party-registry/shared/api.ts index c11889064..14462195b 100644 --- a/app/verticals/party-registry/shared/api.ts +++ b/app/verticals/party-registry/shared/api.ts @@ -1,16 +1,11 @@ -/* eslint-disable oxc/no-barrel-file -- The published Effect API entrypoint composes and exports all governed owner contracts. expires: 2026-12-31. */ -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - Schema, -} from '@modern-js/plugin-bff/effect-client'; import { MicroVerticalBuildMarkerSchema, MicroVerticalReadinessSchema, createMicroVerticalOperationContext, } from '@app/shared-contracts'; import type { MicroVerticalOperationContext } from '@app/shared-contracts'; +/* eslint-disable oxc/no-barrel-file -- The published Effect API entrypoint composes and exports all governed owner contracts. expires: 2026-12-31. */ +import { HttpApi, HttpApiEndpoint, HttpApiGroup, Schema } from '@modern-js/plugin-bff/effect-client'; import { Brand, identity } from 'effect'; // @@ -25,8 +20,8 @@ import { PartyContactPointDetailApi } from './apis/party-contact-point-detail.ts import { PartyContactPointsApi } from './apis/party-contact-points.ts'; import { PartyCorrectionApi } from './apis/party-correction.ts'; import { PartyDetailApi } from './apis/party-detail.ts'; -import { PartyMatchApi } from './apis/party-match.ts'; import { PartyMatchDecisionApi } from './apis/party-match-decision.ts'; +import { PartyMatchApi } from './apis/party-match.ts'; import { PartyMergeReadinessApi } from './apis/party-merge-readiness.ts'; import { PartyOfficialIdentifierDetailApi } from './apis/party-official-identifier-detail.ts'; import { PartyOfficialIdentifierHistoryApi } from './apis/party-official-identifier-history.ts'; @@ -34,10 +29,7 @@ import { PartyRelationshipDetailApi } from './apis/party-relationship-detail.ts' import { PersonEngagementProfileApi } from './apis/person-engagement-profile.ts'; // import { partyRegistryCommandRecoveryApi, partyRegistryCommandsApi } from './command-api.ts'; -import { - organizationEngagementMutationApi, - personEngagementMutationApi, -} from './engagement-profile-api.ts'; +import { organizationEngagementMutationApi, personEngagementMutationApi } from './engagement-profile-api.ts'; export * from './command-api.ts'; export * from './engagement-profile-api.ts'; diff --git a/app/verticals/party-registry/shared/apis/ares-lookup.ts b/app/verticals/party-registry/shared/apis/ares-lookup.ts index a4e68024a..5c15c57fa 100644 --- a/app/verticals/party-registry/shared/apis/ares-lookup.ts +++ b/app/verticals/party-registry/shared/apis/ares-lookup.ts @@ -1,49 +1,28 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { AresSubjectEvidenceSchema, AresSubjectLookupIcoSchema } from '../domain/ares-evidence.ts'; -export const AresLookupRequestSchema = Schema.Struct({ ico: AresSubjectLookupIcoSchema }); +export const AresLookupRequestSchema = Schema.Struct({ + ico: AresSubjectLookupIcoSchema, +}); export type AresLookupRequest = typeof AresLookupRequestSchema.Type; export const AresLookupResponseSchema = AresSubjectEvidenceSchema; export type AresLookupResponse = typeof AresLookupResponseSchema.Type; -export const AresLookupInvalidProblemSchema = makeProblemDetailsSchema( - 'AresLookupInvalidProblem', - 400, -); -export const AresLookupAuthenticationProblemSchema = makeProblemDetailsSchema( - 'AresLookupAuthenticationProblem', - 401, -); -export const AresLookupForbiddenProblemSchema = makeProblemDetailsSchema( - 'AresLookupForbiddenProblem', - 403, -); -export const AresLookupNotFoundProblemSchema = makeProblemDetailsSchema( - 'AresLookupNotFoundProblem', - 404, -); -export const AresLookupPolicyConflictProblemSchema = makeProblemDetailsSchema( - 'AresLookupPolicyConflictProblem', - 409, -); -export const AresLookupPolicyProblemSchema = makeProblemDetailsSchema( - 'AresLookupPolicyProblem', - 422, -); +export const AresLookupInvalidProblemSchema = makeProblemDetailsSchema('AresLookupInvalidProblem', 400); +export const AresLookupAuthenticationProblemSchema = makeProblemDetailsSchema('AresLookupAuthenticationProblem', 401); +export const AresLookupForbiddenProblemSchema = makeProblemDetailsSchema('AresLookupForbiddenProblem', 403); +export const AresLookupNotFoundProblemSchema = makeProblemDetailsSchema('AresLookupNotFoundProblem', 404); +export const AresLookupPolicyConflictProblemSchema = makeProblemDetailsSchema('AresLookupPolicyConflictProblem', 409); +export const AresLookupPolicyProblemSchema = makeProblemDetailsSchema('AresLookupPolicyProblem', 422); export const AresLookupUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'AresLookupUnavailableProblem', 503, ); -export const AresLookupInternalProblemSchema = makeProblemDetailsSchema( - 'AresLookupInternalProblem', - 500, -); +export const AresLookupInternalProblemSchema = makeProblemDetailsSchema('AresLookupInternalProblem', 500); export const AresLookupApi = HttpApi.make('AresLookupApi').add( HttpApiGroup.make('aresLookup').add( diff --git a/app/verticals/party-registry/shared/apis/counterparties-search.ts b/app/verticals/party-registry/shared/apis/counterparties-search.ts index d109cdcd6..b6d3fe8e4 100644 --- a/app/verticals/party-registry/shared/apis/counterparties-search.ts +++ b/app/verticals/party-registry/shared/apis/counterparties-search.ts @@ -1,11 +1,9 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { CounterpartySearchResultSchema, CurrentCounterpartyRoleSchema, diff --git a/app/verticals/party-registry/shared/apis/counterparty-read.ts b/app/verticals/party-registry/shared/apis/counterparty-read.ts index 3f207b6fb..e096c77a7 100644 --- a/app/verticals/party-registry/shared/apis/counterparty-read.ts +++ b/app/verticals/party-registry/shared/apis/counterparty-read.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { CounterpartyPartyProjectionSchema, CounterpartyIsoTimestampSchema, @@ -30,34 +28,19 @@ export const CounterpartyReadAuthenticationProblemSchema = makeProblemDetailsSch 'CounterpartyReadAuthenticationProblem', 401, ); -export const CounterpartyReadInvalidProblemSchema = makeProblemDetailsSchema( - 'CounterpartyReadInvalidProblem', - 400, -); +export const CounterpartyReadInvalidProblemSchema = makeProblemDetailsSchema('CounterpartyReadInvalidProblem', 400); export const CounterpartyReadUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'CounterpartyReadUnavailableProblem', 503, ); -export const CounterpartyReadForbiddenProblemSchema = makeProblemDetailsSchema( - 'CounterpartyReadForbiddenProblem', - 403, -); -export const CounterpartyReadNotFoundProblemSchema = makeProblemDetailsSchema( - 'CounterpartyReadNotFoundProblem', - 404, -); -export const CounterpartyReadPolicyProblemSchema = makeProblemDetailsSchema( - 'CounterpartyReadPolicyProblem', - 422, -); +export const CounterpartyReadForbiddenProblemSchema = makeProblemDetailsSchema('CounterpartyReadForbiddenProblem', 403); +export const CounterpartyReadNotFoundProblemSchema = makeProblemDetailsSchema('CounterpartyReadNotFoundProblem', 404); +export const CounterpartyReadPolicyProblemSchema = makeProblemDetailsSchema('CounterpartyReadPolicyProblem', 422); export const CounterpartyReadPolicyConflictProblemSchema = makeProblemDetailsSchema( 'CounterpartyReadPolicyConflictProblem', 409, ); -export const CounterpartyReadInternalProblemSchema = makeProblemDetailsSchema( - 'CounterpartyReadInternalProblem', - 500, -); +export const CounterpartyReadInternalProblemSchema = makeProblemDetailsSchema('CounterpartyReadInternalProblem', 500); export const CounterpartyReadApi = HttpApi.make('CounterpartyReadApi').add( HttpApiGroup.make('counterpartyRead').add( diff --git a/app/verticals/party-registry/shared/apis/counterparty-role-history.ts b/app/verticals/party-registry/shared/apis/counterparty-role-history.ts index 03fc45af1..8c3868c6e 100644 --- a/app/verticals/party-registry/shared/apis/counterparty-role-history.ts +++ b/app/verticals/party-registry/shared/apis/counterparty-role-history.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { CounterpartyRolePeriodSchema } from '../domain/counterparty-contract.ts'; import { CounterpartyRefSchema } from '../resources/counterparty.ts'; diff --git a/app/verticals/party-registry/shared/apis/duplicate-candidate-detail.ts b/app/verticals/party-registry/shared/apis/duplicate-candidate-detail.ts index f69e855b0..d82e03476 100644 --- a/app/verticals/party-registry/shared/apis/duplicate-candidate-detail.ts +++ b/app/verticals/party-registry/shared/apis/duplicate-candidate-detail.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { DuplicateCandidateDetailSchema } from '../domain/matching-contracts.ts'; import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; diff --git a/app/verticals/party-registry/shared/apis/organization-engagement-profile.ts b/app/verticals/party-registry/shared/apis/organization-engagement-profile.ts index 5c960835b..b5d3ba6c5 100644 --- a/app/verticals/party-registry/shared/apis/organization-engagement-profile.ts +++ b/app/verticals/party-registry/shared/apis/organization-engagement-profile.ts @@ -1,21 +1,17 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { OrganizationEngagementProfileSchema } from '../domain/engagement-profile.ts'; import { OrganizationEngagementProfileRefSchema } from '../resources/organization-engagement-profile.ts'; export const OrganizationEngagementProfileRequestSchema = Schema.Struct({ profileRef: OrganizationEngagementProfileRefSchema, }); -export type OrganizationEngagementProfileRequest = - typeof OrganizationEngagementProfileRequestSchema.Type; +export type OrganizationEngagementProfileRequest = typeof OrganizationEngagementProfileRequestSchema.Type; export const OrganizationEngagementProfileResponseSchema = OrganizationEngagementProfileSchema; -export type OrganizationEngagementProfileResponse = - typeof OrganizationEngagementProfileResponseSchema.Type; +export type OrganizationEngagementProfileResponse = typeof OrganizationEngagementProfileResponseSchema.Type; export const OrganizationEngagementProfileAuthenticationProblemSchema = makeProblemDetailsSchema( 'OrganizationEngagementProfileAuthenticationProblem', @@ -25,8 +21,10 @@ export const OrganizationEngagementProfileInvalidProblemSchema = makeProblemDeta 'OrganizationEngagementProfileInvalidProblem', 400, ); -export const OrganizationEngagementProfileUnavailableProblemSchema = - makeRetryableProblemDetailsSchema('OrganizationEngagementProfileUnavailableProblem', 503); +export const OrganizationEngagementProfileUnavailableProblemSchema = makeRetryableProblemDetailsSchema( + 'OrganizationEngagementProfileUnavailableProblem', + 503, +); export const OrganizationEngagementProfileForbiddenProblemSchema = makeProblemDetailsSchema( 'OrganizationEngagementProfileForbiddenProblem', 403, @@ -48,9 +46,7 @@ export const OrganizationEngagementProfileInternalProblemSchema = makeProblemDet 500, ); -export const OrganizationEngagementProfileApi = HttpApi.make( - 'OrganizationEngagementProfileApi', -).add( +export const OrganizationEngagementProfileApi = HttpApi.make('OrganizationEngagementProfileApi').add( HttpApiGroup.make('organizationEngagementProfile').add( HttpApiEndpoint.post('execute', '/reads/organization-engagement-profile', { error: [ diff --git a/app/verticals/party-registry/shared/apis/parties-search.ts b/app/verticals/party-registry/shared/apis/parties-search.ts index 38900aa5f..e1d93eabc 100644 --- a/app/verticals/party-registry/shared/apis/parties-search.ts +++ b/app/verticals/party-registry/shared/apis/parties-search.ts @@ -1,11 +1,9 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PartySearchQuerySchema, PartySearchResultSchema } from '../domain/search-result.ts'; export const PartiesProviderRequestSchema = Schema.Struct({ @@ -26,30 +24,15 @@ export const PartiesProviderAuthenticationProblemSchema = makeProblemDetailsSche 'PartiesProviderAuthenticationProblem', 401, ); -export const PartiesProviderInvalidProblemSchema = makeProblemDetailsSchema( - 'PartiesProviderInvalidProblem', - 400, -); -export const PartiesProviderForbiddenProblemSchema = makeProblemDetailsSchema( - 'PartiesProviderForbiddenProblem', - 403, -); -export const PartiesProviderNotFoundProblemSchema = makeProblemDetailsSchema( - 'PartiesProviderNotFoundProblem', - 404, -); -export const PartiesProviderPolicyProblemSchema = makeProblemDetailsSchema( - 'PartiesProviderPolicyProblem', - 422, -); +export const PartiesProviderInvalidProblemSchema = makeProblemDetailsSchema('PartiesProviderInvalidProblem', 400); +export const PartiesProviderForbiddenProblemSchema = makeProblemDetailsSchema('PartiesProviderForbiddenProblem', 403); +export const PartiesProviderNotFoundProblemSchema = makeProblemDetailsSchema('PartiesProviderNotFoundProblem', 404); +export const PartiesProviderPolicyProblemSchema = makeProblemDetailsSchema('PartiesProviderPolicyProblem', 422); export const PartiesProviderPolicyConflictProblemSchema = makeProblemDetailsSchema( 'PartiesProviderPolicyConflictProblem', 409, ); -export const PartiesProviderInternalProblemSchema = makeProblemDetailsSchema( - 'PartiesProviderInternalProblem', - 500, -); +export const PartiesProviderInternalProblemSchema = makeProblemDetailsSchema('PartiesProviderInternalProblem', 500); export const PartiesSearchApi = HttpApi.make('PartiesSearchApi').add( HttpApiGroup.make('partiesSearch').add( diff --git a/app/verticals/party-registry/shared/apis/party-contact-point-detail.ts b/app/verticals/party-registry/shared/apis/party-contact-point-detail.ts index abe3d66cd..392306a03 100644 --- a/app/verticals/party-registry/shared/apis/party-contact-point-detail.ts +++ b/app/verticals/party-registry/shared/apis/party-contact-point-detail.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PartyContactPointSchema } from '../domain/contact-point.ts'; import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; diff --git a/app/verticals/party-registry/shared/apis/party-contact-points.ts b/app/verticals/party-registry/shared/apis/party-contact-points.ts index 34846e8b5..418105708 100644 --- a/app/verticals/party-registry/shared/apis/party-contact-points.ts +++ b/app/verticals/party-registry/shared/apis/party-contact-points.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { ContactPointTypeSchema, PartyContactPointSchema } from '../domain/contact-point.ts'; import { PartyRefSchema } from '../resources/party.ts'; @@ -23,10 +21,7 @@ export const PartyContactPointsAuthenticationProblemSchema = makeProblemDetailsS 'PartyContactPointsAuthenticationProblem', 401, ); -export const PartyContactPointsInvalidProblemSchema = makeProblemDetailsSchema( - 'PartyContactPointsInvalidProblem', - 400, -); +export const PartyContactPointsInvalidProblemSchema = makeProblemDetailsSchema('PartyContactPointsInvalidProblem', 400); export const PartyContactPointsUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'PartyContactPointsUnavailableProblem', 503, @@ -39,10 +34,7 @@ export const PartyContactPointsNotFoundProblemSchema = makeProblemDetailsSchema( 'PartyContactPointsNotFoundProblem', 404, ); -export const PartyContactPointsPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyContactPointsPolicyProblem', - 422, -); +export const PartyContactPointsPolicyProblemSchema = makeProblemDetailsSchema('PartyContactPointsPolicyProblem', 422); export const PartyContactPointsPolicyConflictProblemSchema = makeProblemDetailsSchema( 'PartyContactPointsPolicyConflictProblem', 409, diff --git a/app/verticals/party-registry/shared/apis/party-correction.ts b/app/verticals/party-registry/shared/apis/party-correction.ts index 648a80332..38b828f63 100644 --- a/app/verticals/party-registry/shared/apis/party-correction.ts +++ b/app/verticals/party-registry/shared/apis/party-correction.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PartyCorrectionDetailSchema } from '../domain/correction-contracts.ts'; import { PartyCorrectionRefSchema } from '../resources/party-correction.ts'; @@ -19,34 +17,19 @@ export const PartyCorrectionAuthenticationProblemSchema = makeProblemDetailsSche 'PartyCorrectionAuthenticationProblem', 401, ); -export const PartyCorrectionInvalidProblemSchema = makeProblemDetailsSchema( - 'PartyCorrectionInvalidProblem', - 400, -); +export const PartyCorrectionInvalidProblemSchema = makeProblemDetailsSchema('PartyCorrectionInvalidProblem', 400); export const PartyCorrectionUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'PartyCorrectionUnavailableProblem', 503, ); -export const PartyCorrectionForbiddenProblemSchema = makeProblemDetailsSchema( - 'PartyCorrectionForbiddenProblem', - 403, -); -export const PartyCorrectionNotFoundProblemSchema = makeProblemDetailsSchema( - 'PartyCorrectionNotFoundProblem', - 404, -); -export const PartyCorrectionPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyCorrectionPolicyProblem', - 422, -); +export const PartyCorrectionForbiddenProblemSchema = makeProblemDetailsSchema('PartyCorrectionForbiddenProblem', 403); +export const PartyCorrectionNotFoundProblemSchema = makeProblemDetailsSchema('PartyCorrectionNotFoundProblem', 404); +export const PartyCorrectionPolicyProblemSchema = makeProblemDetailsSchema('PartyCorrectionPolicyProblem', 422); export const PartyCorrectionPolicyConflictProblemSchema = makeProblemDetailsSchema( 'PartyCorrectionPolicyConflictProblem', 409, ); -export const PartyCorrectionInternalProblemSchema = makeProblemDetailsSchema( - 'PartyCorrectionInternalProblem', - 500, -); +export const PartyCorrectionInternalProblemSchema = makeProblemDetailsSchema('PartyCorrectionInternalProblem', 500); export const PartyCorrectionApi = HttpApi.make('PartyCorrectionApi').add( HttpApiGroup.make('partyCorrection').add( diff --git a/app/verticals/party-registry/shared/apis/party-detail.ts b/app/verticals/party-registry/shared/apis/party-detail.ts index 172fea544..7b6ca0faf 100644 --- a/app/verticals/party-registry/shared/apis/party-detail.ts +++ b/app/verticals/party-registry/shared/apis/party-detail.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { AresAppliedEvidenceSchema } from '../domain/ares-application.ts'; import { AssertionIdSchema } from '../domain/correction-contracts.ts'; import { IsoTimestampSchema, PartySchema } from '../domain/identity-contracts.ts'; @@ -48,38 +46,17 @@ export const PartyDetailResponseSchema = Schema.Struct({ }); export type PartyDetailResponse = typeof PartyDetailResponseSchema.Type; -export const PartyDetailAuthenticationProblemSchema = makeProblemDetailsSchema( - 'PartyDetailAuthenticationProblem', - 401, -); -export const PartyDetailInvalidProblemSchema = makeProblemDetailsSchema( - 'PartyDetailInvalidProblem', - 400, -); +export const PartyDetailAuthenticationProblemSchema = makeProblemDetailsSchema('PartyDetailAuthenticationProblem', 401); +export const PartyDetailInvalidProblemSchema = makeProblemDetailsSchema('PartyDetailInvalidProblem', 400); export const PartyDetailUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'PartyDetailUnavailableProblem', 503, ); -export const PartyDetailForbiddenProblemSchema = makeProblemDetailsSchema( - 'PartyDetailForbiddenProblem', - 403, -); -export const PartyDetailNotFoundProblemSchema = makeProblemDetailsSchema( - 'PartyDetailNotFoundProblem', - 404, -); -export const PartyDetailPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyDetailPolicyProblem', - 422, -); -export const PartyDetailPolicyConflictProblemSchema = makeProblemDetailsSchema( - 'PartyDetailPolicyConflictProblem', - 409, -); -export const PartyDetailInternalProblemSchema = makeProblemDetailsSchema( - 'PartyDetailInternalProblem', - 500, -); +export const PartyDetailForbiddenProblemSchema = makeProblemDetailsSchema('PartyDetailForbiddenProblem', 403); +export const PartyDetailNotFoundProblemSchema = makeProblemDetailsSchema('PartyDetailNotFoundProblem', 404); +export const PartyDetailPolicyProblemSchema = makeProblemDetailsSchema('PartyDetailPolicyProblem', 422); +export const PartyDetailPolicyConflictProblemSchema = makeProblemDetailsSchema('PartyDetailPolicyConflictProblem', 409); +export const PartyDetailInternalProblemSchema = makeProblemDetailsSchema('PartyDetailInternalProblem', 500); export const PartyDetailApi = HttpApi.make('PartyDetailApi').add( HttpApiGroup.make('partyDetail').add( diff --git a/app/verticals/party-registry/shared/apis/party-match-decision.ts b/app/verticals/party-registry/shared/apis/party-match-decision.ts index cd5a08355..e1506faac 100644 --- a/app/verticals/party-registry/shared/apis/party-match-decision.ts +++ b/app/verticals/party-registry/shared/apis/party-match-decision.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { ActionInvocationIdSchema } from '../domain/correction-contracts.ts'; import { PartyMatchDecisionRecordSchema } from '../domain/matching-contracts.ts'; import { PartyMatchDecisionRefSchema } from '../resources/party-match-decision.ts'; @@ -27,10 +25,7 @@ export const PartyMatchDecisionAuthenticationProblemSchema = makeProblemDetailsS 'PartyMatchDecisionAuthenticationProblem', 401, ); -export const PartyMatchDecisionInvalidProblemSchema = makeProblemDetailsSchema( - 'PartyMatchDecisionInvalidProblem', - 400, -); +export const PartyMatchDecisionInvalidProblemSchema = makeProblemDetailsSchema('PartyMatchDecisionInvalidProblem', 400); export const PartyMatchDecisionUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'PartyMatchDecisionUnavailableProblem', 503, @@ -43,10 +38,7 @@ export const PartyMatchDecisionNotFoundProblemSchema = makeProblemDetailsSchema( 'PartyMatchDecisionNotFoundProblem', 404, ); -export const PartyMatchDecisionPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyMatchDecisionPolicyProblem', - 422, -); +export const PartyMatchDecisionPolicyProblemSchema = makeProblemDetailsSchema('PartyMatchDecisionPolicyProblem', 422); export const PartyMatchDecisionPolicyConflictProblemSchema = makeProblemDetailsSchema( 'PartyMatchDecisionPolicyConflictProblem', 409, diff --git a/app/verticals/party-registry/shared/apis/party-match.ts b/app/verticals/party-registry/shared/apis/party-match.ts index 9172408e8..47ac43d14 100644 --- a/app/verticals/party-registry/shared/apis/party-match.ts +++ b/app/verticals/party-registry/shared/apis/party-match.ts @@ -1,9 +1,7 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PartyMatchPreviewResponseSchema, PartyMatchRequestSchema as RequestSchema, @@ -14,38 +12,17 @@ export type PartyMatchRequest = typeof PartyMatchRequestSchema.Type; export const PartyMatchResponseSchema = PartyMatchPreviewResponseSchema; export type PartyMatchResponse = typeof PartyMatchResponseSchema.Type; -export const PartyMatchAuthenticationProblemSchema = makeProblemDetailsSchema( - 'PartyMatchAuthenticationProblem', - 401, -); -export const PartyMatchInvalidProblemSchema = makeProblemDetailsSchema( - 'PartyMatchInvalidProblem', - 400, -); +export const PartyMatchAuthenticationProblemSchema = makeProblemDetailsSchema('PartyMatchAuthenticationProblem', 401); +export const PartyMatchInvalidProblemSchema = makeProblemDetailsSchema('PartyMatchInvalidProblem', 400); export const PartyMatchUnavailableProblemSchema = makeRetryableProblemDetailsSchema( 'PartyMatchUnavailableProblem', 503, ); -export const PartyMatchForbiddenProblemSchema = makeProblemDetailsSchema( - 'PartyMatchForbiddenProblem', - 403, -); -export const PartyMatchNotFoundProblemSchema = makeProblemDetailsSchema( - 'PartyMatchNotFoundProblem', - 404, -); -export const PartyMatchPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyMatchPolicyProblem', - 422, -); -export const PartyMatchPolicyConflictProblemSchema = makeProblemDetailsSchema( - 'PartyMatchPolicyConflictProblem', - 409, -); -export const PartyMatchInternalProblemSchema = makeProblemDetailsSchema( - 'PartyMatchInternalProblem', - 500, -); +export const PartyMatchForbiddenProblemSchema = makeProblemDetailsSchema('PartyMatchForbiddenProblem', 403); +export const PartyMatchNotFoundProblemSchema = makeProblemDetailsSchema('PartyMatchNotFoundProblem', 404); +export const PartyMatchPolicyProblemSchema = makeProblemDetailsSchema('PartyMatchPolicyProblem', 422); +export const PartyMatchPolicyConflictProblemSchema = makeProblemDetailsSchema('PartyMatchPolicyConflictProblem', 409); +export const PartyMatchInternalProblemSchema = makeProblemDetailsSchema('PartyMatchInternalProblem', 500); export const PartyMatchApi = HttpApi.make('PartyMatchApi').add( HttpApiGroup.make('partyMatch').add( diff --git a/app/verticals/party-registry/shared/apis/party-merge-readiness.ts b/app/verticals/party-registry/shared/apis/party-merge-readiness.ts index f8d8073e8..eaf8f1d00 100644 --- a/app/verticals/party-registry/shared/apis/party-merge-readiness.ts +++ b/app/verticals/party-registry/shared/apis/party-merge-readiness.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { MergeReadinessResultSchema } from '../domain/merge-readiness.ts'; import { PartyRefSchema } from '../resources/party.ts'; @@ -23,7 +21,10 @@ export const PartyMergeReadinessRequestSchema = Schema.Struct({ }); } if (identities.size !== partyRefs.length) { - issues.push({ issue: 'merge readiness Parties must be distinct', path: ['partyRefs'] }); + issues.push({ + issue: 'merge readiness Parties must be distinct', + path: ['partyRefs'], + }); } return issues; }), @@ -52,10 +53,7 @@ export const PartyMergeReadinessNotFoundProblemSchema = makeProblemDetailsSchema 'PartyMergeReadinessNotFoundProblem', 404, ); -export const PartyMergeReadinessPolicyProblemSchema = makeProblemDetailsSchema( - 'PartyMergeReadinessPolicyProblem', - 422, -); +export const PartyMergeReadinessPolicyProblemSchema = makeProblemDetailsSchema('PartyMergeReadinessPolicyProblem', 422); export const PartyMergeReadinessPolicyConflictProblemSchema = makeProblemDetailsSchema( 'PartyMergeReadinessPolicyConflictProblem', 409, diff --git a/app/verticals/party-registry/shared/apis/party-official-identifier-detail.ts b/app/verticals/party-registry/shared/apis/party-official-identifier-detail.ts index 66e3dbec0..cfdec17be 100644 --- a/app/verticals/party-registry/shared/apis/party-official-identifier-detail.ts +++ b/app/verticals/party-registry/shared/apis/party-official-identifier-detail.ts @@ -1,21 +1,17 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { OfficialIdentifierAssertionSchema } from '../domain/identifier-contracts.ts'; import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; export const PartyOfficialIdentifierDetailRequestSchema = Schema.Struct({ officialIdentifierRef: PartyOfficialIdentifierRefSchema, }); -export type PartyOfficialIdentifierDetailRequest = - typeof PartyOfficialIdentifierDetailRequestSchema.Type; +export type PartyOfficialIdentifierDetailRequest = typeof PartyOfficialIdentifierDetailRequestSchema.Type; export const PartyOfficialIdentifierDetailResponseSchema = OfficialIdentifierAssertionSchema; -export type PartyOfficialIdentifierDetailResponse = - typeof PartyOfficialIdentifierDetailResponseSchema.Type; +export type PartyOfficialIdentifierDetailResponse = typeof PartyOfficialIdentifierDetailResponseSchema.Type; export const PartyOfficialIdentifierDetailAuthenticationProblemSchema = makeProblemDetailsSchema( 'PartyOfficialIdentifierDetailAuthenticationProblem', @@ -25,8 +21,10 @@ export const PartyOfficialIdentifierDetailInvalidProblemSchema = makeProblemDeta 'PartyOfficialIdentifierDetailInvalidProblem', 400, ); -export const PartyOfficialIdentifierDetailUnavailableProblemSchema = - makeRetryableProblemDetailsSchema('PartyOfficialIdentifierDetailUnavailableProblem', 503); +export const PartyOfficialIdentifierDetailUnavailableProblemSchema = makeRetryableProblemDetailsSchema( + 'PartyOfficialIdentifierDetailUnavailableProblem', + 503, +); export const PartyOfficialIdentifierDetailForbiddenProblemSchema = makeProblemDetailsSchema( 'PartyOfficialIdentifierDetailForbiddenProblem', 403, @@ -48,9 +46,7 @@ export const PartyOfficialIdentifierDetailInternalProblemSchema = makeProblemDet 500, ); -export const PartyOfficialIdentifierDetailApi = HttpApi.make( - 'PartyOfficialIdentifierDetailApi', -).add( +export const PartyOfficialIdentifierDetailApi = HttpApi.make('PartyOfficialIdentifierDetailApi').add( HttpApiGroup.make('partyOfficialIdentifierDetail').add( HttpApiEndpoint.post('execute', '/reads/party-official-identifier-detail', { error: [ diff --git a/app/verticals/party-registry/shared/apis/party-official-identifier-history.ts b/app/verticals/party-registry/shared/apis/party-official-identifier-history.ts index 0b0d70773..b3cccfaba 100644 --- a/app/verticals/party-registry/shared/apis/party-official-identifier-history.ts +++ b/app/verticals/party-registry/shared/apis/party-official-identifier-history.ts @@ -1,23 +1,19 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { OfficialIdentifierAssertionSchema } from '../domain/identifier-contracts.ts'; import { PartyRefSchema } from '../resources/party.ts'; export const PartyOfficialIdentifierHistoryRequestSchema = Schema.Struct({ partyRef: PartyRefSchema, }); -export type PartyOfficialIdentifierHistoryRequest = - typeof PartyOfficialIdentifierHistoryRequestSchema.Type; +export type PartyOfficialIdentifierHistoryRequest = typeof PartyOfficialIdentifierHistoryRequestSchema.Type; export const PartyOfficialIdentifierHistoryResponseSchema = Schema.Struct({ items: Schema.Array(OfficialIdentifierAssertionSchema), }); -export type PartyOfficialIdentifierHistoryResponse = - typeof PartyOfficialIdentifierHistoryResponseSchema.Type; +export type PartyOfficialIdentifierHistoryResponse = typeof PartyOfficialIdentifierHistoryResponseSchema.Type; export const PartyOfficialIdentifierHistoryAuthenticationProblemSchema = makeProblemDetailsSchema( 'PartyOfficialIdentifierHistoryAuthenticationProblem', @@ -27,8 +23,10 @@ export const PartyOfficialIdentifierHistoryInvalidProblemSchema = makeProblemDet 'PartyOfficialIdentifierHistoryInvalidProblem', 400, ); -export const PartyOfficialIdentifierHistoryUnavailableProblemSchema = - makeRetryableProblemDetailsSchema('PartyOfficialIdentifierHistoryUnavailableProblem', 503); +export const PartyOfficialIdentifierHistoryUnavailableProblemSchema = makeRetryableProblemDetailsSchema( + 'PartyOfficialIdentifierHistoryUnavailableProblem', + 503, +); export const PartyOfficialIdentifierHistoryForbiddenProblemSchema = makeProblemDetailsSchema( 'PartyOfficialIdentifierHistoryForbiddenProblem', 403, @@ -50,9 +48,7 @@ export const PartyOfficialIdentifierHistoryInternalProblemSchema = makeProblemDe 500, ); -export const PartyOfficialIdentifierHistoryApi = HttpApi.make( - 'PartyOfficialIdentifierHistoryApi', -).add( +export const PartyOfficialIdentifierHistoryApi = HttpApi.make('PartyOfficialIdentifierHistoryApi').add( HttpApiGroup.make('partyOfficialIdentifierHistory').add( HttpApiEndpoint.post('execute', '/reads/party-official-identifier-history', { error: [ diff --git a/app/verticals/party-registry/shared/apis/party-relationship-detail.ts b/app/verticals/party-registry/shared/apis/party-relationship-detail.ts index 6b9d3f1c9..3da76c704 100644 --- a/app/verticals/party-registry/shared/apis/party-relationship-detail.ts +++ b/app/verticals/party-registry/shared/apis/party-relationship-detail.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PartyRelationshipDetailSchema } from '../domain/relationship-contract.ts'; import { PartyRelationshipRefSchema } from '../resources/party-relationship.ts'; diff --git a/app/verticals/party-registry/shared/apis/person-engagement-profile.ts b/app/verticals/party-registry/shared/apis/person-engagement-profile.ts index b30e66a69..23a63b059 100644 --- a/app/verticals/party-registry/shared/apis/person-engagement-profile.ts +++ b/app/verticals/party-registry/shared/apis/person-engagement-profile.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; import { Schema } from 'effect'; import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'; + import { PersonEngagementProfileSchema } from '../domain/engagement-profile.ts'; import { PersonEngagementProfileRefSchema } from '../resources/person-engagement-profile.ts'; diff --git a/app/verticals/party-registry/shared/command-api.ts b/app/verticals/party-registry/shared/command-api.ts index 1ebbcada4..4a97ebd6c 100644 --- a/app/verticals/party-registry/shared/command-api.ts +++ b/app/verticals/party-registry/shared/command-api.ts @@ -1,21 +1,9 @@ -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; /* eslint-disable import/no-duplicates, no-duplicate-imports -- Canonical public command contracts re-export schema-only Action payloads and results. expires: 2026-12-31. */ -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - Schema, -} from '@modern-js/plugin-bff/effect-client'; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, Schema } from '@modern-js/plugin-bff/effect-client'; import { HttpApiMiddleware } from 'effect/unstable/httpapi'; -import { PartyRefSchema } from './resources/party.ts'; -import { ActionInvocationIdSchema } from './domain/correction-contracts.ts'; -import { - AddContactPointPayloadSchema, - AddContactPointResultSchema, -} from './actions/add-contact-point.ts'; + +import { AddContactPointPayloadSchema, AddContactPointResultSchema } from './actions/add-contact-point.ts'; import { AddPartyOfficialIdentifierPayloadSchema, AddPartyOfficialIdentifierResultSchema, @@ -25,44 +13,21 @@ import { ConfirmDuplicatePartiesPayloadSchema, ConfirmDuplicatePartiesResultSchema, } from './actions/confirm-duplicate-parties.ts'; -import { - CorrectPartyFactPayloadSchema, - CorrectPartyFactResultSchema, -} from './actions/correct-party-fact.ts'; -import { - CounterpartyCreatePayloadSchema, - CounterpartyCreateResultSchema, -} from './actions/counterparty-create.ts'; -import { - CounterpartyRoleAddPayloadSchema, - CounterpartyRoleAddResultSchema, -} from './actions/counterparty-role-add.ts'; -import { - CounterpartyRoleEndPayloadSchema, - CounterpartyRoleEndResultSchema, -} from './actions/counterparty-role-end.ts'; -import { - CreatePartyRelationshipPayloadSchema, - CreatePartyRelationshipResultSchema, -} from './domain/relationship-contract.ts'; +import { CorrectPartyFactPayloadSchema, CorrectPartyFactResultSchema } from './actions/correct-party-fact.ts'; +import { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema } from './actions/counterparty-create.ts'; +import { CounterpartyRoleAddPayloadSchema, CounterpartyRoleAddResultSchema } from './actions/counterparty-role-add.ts'; +import { CounterpartyRoleEndPayloadSchema, CounterpartyRoleEndResultSchema } from './actions/counterparty-role-end.ts'; import { CreatePartyPayloadJsonSchema, CreatePartyResultSchema } from './actions/create-party.ts'; import type { CreatePartyPayloadSchema } from './actions/create-party.ts'; import { DismissDuplicateCandidatePayloadSchema, DismissDuplicateCandidateResultSchema, } from './actions/dismiss-duplicate-candidate.ts'; -import { - EndContactPointPayloadSchema, - EndContactPointResultSchema, -} from './actions/end-contact-point.ts'; +import { EndContactPointPayloadSchema, EndContactPointResultSchema } from './actions/end-contact-point.ts'; import { EndPartyOfficialIdentifierPayloadSchema, EndPartyOfficialIdentifierResultSchema, } from './actions/end-party-official-identifier.ts'; -import { - EndPartyRelationshipPayloadSchema, - ChangePartyRelationshipResultSchema as EndPartyRelationshipResultSchema, -} from './domain/relationship-contract.ts'; import { MarkDuplicateCandidateNeedsEvidencePayloadSchema, MarkDuplicateCandidateNeedsEvidenceResultSchema, @@ -80,28 +45,29 @@ import { ResolveDuplicateCandidateMatchPayloadSchema, ResolveDuplicateCandidateMatchResultSchema, } from './actions/resolve-duplicate-candidate-match.ts'; -import { - UnarchivePartyPayloadSchema, - UnarchivePartyResultSchema, -} from './actions/unarchive-party.ts'; -import { - UpdateContactPointPayloadSchema, - UpdateContactPointResultSchema, -} from './actions/update-contact-point.ts'; +import { UnarchivePartyPayloadSchema, UnarchivePartyResultSchema } from './actions/unarchive-party.ts'; +import { UpdateContactPointPayloadSchema, UpdateContactPointResultSchema } from './actions/update-contact-point.ts'; import { UpdatePartyOfficialIdentifierPayloadSchema, UpdatePartyOfficialIdentifierResultSchema, } from './actions/update-party-official-identifier.ts'; +import { UpdatePartyPayloadSchema, UpdatePartyResultSchema } from './actions/update-party.ts'; +import { ActionInvocationIdSchema } from './domain/correction-contracts.ts'; +import { + CreatePartyRelationshipPayloadSchema, + CreatePartyRelationshipResultSchema, +} from './domain/relationship-contract.ts'; +import { + EndPartyRelationshipPayloadSchema, + ChangePartyRelationshipResultSchema as EndPartyRelationshipResultSchema, +} from './domain/relationship-contract.ts'; import { UpdatePartyRelationshipPayloadSchema, ChangePartyRelationshipResultSchema as UpdatePartyRelationshipResultSchema, } from './domain/relationship-contract.ts'; -import { UpdatePartyPayloadSchema, UpdatePartyResultSchema } from './actions/update-party.ts'; +import { PartyRefSchema } from './resources/party.ts'; -export { - AddContactPointPayloadSchema, - AddContactPointResultSchema, -} from './actions/add-contact-point.ts'; +export { AddContactPointPayloadSchema, AddContactPointResultSchema } from './actions/add-contact-point.ts'; export type AddContactPointPayload = typeof AddContactPointPayloadSchema.Type; export { AddPartyOfficialIdentifierPayloadSchema, @@ -115,25 +81,13 @@ export { ConfirmDuplicatePartiesResultSchema, } from './actions/confirm-duplicate-parties.ts'; export type ConfirmDuplicatePartiesPayload = typeof ConfirmDuplicatePartiesPayloadSchema.Type; -export { - CorrectPartyFactPayloadSchema, - CorrectPartyFactResultSchema, -} from './actions/correct-party-fact.ts'; +export { CorrectPartyFactPayloadSchema, CorrectPartyFactResultSchema } from './actions/correct-party-fact.ts'; export type CorrectPartyFactPayload = typeof CorrectPartyFactPayloadSchema.Type; -export { - CounterpartyCreatePayloadSchema, - CounterpartyCreateResultSchema, -} from './actions/counterparty-create.ts'; +export { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema } from './actions/counterparty-create.ts'; export type CounterpartyCreatePayload = typeof CounterpartyCreatePayloadSchema.Type; -export { - CounterpartyRoleAddPayloadSchema, - CounterpartyRoleAddResultSchema, -} from './actions/counterparty-role-add.ts'; +export { CounterpartyRoleAddPayloadSchema, CounterpartyRoleAddResultSchema } from './actions/counterparty-role-add.ts'; export type CounterpartyRoleAddPayload = typeof CounterpartyRoleAddPayloadSchema.Type; -export { - CounterpartyRoleEndPayloadSchema, - CounterpartyRoleEndResultSchema, -} from './actions/counterparty-role-end.ts'; +export { CounterpartyRoleEndPayloadSchema, CounterpartyRoleEndResultSchema } from './actions/counterparty-role-end.ts'; export type CounterpartyRoleEndPayload = typeof CounterpartyRoleEndPayloadSchema.Type; export { CreatePartyRelationshipPayloadSchema, @@ -151,10 +105,7 @@ export { DismissDuplicateCandidateResultSchema, } from './actions/dismiss-duplicate-candidate.ts'; export type DismissDuplicateCandidatePayload = typeof DismissDuplicateCandidatePayloadSchema.Type; -export { - EndContactPointPayloadSchema, - EndContactPointResultSchema, -} from './actions/end-contact-point.ts'; +export { EndContactPointPayloadSchema, EndContactPointResultSchema } from './actions/end-contact-point.ts'; export type EndContactPointPayload = typeof EndContactPointPayloadSchema.Type; export { EndPartyOfficialIdentifierPayloadSchema, @@ -170,8 +121,7 @@ export { MarkDuplicateCandidateNeedsEvidencePayloadSchema, MarkDuplicateCandidateNeedsEvidenceResultSchema, } from './actions/mark-duplicate-candidate-needs-evidence.ts'; -export type MarkDuplicateCandidateNeedsEvidencePayload = - typeof MarkDuplicateCandidateNeedsEvidencePayloadSchema.Type; +export type MarkDuplicateCandidateNeedsEvidencePayload = typeof MarkDuplicateCandidateNeedsEvidencePayloadSchema.Type; export { MatchPartyPayloadSchema, MatchPartyResultSchema } from './actions/match-party.ts'; export type MatchPartyPayload = typeof MatchPartyPayloadSchema.Type; export { @@ -183,30 +133,21 @@ export { ResolveDuplicateCandidateCreatePayloadSchema, ResolveDuplicateCandidateCreateResultSchema, } from './actions/resolve-duplicate-candidate-create.ts'; -export type ResolveDuplicateCandidateCreatePayload = - typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; +export type ResolveDuplicateCandidateCreatePayload = typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; export { ResolveDuplicateCandidateMatchPayloadSchema, ResolveDuplicateCandidateMatchResultSchema, } from './actions/resolve-duplicate-candidate-match.ts'; -export type ResolveDuplicateCandidateMatchPayload = - typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; -export { - UnarchivePartyPayloadSchema, - UnarchivePartyResultSchema, -} from './actions/unarchive-party.ts'; +export type ResolveDuplicateCandidateMatchPayload = typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; +export { UnarchivePartyPayloadSchema, UnarchivePartyResultSchema } from './actions/unarchive-party.ts'; export type UnarchivePartyPayload = typeof UnarchivePartyPayloadSchema.Type; -export { - UpdateContactPointPayloadSchema, - UpdateContactPointResultSchema, -} from './actions/update-contact-point.ts'; +export { UpdateContactPointPayloadSchema, UpdateContactPointResultSchema } from './actions/update-contact-point.ts'; export type UpdateContactPointPayload = typeof UpdateContactPointPayloadSchema.Type; export { UpdatePartyOfficialIdentifierPayloadSchema, UpdatePartyOfficialIdentifierResultSchema, } from './actions/update-party-official-identifier.ts'; -export type UpdatePartyOfficialIdentifierPayload = - typeof UpdatePartyOfficialIdentifierPayloadSchema.Type; +export type UpdatePartyOfficialIdentifierPayload = typeof UpdatePartyOfficialIdentifierPayloadSchema.Type; export { UpdatePartyRelationshipPayloadSchema, ChangePartyRelationshipResultSchema as UpdatePartyRelationshipResultSchema, @@ -217,9 +158,7 @@ export type UpdatePartyPayload = typeof UpdatePartyPayloadSchema.Type; // Absence reaches the explicit 428 mapping; every typed command client requires a key. export const PartyCommandHeadersSchema = Schema.Struct({ - 'idempotency-key': Schema.optionalKey( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), - ), + 'idempotency-key': Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200))), }); export const PartyCommandInvalidRequestProblemSchema = makeProblemDetailsSchema( 'PartyCommandInvalidRequestProblem', @@ -237,46 +176,36 @@ export const PartyCommandAuthenticationProblemSchema = makeProblemDetailsSchema( 401, ); -export const PartyCommandForbiddenProblemSchema = makeProblemDetailsSchema( - 'PartyCommandForbiddenProblem', - 403, -); +export const PartyCommandForbiddenProblemSchema = makeProblemDetailsSchema('PartyCommandForbiddenProblem', 403); -export const PartyCommandNotFoundProblemSchema = makeProblemDetailsSchema( - 'PartyCommandNotFoundProblem', - 404, -); +export const PartyCommandNotFoundProblemSchema = makeProblemDetailsSchema('PartyCommandNotFoundProblem', 404); -export const PartyCommandConflictProblemSchema = makeProblemDetailsSchema( - 'PartyCommandConflictProblem', - 409, - { - code: Schema.Literals([ - 'action_request_hash_conflict', - 'action_invocation_state_invalid', - 'party_lifecycle_conflict', - 'party_unarchive_identity_conflict', - 'party_unarchive_identity_ambiguous', - 'party_unarchive_review_required', - 'party_identifier_claim_conflict', - 'party_official_identifier_update_conflict', - 'party_contact_point_already_exists', - 'party_contact_point_revision_conflict', - 'party_contact_point_lifecycle_conflict', - 'party_contact_point_correction_required', - 'party_correction_conflict', - 'counterparty_party_archived', - 'counterparty_role_overlap', - 'counterparty_role_already_ended', - 'counterparty_temporal_conflict', - 'duplicate_candidate_conflict', - 'claim_owned_by_different_party', - 'party_relationship_overlap_conflict', - 'party_relationship_revision_conflict', - 'party_relationship_correction_required', - ]), - }, -); +export const PartyCommandConflictProblemSchema = makeProblemDetailsSchema('PartyCommandConflictProblem', 409, { + code: Schema.Literals([ + 'action_request_hash_conflict', + 'action_invocation_state_invalid', + 'party_lifecycle_conflict', + 'party_unarchive_identity_conflict', + 'party_unarchive_identity_ambiguous', + 'party_unarchive_review_required', + 'party_identifier_claim_conflict', + 'party_official_identifier_update_conflict', + 'party_contact_point_already_exists', + 'party_contact_point_revision_conflict', + 'party_contact_point_lifecycle_conflict', + 'party_contact_point_correction_required', + 'party_correction_conflict', + 'counterparty_party_archived', + 'counterparty_role_overlap', + 'counterparty_role_already_ended', + 'counterparty_temporal_conflict', + 'duplicate_candidate_conflict', + 'claim_owned_by_different_party', + 'party_relationship_overlap_conflict', + 'party_relationship_revision_conflict', + 'party_relationship_correction_required', + ]), +}); export const PartyCommandUnprocessableProblemSchema = makeProblemDetailsSchema( 'PartyCommandUnprocessableProblem', @@ -332,20 +261,14 @@ export const ResolvePartyCommandCommitPayloadSchema = Schema.Struct({ }); export type ResolvePartyCommandCommitPayload = typeof ResolvePartyCommandCommitPayloadSchema.Type; -export const ResolvePartyCommandCommitResultSchema = Schema.TaggedStruct( - 'PartyCommandCommitResolution', - { - invocationId: ActionInvocationIdSchema, - retryCommand: Schema.Literal(false), - state: Schema.Literals(['OPEN', 'COMMITTED']), - }, -); +export const ResolvePartyCommandCommitResultSchema = Schema.TaggedStruct('PartyCommandCommitResolution', { + invocationId: ActionInvocationIdSchema, + retryCommand: Schema.Literal(false), + state: Schema.Literals(['OPEN', 'COMMITTED']), +}); export type ResolvePartyCommandCommitResult = typeof ResolvePartyCommandCommitResultSchema.Type; -export const PartyCommandInternalProblemSchema = makeProblemDetailsSchema( - 'PartyCommandInternalProblem', - 500, -); +export const PartyCommandInternalProblemSchema = makeProblemDetailsSchema('PartyCommandInternalProblem', 500); export const PartyCommandAliasWriteRejectedProblemSchema = makeProblemDetailsSchema( 'PartyCommandAliasWriteRejectedProblem', @@ -398,16 +321,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'addPartyOfficialIdentifier', - '/party-registry/actions/add-party-official-identifier', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: Schema.toEncoded(AddPartyOfficialIdentifierPayloadSchema), - success: AddPartyOfficialIdentifierResultSchema, - }, - ), + HttpApiEndpoint.post('addPartyOfficialIdentifier', '/party-registry/actions/add-party-official-identifier', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: Schema.toEncoded(AddPartyOfficialIdentifierPayloadSchema), + success: AddPartyOfficialIdentifierResultSchema, + }), ) .add( HttpApiEndpoint.post('archiveParty', '/party-registry/actions/archive-party', { @@ -418,16 +337,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'confirmDuplicateParties', - '/party-registry/actions/confirm-duplicate-parties', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: ConfirmDuplicatePartiesPayloadSchema, - success: ConfirmDuplicatePartiesResultSchema, - }, - ), + HttpApiEndpoint.post('confirmDuplicateParties', '/party-registry/actions/confirm-duplicate-parties', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: ConfirmDuplicatePartiesPayloadSchema, + success: ConfirmDuplicatePartiesResultSchema, + }), ) .add( HttpApiEndpoint.post('correctPartyFact', '/party-registry/actions/correct-party-fact', { @@ -462,16 +377,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'createPartyRelationship', - '/party-registry/actions/create-party-relationship', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: CreatePartyRelationshipPayloadSchema, - success: CreatePartyRelationshipResultSchema, - }, - ), + HttpApiEndpoint.post('createPartyRelationship', '/party-registry/actions/create-party-relationship', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: CreatePartyRelationshipPayloadSchema, + success: CreatePartyRelationshipResultSchema, + }), ) .add( HttpApiEndpoint.post('createParty', '/party-registry/actions/create-party', { @@ -482,16 +393,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'dismissDuplicateCandidate', - '/party-registry/actions/dismiss-duplicate-candidate', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: DismissDuplicateCandidatePayloadSchema, - success: DismissDuplicateCandidateResultSchema, - }, - ), + HttpApiEndpoint.post('dismissDuplicateCandidate', '/party-registry/actions/dismiss-duplicate-candidate', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: DismissDuplicateCandidatePayloadSchema, + success: DismissDuplicateCandidateResultSchema, + }), ) .add( HttpApiEndpoint.post('endContactPoint', '/party-registry/actions/end-contact-point', { @@ -502,28 +409,20 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'endPartyOfficialIdentifier', - '/party-registry/actions/end-party-official-identifier', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: EndPartyOfficialIdentifierPayloadSchema, - success: EndPartyOfficialIdentifierResultSchema, - }, - ), + HttpApiEndpoint.post('endPartyOfficialIdentifier', '/party-registry/actions/end-party-official-identifier', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: EndPartyOfficialIdentifierPayloadSchema, + success: EndPartyOfficialIdentifierResultSchema, + }), ) .add( - HttpApiEndpoint.post( - 'endPartyRelationship', - '/party-registry/actions/end-party-relationship', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: EndPartyRelationshipPayloadSchema, - success: EndPartyRelationshipResultSchema, - }, - ), + HttpApiEndpoint.post('endPartyRelationship', '/party-registry/actions/end-party-relationship', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: EndPartyRelationshipPayloadSchema, + success: EndPartyRelationshipResultSchema, + }), ) .add( HttpApiEndpoint.post( @@ -546,16 +445,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') }), ) .add( - HttpApiEndpoint.post( - 'requestSearchRebuild', - '/party-registry/actions/request-search-rebuild', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: RequestSearchRebuildPayloadSchema, - success: RequestSearchRebuildResultSchema, - }, - ), + HttpApiEndpoint.post('requestSearchRebuild', '/party-registry/actions/request-search-rebuild', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: RequestSearchRebuildPayloadSchema, + success: RequestSearchRebuildResultSchema, + }), ) .add( HttpApiEndpoint.post( @@ -610,16 +505,12 @@ export const partyRegistryCommandsApi = HttpApi.make('PartyRegistryCommandsApi') ), ) .add( - HttpApiEndpoint.post( - 'updatePartyRelationship', - '/party-registry/actions/update-party-relationship', - { - error: commandErrors, - headers: PartyCommandHeadersSchema, - payload: UpdatePartyRelationshipPayloadSchema, - success: UpdatePartyRelationshipResultSchema, - }, - ), + HttpApiEndpoint.post('updatePartyRelationship', '/party-registry/actions/update-party-relationship', { + error: commandErrors, + headers: PartyCommandHeadersSchema, + payload: UpdatePartyRelationshipPayloadSchema, + success: UpdatePartyRelationshipResultSchema, + }), ) .add( HttpApiEndpoint.post('updateParty', '/party-registry/actions/update-party', { diff --git a/app/verticals/party-registry/shared/domain/ares-application.ts b/app/verticals/party-registry/shared/domain/ares-application.ts index ffecbf301..1806da0b2 100644 --- a/app/verticals/party-registry/shared/domain/ares-application.ts +++ b/app/verticals/party-registry/shared/domain/ares-application.ts @@ -1,9 +1,6 @@ import { DateTime, Option, Result, Schema } from 'effect'; -import { - AresIsoTimestampSchema, - AresRegisteredAddressSchema, - AresSubjectEvidenceSchema, -} from './ares-evidence.ts'; + +import { AresIsoTimestampSchema, AresRegisteredAddressSchema, AresSubjectEvidenceSchema } from './ares-evidence.ts'; import type { AresRegisteredAddress, AresSubjectEvidence } from './ares-evidence.ts'; import type { StructuredAddress } from './contact-point.ts'; import type { PartyCandidate } from './identity-contracts.ts'; @@ -25,12 +22,7 @@ const AresApplyOutcomeSchema = Schema.Literals([ ]); type AresApplyOutcome = typeof AresApplyOutcomeSchema.Type; -const AresSelectedFactSchema = Schema.Literals([ - 'BUSINESS_NAME', - 'ICO', - 'REGISTERED_ADDRESS', - 'PARTY_CANDIDATE', -]); +const AresSelectedFactSchema = Schema.Literals(['BUSINESS_NAME', 'ICO', 'REGISTERED_ADDRESS', 'PARTY_CANDIDATE']); type AresSelectedFact = typeof AresSelectedFactSchema.Type; const decisionEvidence = { @@ -91,9 +83,7 @@ export const AresEvidenceApplicationSchema = Schema.Struct({ Schema.makeFilter((application) => { if (application.outcome === 'APPLY_ENRICHMENT') { return application.userConfirmed && - application.factDecisions.some( - (decision) => decision.outcome === 'APPLY_ENRICHMENT' && decision.route !== null, - ) + application.factDecisions.some((decision) => decision.outcome === 'APPLY_ENRICHMENT' && decision.route !== null) ? undefined : 'V1 enrichment requires explicit user confirmation and a standard Party Action route'; } @@ -168,11 +158,8 @@ export const aresRegisteredAddressMatches = ( ): boolean => { const address = Schema.is(AresRegisteredAddressSchema)(observed) ? observed - : Result.getOrThrow(Schema.decodeUnknownResult(AresRegisteredAddressSchema)(observed)); - const houseNumber = [ - Option.getOrNull(address.buildingNumber), - Option.getOrNull(address.orientationNumber), - ] + : Result.getOrThrow(Schema.decodeResult(AresRegisteredAddressSchema)(observed)); + const houseNumber = [Option.getOrNull(address.buildingNumber), Option.getOrNull(address.orientationNumber)] .filter(Boolean) .join('/'); const line = [Option.getOrNull(address.street), houseNumber].filter(Boolean).join(' '); @@ -182,8 +169,7 @@ export const aresRegisteredAddressMatches = ( line.length > 0 && countryCode !== null && normalizeText(current.addressLine1) === normalizeText(line) && - normalizeText(current.addressLine2) === - normalizeText(Option.getOrNull(address.municipalityPart)) && + normalizeText(current.addressLine2) === normalizeText(Option.getOrNull(address.municipalityPart)) && normalizeText(current.city) === normalizeText(Option.getOrNull(address.municipality)) && normalizeText(current.countryCode) === normalizeText(countryCode) && normalizeText(current.postalCode).replaceAll(' ', '') === @@ -218,17 +204,22 @@ const epochMillisFromString = (value: string): number => }); /** A bounded proposal only. An explicit evidence-backed Matching/Create flow owns acceptance. */ -export const prefillPartyCandidateFromAres = ( - input: typeof AresSubjectEvidenceSchema.Encoded, -): PartyCandidate => { - const evidence = Result.getOrThrow(Schema.decodeUnknownResult(AresSubjectEvidenceSchema)(input)); +export const prefillPartyCandidateFromAres = (input: typeof AresSubjectEvidenceSchema.Encoded): PartyCandidate => { + const evidence = Result.getOrThrow(Schema.decodeResult(AresSubjectEvidenceSchema)(input)); const candidate: PartyCandidate = { evidenceRefs: [`ares:${evidence.queryIco}:${DateTime.formatIso(evidence.observedAt)}`], officialIdentifiers: [ - { identifierType: 'ICO', value: evidence.subject.ico, verification: 'UNVERIFIED' }, + { + identifierType: 'ICO', + value: evidence.subject.ico, + verification: 'UNVERIFIED', + }, ], partyType: 'UNRESOLVED', - provenance: { method: 'PROVIDER_OBSERVATION', source: 'ARES_CANDIDATE_PREFILL' }, + provenance: { + method: 'PROVIDER_OBSERVATION', + source: 'ARES_CANDIDATE_PREFILL', + }, subjectEvidence: [], validFrom: evidence.observedAt, }; @@ -246,8 +237,7 @@ const acceptedObservationMatches = ( ): boolean => (Option.isNone(accepted.providerRecordRef) || Option.isNone(evidence.providerRecordRef) || - Option.getOrNull(accepted.providerRecordRef) === - Option.getOrNull(evidence.providerRecordRef)) && + Option.getOrNull(accepted.providerRecordRef) === Option.getOrNull(evidence.providerRecordRef)) && DateTime.toEpochMillis(accepted.observedAt) <= epochMillisFromString(validFrom) && epochMillisFromString(validFrom) <= DateTime.toEpochMillis(evidence.observedAt); @@ -260,7 +250,7 @@ const acceptedEvidenceConflicts = ( const accepted = acceptedInput === null || Schema.is(AresAppliedEvidenceSchema)(acceptedInput) ? acceptedInput - : Result.getOrUndefined(Schema.decodeUnknownResult(AresAppliedEvidenceSchema)(acceptedInput)); + : Result.getOrUndefined(Schema.decodeResult(AresAppliedEvidenceSchema)(acceptedInput)); return ( accepted !== null && accepted !== undefined && @@ -277,8 +267,7 @@ const historicalConflict = ( evidence: AresSubjectEvidence, fact: 'BUSINESS_NAME' | 'ICO', ): AresCanonicalFactEvidence | undefined => { - const observedValue = - fact === 'ICO' ? evidence.subject.ico : Option.getOrNull(evidence.subject.businessName); + const observedValue = fact === 'ICO' ? evidence.subject.ico : Option.getOrNull(evidence.subject.businessName); if ( observedValue === null || Option.isNone(evidence.providerChangedOn) || @@ -303,7 +292,13 @@ const blocked = ( fact: AresSelectedFact, outcome: Exclude, reasonCode: string, -): AresFactDecision => ({ ...ownerPolicy, fact, outcome, reasonCode, route: null }); +): AresFactDecision => ({ + ...ownerPolicy, + fact, + outcome, + reasonCode, + route: null, +}); const businessNameDecision = ( canonical: AresCanonicalSnapshot, @@ -332,9 +327,7 @@ const addressDecision = ( if (address === undefined || !isSupportedAddress(address)) { return blocked(fact, 'NO_CHANGE', 'provider_fact_absent_or_unsupported'); } - if ( - canonical.registeredAddresses.some((current) => aresRegisteredAddressMatches(address, current)) - ) { + if (canonical.registeredAddresses.some((current) => aresRegisteredAddressMatches(address, current))) { return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); } if (canonical.registeredAddresses.length > 0) { @@ -350,21 +343,12 @@ const identityDecision = ( ): AresFactDecision | undefined => { const conflictingIco = canonical.icoValues.some((value) => value !== evidence.subject.ico); const historical = - fact === 'BUSINESS_NAME' || fact === 'ICO' - ? historicalConflict(canonical, evidence, fact) - : undefined; - if ( - conflictingIco && - (fact !== 'ICO' || historical === undefined || canonical.icoValues.length !== 1) - ) { + fact === 'BUSINESS_NAME' || fact === 'ICO' ? historicalConflict(canonical, evidence, fact) : undefined; + if (conflictingIco && (fact !== 'ICO' || historical === undefined || canonical.icoValues.length !== 1)) { return blocked(fact, 'IDENTITY_AMBIGUITY', 'canonical_identity_conflict'); } if (historical !== undefined) { - return blocked( - fact, - 'CORRECTION_CANDIDATE', - 'unchanged_provider_revision_conflicts_with_accepted_assertion', - ); + return blocked(fact, 'CORRECTION_CANDIDATE', 'unchanged_provider_revision_conflicts_with_accepted_assertion'); } return undefined; }; @@ -424,17 +408,11 @@ const selectedFactDecision = ( }; /** Policy is closed owner code, never a caller-supplied outcome, route or authority assertion. */ -export const deriveAresEvidenceApplication = ( - input: AresDecisionInput, -): AresEvidenceApplication => { - const evidence = Result.getOrThrow( - Schema.decodeUnknownResult(AresSubjectEvidenceSchema)(input.evidence), - ); - const decidedAt = Result.getOrThrow( - Schema.decodeUnknownResult(AresIsoTimestampSchema)(input.decidedAt), - ); +export const deriveAresEvidenceApplication = (input: AresDecisionInput): AresEvidenceApplication => { + const evidence = Result.getOrThrow(Schema.decodeResult(AresSubjectEvidenceSchema)(input.evidence)); + const decidedAt = Result.getOrThrow(Schema.decodeResult(AresIsoTimestampSchema)(input.decidedAt)); const selectedFacts = Result.getOrThrow( - Schema.decodeUnknownResult( + Schema.decodeResult( Schema.Array(AresSelectedFactSchema).check( Schema.isMinLength(1), Schema.isMaxLength(4), @@ -482,10 +460,9 @@ export const deriveAresEvidenceApplication = ( 'NO_CHANGE', ]; const outcome = - priority.find((candidate) => decisions.some((decision) => decision.outcome === candidate)) ?? - 'NO_CHANGE'; + priority.find((candidate) => decisions.some((decision) => decision.outcome === candidate)) ?? 'NO_CHANGE'; return Result.getOrThrow( - Schema.decodeUnknownResult(Schema.toType(AresEvidenceApplicationSchema))({ + Schema.decodeResult(Schema.toType(AresEvidenceApplicationSchema))({ decidedAt, evidence, factDecisions: decisions, @@ -522,10 +499,7 @@ export const deriveAresCorrectionReviewHandoffs = ( canonical: AresCanonicalSnapshot, ): readonly AresCorrectionReviewHandoff[] => application.factDecisions.flatMap((decision) => { - if ( - decision.outcome !== 'CORRECTION_CANDIDATE' || - (decision.fact !== 'BUSINESS_NAME' && decision.fact !== 'ICO') - ) { + if (decision.outcome !== 'CORRECTION_CANDIDATE' || (decision.fact !== 'BUSINESS_NAME' && decision.fact !== 'ICO')) { return []; } const assertion = historicalConflict(canonical, application.evidence, decision.fact); diff --git a/app/verticals/party-registry/shared/domain/ares-evidence.ts b/app/verticals/party-registry/shared/domain/ares-evidence.ts index 99fd6c70c..f1b66135b 100644 --- a/app/verticals/party-registry/shared/domain/ares-evidence.ts +++ b/app/verticals/party-registry/shared/domain/ares-evidence.ts @@ -19,10 +19,9 @@ export const AresSubjectLookupIcoSchema = Schema.Trim.check(Schema.isPattern(/^\ Schema.brand('AresSubjectLookupIco'), ); -export const AresDicSchema = Schema.Trim.check( - Schema.isPattern(/^CZ\d{8,10}$/u), - Schema.isMaxLength(12), -).pipe(Schema.brand('AresDic')); +export const AresDicSchema = Schema.Trim.check(Schema.isPattern(/^CZ\d{8,10}$/u), Schema.isMaxLength(12)).pipe( + Schema.brand('AresDic'), +); export const AresDateOnlySchema = Schema.String.pipe( Schema.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u), validDateOnly), Schema.decodeTo(Schema.toType(Schema.DateTimeUtc), { diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/already-exists.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/already-exists.ts index b1fa18110..b17200a1a 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/already-exists.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/already-exists.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../../resources/party-contact-point.ts'; import { ContactPointErrorReasonSchema } from './shared.ts'; diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/correction-required.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/correction-required.ts index 096f83ef8..4fbf2a7ea 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/correction-required.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/correction-required.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { ContactPointErrorReasonSchema } from './shared.ts'; export class PartyContactPointCorrectionRequired extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/invalid.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/invalid.ts index de3a9f655..759dd30ae 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/invalid.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/invalid.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { ContactPointErrorReasonSchema } from './shared.ts'; export class PartyContactPointInvalid extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/lifecycle-conflict.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/lifecycle-conflict.ts index 8b68a3e39..104b4d39f 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/lifecycle-conflict.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/lifecycle-conflict.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { ContactPointErrorReasonSchema } from './shared.ts'; export class PartyContactPointLifecycleConflict extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/not-found.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/not-found.ts index f499b12f3..7c81dd642 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/not-found.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../../resources/party-contact-point.ts'; import { ContactPointErrorReasonSchema } from './shared.ts'; diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/party-not-found.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/party-not-found.ts index 2285bb81f..deeaaa0e1 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/party-not-found.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/party-not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyRefSchema } from '../../resources/party.ts'; import { ContactPointErrorReasonSchema } from './shared.ts'; diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/persistence-unavailable.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/persistence-unavailable.ts index 1782d7da7..d67e92e35 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/persistence-unavailable.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/persistence-unavailable.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { ContactPointErrorReasonSchema } from './shared.ts'; export class PartyContactPointPersistenceUnavailable extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/revision-conflict.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/revision-conflict.ts index 3654c0f90..3c2e7a8d4 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/revision-conflict.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/revision-conflict.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { ContactPointErrorReasonSchema } from './shared.ts'; export class PartyContactPointRevisionConflict extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/contact-point-errors/shared.ts b/app/verticals/party-registry/shared/domain/contact-point-errors/shared.ts index 9f78d000c..18d2c4ac9 100644 --- a/app/verticals/party-registry/shared/domain/contact-point-errors/shared.ts +++ b/app/verticals/party-registry/shared/domain/contact-point-errors/shared.ts @@ -1,6 +1,3 @@ import { Schema } from 'effect'; -export const ContactPointErrorReasonSchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(500), -); +export const ContactPointErrorReasonSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); diff --git a/app/verticals/party-registry/shared/domain/contact-point.ts b/app/verticals/party-registry/shared/domain/contact-point.ts index d9e70c7bc..fc156747d 100644 --- a/app/verticals/party-registry/shared/domain/contact-point.ts +++ b/app/verticals/party-registry/shared/domain/contact-point.ts @@ -1,4 +1,5 @@ import { Match, Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; import { PartyRefSchema } from '../resources/party.ts'; import { AresAppliedEvidenceSchema } from './ares-application.ts'; @@ -7,10 +8,7 @@ import { IsoTimestampSchema } from './identity-contracts.ts'; const TrimmedTextSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)); const OptionalTrimmedTextSchema = Schema.optionalKey(TrimmedTextSchema); -const RegistryKeySchema = TrimmedTextSchema.pipe( - Schema.brand('RegistryKey'), - Schema.decodeTo(TrimmedTextSchema), -); +const RegistryKeySchema = TrimmedTextSchema.pipe(Schema.brand('RegistryKey'), Schema.decodeTo(TrimmedTextSchema)); const EndedByActionInvocationIdSchema = TrimmedTextSchema.pipe( Schema.brand('EndedByActionInvocationId'), Schema.decodeTo(TrimmedTextSchema), @@ -23,26 +21,11 @@ export const ContactPointTimestampSchema = IsoTimestampSchema; export const ContactPointTypeSchema = Schema.Literals(['EMAIL', 'PHONE', 'ADDRESS']); -const AddressPurposeSchema = Schema.Literals([ - 'REGISTERED', - 'BILLING', - 'DELIVERY', - 'CORRESPONDENCE', -]); +const AddressPurposeSchema = Schema.Literals(['REGISTERED', 'BILLING', 'DELIVERY', 'CORRESPONDENCE']); -const ContactPointLifecycleStateSchema = Schema.Literals([ - 'ACTIVE', - 'ENDED', - 'SUPERSEDED', - 'RETRACTED', - 'DISPUTED', -]); +const ContactPointLifecycleStateSchema = Schema.Literals(['ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED']); -export const ContactPointPrivacyClassificationSchema = Schema.Literals([ - 'PUBLIC', - 'BUSINESS_SENSITIVE', - 'PERSONAL', -]); +export const ContactPointPrivacyClassificationSchema = Schema.Literals(['PUBLIC', 'BUSINESS_SENSITIVE', 'PERSONAL']); export type ContactPointPrivacyClassification = typeof ContactPointPrivacyClassificationSchema.Type; const ContactPointVerificationStateSchema = Schema.Literals(['UNVERIFIED', 'VERIFIED', 'REJECTED']); @@ -58,19 +41,12 @@ export const ContactPointProvenanceSchema = Schema.Struct({ 'PROVIDER_OBSERVATION', 'MIGRATION', ]), - source: Schema.Literals([ - 'USER_ASSERTION', - 'PARTY_DECLARATION', - 'EXTERNAL_EVIDENCE', - 'MIGRATION_DATASET', - ]), + source: Schema.Literals(['USER_ASSERTION', 'PARTY_DECLARATION', 'EXTERNAL_EVIDENCE', 'MIGRATION_DATASET']), }); export type ContactPointProvenance = typeof ContactPointProvenanceSchema.Type; const ContactPointProvenanceHistorySchema = Schema.Struct({ ...ContactPointProvenanceSchema.fields, - evidenceReferences: Schema.optionalKey( - Schema.Array(TrimmedTextSchema).check(Schema.isMaxLength(33)), - ), + evidenceReferences: Schema.optionalKey(Schema.Array(TrimmedTextSchema).check(Schema.isMaxLength(33))), }); export const ContactPointVerificationSchema = Schema.Struct({ @@ -158,10 +134,7 @@ export type AddressPurposeTarget = typeof AddressPurposeTargetSchema.Type; export const AddressContactPointInputSchema = Schema.Struct({ address: StructuredAddressSchema, - purposes: Schema.Array(AddressPurposeAssignmentSchema).check( - Schema.isMinLength(1), - Schema.isMaxLength(4), - ), + purposes: Schema.Array(AddressPurposeAssignmentSchema).check(Schema.isMinLength(1), Schema.isMaxLength(4)), type: Schema.Literal('ADDRESS'), }); @@ -268,7 +241,10 @@ export interface NormalizedAddress { } const invalidContactPoint = (reason: string): never => { - throw new PartyContactPointInvalid({ code: 'party_contact_point_invalid', reason }); + throw new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason, + }); }; export const normalizeEmail = (rawValue: string): NormalizedChannel => { @@ -285,11 +261,7 @@ export const normalizeEmail = (rawValue: string): NormalizedChannel => { const digitsOnly = (value: string): string => value.replaceAll(/[^0-9]/gu, ''); -const assertPhoneParts = ( - displayValue: string, - countryCode: string | undefined, - extension: string | null, -): void => { +const assertPhoneParts = (displayValue: string, countryCode: string | undefined, extension: string | null): void => { if (countryCode !== undefined && !/^[A-Z]{2}$/u.test(countryCode)) { return invalidContactPoint('PHONE country context must be a two-letter country code'); } @@ -301,11 +273,7 @@ const assertPhoneParts = ( } }; -export const normalizePhone = ( - rawValue: string, - rawCountryCode?: string, - rawExtension?: string, -): NormalizedPhone => { +export const normalizePhone = (rawValue: string, rawCountryCode?: string, rawExtension?: string): NormalizedPhone => { const displayValue = rawValue.trim(); const countryCode = rawCountryCode?.trim().toUpperCase(); const digits = digitsOnly(displayValue); @@ -313,9 +281,7 @@ export const normalizePhone = ( assertPhoneParts(displayValue, countryCode, extension); if (displayValue.startsWith('+')) { if (!/^[1-9][0-9]{6,14}$/u.test(digits)) { - return invalidContactPoint( - 'PHONE international form must contain 7 to 15 digits and start with 1 to 9', - ); + return invalidContactPoint('PHONE international form must contain 7 to 15 digits and start with 1 to 9'); } return { countryCode: countryCode ?? (digits.startsWith('420') ? 'CZ' : null), @@ -328,9 +294,7 @@ export const normalizePhone = ( return invalidContactPoint('National PHONE requires an explicit country context'); } if (countryCode !== 'CZ' || digits.length !== 9) { - return invalidContactPoint( - 'National PHONE has no approved normalization rule for this country', - ); + return invalidContactPoint('National PHONE has no approved normalization rule for this country'); } return { countryCode, @@ -352,16 +316,11 @@ export const normalizeAddress = (address: StructuredAddress): NormalizedAddress region: normalizedOptional(address.region), }; if ( - [ - normalized.addressLine1, - normalized.addressLine2, - normalized.city, - normalized.postalCode, - ].filter((part) => part !== null).length < 2 + [normalized.addressLine1, normalized.addressLine2, normalized.city, normalized.postalCode].filter( + (part) => part !== null, + ).length < 2 ) { - return invalidContactPoint( - 'ADDRESS must contain enough structure to identify a usable location', - ); + return invalidContactPoint('ADDRESS must contain enough structure to identify a usable location'); } return normalized; }; @@ -403,9 +362,7 @@ export const assertVerificationRules = (verification: ContactPointVerification): verification.verifiedAt === undefined || verification.verifierReference === undefined) ) { - return invalidContactPoint( - 'VERIFIED contact channel requires method, time, and verifier provenance', - ); + return invalidContactPoint('VERIFIED contact channel requires method, time, and verifier provenance'); } }; @@ -436,9 +393,7 @@ const assertAddressPurposeAssignment = ( !provenance.authoritative || provenance.evidenceReference === undefined ) { - return invalidContactPoint( - 'REGISTERED requires an explicit registry context and authoritative provenance', - ); + return invalidContactPoint('REGISTERED requires an explicit registry context and authoritative provenance'); } } else if (assignment.registryContext !== undefined) { return invalidContactPoint('Registry context belongs only to REGISTERED purpose'); diff --git a/app/verticals/party-registry/shared/domain/correction-contracts.ts b/app/verticals/party-registry/shared/domain/correction-contracts.ts index 4b32d009f..056fac940 100644 --- a/app/verticals/party-registry/shared/domain/correction-contracts.ts +++ b/app/verticals/party-registry/shared/domain/correction-contracts.ts @@ -1,13 +1,14 @@ import { DateTime, Option, Schema } from 'effect'; + +import { PartyCorrectionRefSchema } from '../resources/party-correction.ts'; +import { PartyRelationshipRefSchema } from '../resources/party-relationship.ts'; +import { PartyRefSchema } from '../resources/party.ts'; import { PartyIdSchema, PartySubjectEvidenceListSchema } from './identity-contracts.ts'; import { PartyRelationshipProvenanceSchema, RelationshipEndEvidenceSchema, RelationshipIsoTimestampSchema, } from './relationship-contract.ts'; -import { PartyCorrectionRefSchema } from '../resources/party-correction.ts'; -import { PartyRefSchema } from '../resources/party.ts'; -import { PartyRelationshipRefSchema } from '../resources/party-relationship.ts'; export const CorrectablePartyFactSchema = Schema.Literals([ 'PARTY_TYPE', @@ -17,34 +18,22 @@ export const CorrectablePartyFactSchema = Schema.Literals([ ]); export type CorrectablePartyFact = typeof CorrectablePartyFactSchema.Type; -const EvidenceRefsSchema = Schema.Array( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)), -).check(Schema.isMinLength(1), Schema.isMaxLength(32)); +const EvidenceRefsSchema = Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( + Schema.isMinLength(1), + Schema.isMaxLength(32), +); export const PartyCorrectionPolicyVersion = 'party-correction.v1' as const; const PolicyVersionSchema = Schema.Literal(PartyCorrectionPolicyVersion); const ReasonSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(1000)); -const PositiveRevisionSchema = Schema.Finite.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), -); -export const TargetAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('TargetAssertionId'), -); +const PositiveRevisionSchema = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)); +export const TargetAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TargetAssertionId')); export const ReplacementAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('ReplacementAssertionId'), ); -const RetractedAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('RetractedAssertionId'), -); -export const AssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('AssertionId'), -); -export const ActingPrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('ActingPrincipalId'), -); -export const ActionInvocationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('ActionInvocationId'), -); +const RetractedAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('RetractedAssertionId')); +export const AssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('AssertionId')); +export const ActingPrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('ActingPrincipalId')); +export const ActionInvocationIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('ActionInvocationId')); export const ApprovingPrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('ApprovingPrincipalId'), ); @@ -76,9 +65,7 @@ export const IdentityCorrectionCommandSchema = Schema.Struct({ ...correctionEvidenceFields, factKind: Schema.Literals(['PARTY_TYPE', 'DISPLAY_NAME', 'OFFICIAL_IDENTIFIER']), partyId: PartyIdSchema, - replacementValue: Schema.optionalKey( - Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(300)), - ), + replacementValue: Schema.optionalKey(Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(300))), targetAssertionId: TargetAssertionIdSchema, }).check( Schema.makeFilter((command) => @@ -142,8 +129,7 @@ const correctionRoutes = { PARTY_TYPE: 'LIFECYCLE_REVIEW', RELATIONSHIP: 'RELATIONSHIP_REVIEW', } as const satisfies Readonly>; -export const classifyCorrectionRoute = (factKind: CorrectablePartyFact): CorrectionRoute => - correctionRoutes[factKind]; +export const classifyCorrectionRoute = (factKind: CorrectablePartyFact): CorrectionRoute => correctionRoutes[factKind]; export const PartyCorrectionResultSchema = Schema.Struct({ correctionRef: PartyCorrectionRefSchema, @@ -157,13 +143,10 @@ export const PartyCorrectionResultSchema = Schema.Struct({ }); export const PartyCorrectionResultJsonSchema = Schema.toEncoded(PartyCorrectionResultSchema); -export class PartyCorrectionConflict extends Schema.TaggedError()( - 'PartyCorrectionConflict', - { - code: Schema.Literal('party_correction_conflict'), - reason: Schema.String, - }, -) {} +export class PartyCorrectionConflict extends Schema.TaggedError()('PartyCorrectionConflict', { + code: Schema.Literal('party_correction_conflict'), + reason: Schema.String, +}) {} const assertionHistoryFields = { assertionId: AssertionIdSchema, @@ -171,13 +154,7 @@ const assertionHistoryFields = { recordedAt: RelationshipIsoTimestampSchema, validTo: Schema.OptionFromNullOr(RelationshipIsoTimestampSchema), } as const; -const AssertionStateSchema = Schema.Literals([ - 'ACTIVE', - 'ENDED', - 'SUPERSEDED', - 'RETRACTED', - 'DISPUTED', -]); +const AssertionStateSchema = Schema.Literals(['ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED']); export const PartyCorrectionAssertionValueSchema = Schema.Union([ Schema.Struct({ diff --git a/app/verticals/party-registry/shared/domain/counterparty-contract.ts b/app/verticals/party-registry/shared/domain/counterparty-contract.ts index 28ec964ee..53277415a 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-contract.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-contract.ts @@ -1,7 +1,8 @@ import { DateTime, Option, Schema, SchemaGetter } from 'effect'; -import { PartyRefSchema } from '../resources/party.ts'; -import { CounterpartyRefSchema } from '../resources/counterparty.ts'; + import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; +import { CounterpartyRefSchema } from '../resources/counterparty.ts'; +import { PartyRefSchema } from '../resources/party.ts'; export const CounterpartyUuidSchema = Schema.String.check(Schema.isUUID()); const CounterpartyTextSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)); @@ -58,13 +59,7 @@ export const CounterpartyAuditEvidenceSchema = Schema.Struct({ export const CounterpartyRoleTypeSchema = Schema.Literals(['CUSTOMER', 'SUPPLIER']); export type CounterpartyRoleType = typeof CounterpartyRoleTypeSchema.Type; -const CounterpartyRoleStateSchema = Schema.Literals([ - 'ACTIVE', - 'ENDED', - 'SUPERSEDED', - 'RETRACTED', - 'DISPUTED', -]); +const CounterpartyRoleStateSchema = Schema.Literals(['ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED']); export const CounterpartyRolePeriodSchema = Schema.Struct({ endProvenance: Schema.toEncoded(Schema.OptionFromOptionalNullOr(CounterpartyProvenanceSchema)), diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/not-found.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/not-found.ts index 8c51fe670..61b764da0 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/not-found.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/not-found.ts @@ -1,11 +1,9 @@ import { Schema } from 'effect'; + import { CounterpartyUuidSchema } from '../counterparty-contract.ts'; -export class CounterpartyNotFound extends Schema.TaggedError()( - 'CounterpartyNotFound', - { - code: Schema.Literal('counterparty_not_found'), - counterpartyId: CounterpartyUuidSchema, - reason: Schema.String, - }, -) {} +export class CounterpartyNotFound extends Schema.TaggedError()('CounterpartyNotFound', { + code: Schema.Literal('counterparty_not_found'), + counterpartyId: CounterpartyUuidSchema, + reason: Schema.String, +}) {} diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/party-archived.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/party-archived.ts index 81407a705..aa1f76988 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/party-archived.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/party-archived.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { CounterpartyUuidSchema } from '../counterparty-contract.ts'; export class CounterpartyPartyArchived extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/party-not-found.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/party-not-found.ts index c8b0da758..cd12c1f43 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/party-not-found.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/party-not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { CounterpartyUuidSchema } from '../counterparty-contract.ts'; export class CounterpartyPartyNotFound extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/role-already-ended.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/role-already-ended.ts index 7fef49320..4fee72c1a 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/role-already-ended.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/role-already-ended.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { CounterpartyUuidSchema } from '../counterparty-contract.ts'; export class CounterpartyRoleAlreadyEnded extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/role-overlap.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/role-overlap.ts index 2590a6dd8..e1628c1f1 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/role-overlap.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/role-overlap.ts @@ -1,10 +1,7 @@ import { Schema } from 'effect'; -export class CounterpartyRoleOverlap extends Schema.TaggedError()( - 'CounterpartyRoleOverlap', - { - code: Schema.Literal('counterparty_role_overlap'), - reason: Schema.String, - roleType: Schema.Literals(['CUSTOMER', 'SUPPLIER']), - }, -) {} +export class CounterpartyRoleOverlap extends Schema.TaggedError()('CounterpartyRoleOverlap', { + code: Schema.Literal('counterparty_role_overlap'), + reason: Schema.String, + roleType: Schema.Literals(['CUSTOMER', 'SUPPLIER']), +}) {} diff --git a/app/verticals/party-registry/shared/domain/counterparty-errors/role-period-not-found.ts b/app/verticals/party-registry/shared/domain/counterparty-errors/role-period-not-found.ts index d6b8d8435..24ffeb796 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-errors/role-period-not-found.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-errors/role-period-not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { CounterpartyUuidSchema } from '../counterparty-contract.ts'; export class CounterpartyRolePeriodNotFound extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/counterparty-role-period.ts b/app/verticals/party-registry/shared/domain/counterparty-role-period.ts index 416d5744a..cb691a7cf 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-role-period.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-role-period.ts @@ -44,19 +44,14 @@ export const counterpartyContextEvidenceIsSufficient = (method: string): boolean export const roleEvidenceIsSufficient = (roleType: CounterpartyRoleType, method: string): boolean => (roleType === 'CUSTOMER' ? customerEvidenceMethods : supplierEvidenceMethods).has(method); -export const roleEndEvidenceIsSufficient = ( - roleType: CounterpartyRoleType, - method: string, -): boolean => +export const roleEndEvidenceIsSufficient = (roleType: CounterpartyRoleType, method: string): boolean => (roleType === 'CUSTOMER' ? customerEndEvidenceMethods : supplierEndEvidenceMethods).has(method); export const rolePeriodIsCurrentAt = ( period: Pick, instant: string, ): boolean => - period.state === 'ACTIVE' && - period.validFrom <= instant && - (period.validTo === null || instant < period.validTo); + period.state === 'ACTIVE' && period.validFrom <= instant && (period.validTo === null || instant < period.validTo); export interface RolePeriodStorageState { readonly isCurrent: boolean; @@ -69,10 +64,7 @@ export const rolePeriodStorageStateAt = ( ): RolePeriodStorageState => { const ended = period.validTo !== null && period.validTo <= recordedAt; return { - isCurrent: - !ended && - period.validFrom <= recordedAt && - (period.validTo === null || recordedAt < period.validTo), + isCurrent: !ended && period.validFrom <= recordedAt && (period.validTo === null || recordedAt < period.validTo), state: ended ? 'ENDED' : 'ACTIVE', }; }; diff --git a/app/verticals/party-registry/shared/domain/engagement-profile-errors/not-found.ts b/app/verticals/party-registry/shared/domain/engagement-profile-errors/not-found.ts index c56dd2d08..d375d9791 100644 --- a/app/verticals/party-registry/shared/domain/engagement-profile-errors/not-found.ts +++ b/app/verticals/party-registry/shared/domain/engagement-profile-errors/not-found.ts @@ -1,8 +1,6 @@ import { Schema } from 'effect'; -export const EngagementProfileIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('EngagementProfileId'), -); +export const EngagementProfileIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('EngagementProfileId')); export class EngagementProfileNotFound extends Schema.TaggedError()( 'EngagementProfileNotFound', diff --git a/app/verticals/party-registry/shared/domain/engagement-profile.ts b/app/verticals/party-registry/shared/domain/engagement-profile.ts index 75a49a242..029171e88 100644 --- a/app/verticals/party-registry/shared/domain/engagement-profile.ts +++ b/app/verticals/party-registry/shared/domain/engagement-profile.ts @@ -1,13 +1,11 @@ import { Schema } from 'effect'; -import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; + import { CounterpartyRefSchema, PartyRefSchema } from '../party-registry-references.ts'; import { OrganizationEngagementProfileRefSchema } from '../resources/organization-engagement-profile.ts'; import { PersonEngagementProfileRefSchema } from '../resources/person-engagement-profile.ts'; +import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; -export { - EngagementProfileIdSchema, - EngagementProfileNotFound, -} from './engagement-profile-errors/not-found.ts'; +export { EngagementProfileIdSchema, EngagementProfileNotFound } from './engagement-profile-errors/not-found.ts'; export { EngagementProfileConflict } from './engagement-profile-errors/conflict.ts'; export { EngagementProfilePersistenceUnavailable } from './engagement-profile-errors/persistence-unavailable.ts'; export { PartyRegistryReferenceUnavailable } from './engagement-profile-errors/party-registry-reference-unavailable.ts'; @@ -37,8 +35,7 @@ export const AttachOrganizationEngagementPayloadSchema = Schema.Struct({ counterpartyRef: Schema.optionalKey(CounterpartyRefSchema), partyRef: PartyRefSchema, }); -export type AttachOrganizationEngagementPayload = - typeof AttachOrganizationEngagementPayloadSchema.Type; +export type AttachOrganizationEngagementPayload = typeof AttachOrganizationEngagementPayloadSchema.Type; export const AttachPersonEngagementPayloadSchema = Schema.Struct({ counterpartyRef: Schema.optionalKey(CounterpartyRefSchema), @@ -49,8 +46,7 @@ export type AttachPersonEngagementPayload = typeof AttachPersonEngagementPayload export const OrganizationEngagementLifecyclePayloadSchema = Schema.Struct({ profileRef: OrganizationEngagementProfileRefSchema, }); -export type OrganizationEngagementLifecyclePayload = - typeof OrganizationEngagementLifecyclePayloadSchema.Type; +export type OrganizationEngagementLifecyclePayload = typeof OrganizationEngagementLifecyclePayloadSchema.Type; export const PersonEngagementLifecyclePayloadSchema = Schema.Struct({ profileRef: PersonEngagementProfileRefSchema, diff --git a/app/verticals/party-registry/shared/domain/identifier-contracts.ts b/app/verticals/party-registry/shared/domain/identifier-contracts.ts index a1a779e9b..b6b8e2040 100644 --- a/app/verticals/party-registry/shared/domain/identifier-contracts.ts +++ b/app/verticals/party-registry/shared/domain/identifier-contracts.ts @@ -1,8 +1,9 @@ -import { AresAppliedEvidenceSchema } from './ares-application.ts'; import { Schema } from 'effect'; -import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; + import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; +import { AresAppliedEvidenceSchema } from './ares-application.ts'; +import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; export { OfficialIdentifierClaimConflict } from './identifier-errors/claim-conflict.ts'; export { OfficialIdentifierInvalid } from './identifier-errors/invalid.ts'; @@ -38,21 +39,14 @@ export const OfficialIdentifierInputSchema = Schema.Struct({ ); export type OfficialIdentifierInput = typeof OfficialIdentifierInputSchema.Type; -export interface NormalizedOfficialIdentifier extends Omit< - OfficialIdentifierInput, - 'value' | 'namespace' -> { +export interface NormalizedOfficialIdentifier extends Omit { readonly namespace: 'CZ:DIC' | 'CZ:ICO'; readonly normalizedValue: string; } -export const normalizeOfficialIdentifier = ( - input: OfficialIdentifierInput, -): NormalizedOfficialIdentifier => { +export const normalizeOfficialIdentifier = (input: OfficialIdentifierInput): NormalizedOfficialIdentifier => { const normalizedValue = - input.identifierType === 'ICO' - ? input.value.trim().padStart(8, '0') - : input.value.trim().toUpperCase(); + input.identifierType === 'ICO' ? input.value.trim().padStart(8, '0') : input.value.trim().toUpperCase(); return { identifierType: input.identifierType, namespace: input.identifierType === 'ICO' ? 'CZ:ICO' : 'CZ:DIC', @@ -90,9 +84,7 @@ export const OfficialIdentifierAssertionStateSchema = Schema.Literals([ ]); export const OfficialIdentifierAssertionSchema = Schema.Struct({ - externalEvidence: Schema.optionalKey( - Schema.toEncoded(Schema.OptionFromNullOr(AresAppliedEvidenceSchema)), - ), + externalEvidence: Schema.optionalKey(Schema.toEncoded(Schema.OptionFromNullOr(AresAppliedEvidenceSchema))), identifierType: OfficialIdentifierTypeSchema, namespace: Schema.String, normalizedValue: Schema.String, diff --git a/app/verticals/party-registry/shared/domain/identity-contracts.ts b/app/verticals/party-registry/shared/domain/identity-contracts.ts index cb9ea02e9..6a8d5935c 100644 --- a/app/verticals/party-registry/shared/domain/identity-contracts.ts +++ b/app/verticals/party-registry/shared/domain/identity-contracts.ts @@ -1,10 +1,11 @@ import { Brand, Schema } from 'effect'; -import { PartyRefSchema } from '../resources/party.ts'; -import type { PartyRef } from '../resources/party.ts'; + import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; import { PartyMatchDecisionRefSchema } from '../resources/party-match-decision.ts'; -import { OfficialIdentifierInputSchema } from './identifier-contracts.ts'; +import { PartyRefSchema } from '../resources/party.ts'; +import type { PartyRef } from '../resources/party.ts'; import { AresAppliedEvidenceSchema } from './ares-application.ts'; +import { OfficialIdentifierInputSchema } from './identifier-contracts.ts'; export const PartyTypeSchema = Schema.Literals(['PERSON', 'ORGANIZATION', 'UNRESOLVED']); export type PartyType = typeof PartyTypeSchema.Type; @@ -13,17 +14,13 @@ export const isPartyTypeEnrichment = (current: PartyType, requested: PartyType): export const IsoTimestampSchema = Schema.DateTimeUtcFromString; export const PartyIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PartyId')); export type PartyId = typeof PartyIdSchema.Type; -const PartySubjectKeySchema = Schema.Trim.check( - Schema.isMinLength(1), - Schema.isMaxLength(200), -).pipe(Schema.brand('PartySubjectKey')); +const PartySubjectKeySchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(200)).pipe( + Schema.brand('PartySubjectKey'), +); export type PartySubjectKey = typeof PartySubjectKeySchema.Type; export const partyIdFromString = Brand.nominal(); export const partySubjectKeyFromString = Brand.nominal(); -export const PartyDisplayNameSchema = Schema.Trim.check( - Schema.isMinLength(1), - Schema.isMaxLength(300), -); +export const PartyDisplayNameSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(300)); const ProvenanceSchema = Schema.Struct({ externalEvidence: Schema.optionalKey(AresAppliedEvidenceSchema), method: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(100)), @@ -48,9 +45,7 @@ export const PartySubjectEvidenceSchema = Schema.Struct({ subjectKey: PartySubjectKeySchema, }); export type PartySubjectEvidence = typeof PartySubjectEvidenceSchema.Type; -export const PartySubjectEvidenceListSchema = Schema.Array(PartySubjectEvidenceSchema).check( - Schema.isMaxLength(32), -); +export const PartySubjectEvidenceListSchema = Schema.Array(PartySubjectEvidenceSchema).check(Schema.isMaxLength(32)); export const PartySubjectEligibilityVersion = 'party-concrete-subject.v1' as const; export const PartyTypeRuleVersion = 'party-subject-type.v1' as const; export const PartyEvidenceEvaluationSchema = Schema.Struct({ @@ -65,9 +60,9 @@ export type PartyEvidenceEvaluation = typeof PartyEvidenceEvaluationSchema.Type; export const PartyCandidateSchema = Schema.Struct({ displayName: Schema.optionalKey(PartyDisplayNameSchema), - evidenceRefs: Schema.Array( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)), - ).check(Schema.isMaxLength(100)), + evidenceRefs: Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( + Schema.isMaxLength(100), + ), officialIdentifiers: Schema.Array(OfficialIdentifierInputSchema).check(Schema.isMaxLength(20)), partyType: PartyTypeSchema, provenance: ProvenanceSchema, @@ -130,13 +125,11 @@ const partyLifecycleConflictFields = { reason: Schema.String, requestedState: Schema.Literals(['ACTIVE', 'ARCHIVED']), } as const; -const PartyLifecycleConflictSchema = Schema.TaggedStruct( +const PartyLifecycleConflictSchema = Schema.TaggedStruct('PartyLifecycleConflict', partyLifecycleConflictFields); +export const PartyLifecycleConflict = Schema.TaggedError()( 'PartyLifecycleConflict', partyLifecycleConflictFields, ); -export const PartyLifecycleConflict = Schema.TaggedError< - typeof PartyLifecycleConflictSchema.Type ->()('PartyLifecycleConflict', partyLifecycleConflictFields); const partyEvidenceInsufficientFields = { code: Schema.Literal('party_evidence_insufficient'), @@ -146,9 +139,10 @@ const PartyEvidenceInsufficientSchema = Schema.TaggedStruct( 'PartyEvidenceInsufficient', partyEvidenceInsufficientFields, ); -export const PartyEvidenceInsufficient = Schema.TaggedError< - typeof PartyEvidenceInsufficientSchema.Type ->()('PartyEvidenceInsufficient', partyEvidenceInsufficientFields); +export const PartyEvidenceInsufficient = Schema.TaggedError()( + 'PartyEvidenceInsufficient', + partyEvidenceInsufficientFields, +); export type PartyEvidenceInsufficientError = InstanceType; const partyPersistenceUnavailableFields = { @@ -159,7 +153,8 @@ const PartyPersistenceUnavailableSchema = Schema.TaggedStruct( 'PartyPersistenceUnavailable', partyPersistenceUnavailableFields, ); -export const PartyPersistenceUnavailable = Schema.TaggedError< - typeof PartyPersistenceUnavailableSchema.Type ->()('PartyPersistenceUnavailable', partyPersistenceUnavailableFields); +export const PartyPersistenceUnavailable = Schema.TaggedError()( + 'PartyPersistenceUnavailable', + partyPersistenceUnavailableFields, +); export type PartyPersistenceUnavailableError = InstanceType; diff --git a/app/verticals/party-registry/shared/domain/matching-contracts.ts b/app/verticals/party-registry/shared/domain/matching-contracts.ts index 56423c5d6..269f7223f 100644 --- a/app/verticals/party-registry/shared/domain/matching-contracts.ts +++ b/app/verticals/party-registry/shared/domain/matching-contracts.ts @@ -1,8 +1,9 @@ import { DateTime, Option, Schema, SchemaGetter } from 'effect'; + import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; import { PartyMatchDecisionRefSchema } from '../resources/party-match-decision.ts'; -import { PartyRefSchema } from '../resources/party.ts'; import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; +import { PartyRefSchema } from '../resources/party.ts'; import type { PartyCreateOutcomeSchema } from './identity-contracts.ts'; import { PartyCandidateSchema, PartyEvidenceEvaluationSchema } from './identity-contracts.ts'; @@ -13,18 +14,16 @@ export { PartyCreateRecoveryUnavailable } from './party-create-recovery-unavaila const MatchOutcomeSchema = Schema.Literals(['MATCHED', 'NO_MATCH', 'AMBIGUOUS']); type MatchOutcome = typeof MatchOutcomeSchema.Type; -export const RuleKeySchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(100), -).pipe(Schema.brand('RuleKey')); +export const RuleKeySchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)).pipe( + Schema.brand('RuleKey'), +); // Matching contracts predate Option/DateTime models and are consumed directly as JSON-shaped DTOs. // Validate through Effect's temporal and absence codecs while retaining those decoded DTO shapes. const UtcTimestampStringSchema = Schema.String.check( Schema.makeFilter((value) => { const parsed = DateTime.make(value); - const canonicalInput = - value.length === 20 && value.endsWith('Z') ? `${value.slice(0, -1)}.000Z` : value; + const canonicalInput = value.length === 20 && value.endsWith('Z') ? `${value.slice(0, -1)}.000Z` : value; return Option.isSome(parsed) && DateTime.formatIso(parsed.value) === canonicalInput ? undefined : 'timestamp must be a canonical UTC ISO instant'; @@ -53,20 +52,16 @@ export const evaluateExactClaims = (partyIds: readonly string[]) => { } as const; }; -export const PartyMatchRequestSchema = Schema.Struct({ candidate: PartyCandidateSchema }); +export const PartyMatchRequestSchema = Schema.Struct({ + candidate: PartyCandidateSchema, +}); const MatchEvidenceExplanationSchema = Schema.Struct({ evidenceRefs: Schema.optionalKey( - Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( - Schema.isMaxLength(100), - ), + Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check(Schema.isMaxLength(100)), ), identifierType: Schema.optionalKey(Schema.Literals(['ICO', 'CZ_DIC'])), - namespace: Schema.optionalKey( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), - ), - normalizedValue: Schema.optionalKey( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), - ), + namespace: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100))), + normalizedValue: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100))), officialIdentifierRef: Schema.optionalKey(PartyOfficialIdentifierRefSchema), outcome: Schema.optionalKey(MatchOutcomeSchema), reason: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(1000)), @@ -126,12 +121,16 @@ const CommittedCreateOutcomeSchema = Schema.Literals(['CREATED', 'MATCHED_EXISTI const PartyMatchDecisionRecordFieldsSchema = Schema.Struct({ caseRef: Schema.toEncoded(Schema.OptionFromNullOr(DuplicateCandidateCaseRefSchema)), committedCreateOutcome: Schema.toEncoded( - Schema.OptionFromOptionalNullOr(CommittedCreateOutcomeSchema, { onNoneEncoding: null }), + Schema.OptionFromOptionalNullOr(CommittedCreateOutcomeSchema, { + onNoneEncoding: null, + }), ), decidedAt: UtcTimestampStringSchema, decisionRef: PartyMatchDecisionRefSchema, evidenceEvaluation: Schema.toEncoded( - Schema.OptionFromOptionalNullOr(PartyEvidenceEvaluationSchema, { onNoneEncoding: null }), + Schema.OptionFromOptionalNullOr(PartyEvidenceEvaluationSchema, { + onNoneEncoding: null, + }), ), evidenceExplanation: Schema.Array(MatchEvidenceExplanationSchema), matchRuleVersion: Schema.String, @@ -149,11 +148,7 @@ const validateCreateOutcome = (record: DecisionRecord): string | undefined => { if (isCreate && (record.committedCreateOutcome !== expected || record.outcome === 'NO_MATCH')) { return 'Create decisions must preserve the exact committed Create result'; } - if ( - !isCreate && - record.committedCreateOutcome !== null && - record.committedCreateOutcome !== undefined - ) { + if (!isCreate && record.committedCreateOutcome !== null && record.committedCreateOutcome !== undefined) { return 'Only Create operations carry committed Create outcomes'; } return undefined; @@ -166,9 +161,7 @@ const validateDecisionReferences = (record: DecisionRecord): string | undefined : 'Ambiguity requires exactly one case reference'; } if (record.outcome === 'NO_MATCH') { - return record.partyRef === null && record.caseRef === null - ? undefined - : 'NO_MATCH has no result reference'; + return record.partyRef === null && record.caseRef === null ? undefined : 'NO_MATCH has no result reference'; } return record.partyRef !== null && record.caseRef === null ? undefined @@ -176,9 +169,7 @@ const validateDecisionReferences = (record: DecisionRecord): string | undefined }; export const PartyMatchDecisionRecordSchema = PartyMatchDecisionRecordFieldsSchema.check( - Schema.makeFilter( - (record) => validateCreateOutcome(record) ?? validateDecisionReferences(record), - ), + Schema.makeFilter((record) => validateCreateOutcome(record) ?? validateDecisionReferences(record)), ); export const DuplicateCandidateDetailSchema = Schema.Struct({ candidate: PartyCandidateSchema, @@ -194,9 +185,7 @@ export const DuplicateCandidateDetailSchema = Schema.Struct({ revision: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThan(0)), }); -const resolvedCreateResult = ( - record: DecisionRecord, -): typeof PartyCreateOutcomeSchema.Type | null => { +const resolvedCreateResult = (record: DecisionRecord): typeof PartyCreateOutcomeSchema.Type | null => { if ( record.partyRef !== null && record.caseRef === null && @@ -225,7 +214,11 @@ export const committedCreateResult = ( record.partyRef === null && record.outcome === 'AMBIGUOUS' ) { - return { caseRef: record.caseRef, decisionRef: record.decisionRef, outcome: 'AMBIGUOUS' }; + return { + caseRef: record.caseRef, + decisionRef: record.decisionRef, + outcome: 'AMBIGUOUS', + }; } return resolvedCreateResult(record); }; diff --git a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/broken-chain.ts b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/broken-chain.ts index b2f894350..7726e88ab 100644 --- a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/broken-chain.ts +++ b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/broken-chain.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyIdJsonSchema, TenantIdJsonSchema } from './shared.ts'; export class PartyAliasResolutionBrokenChain extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cross-tenant.ts b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cross-tenant.ts index 9b5d9b284..018720930 100644 --- a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cross-tenant.ts +++ b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cross-tenant.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyIdJsonSchema, TenantIdJsonSchema } from './shared.ts'; export class PartyAliasResolutionCrossTenant extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cycle.ts b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cycle.ts index 2bc5f8728..22d675c04 100644 --- a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cycle.ts +++ b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/cycle.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyIdJsonSchema, TenantIdJsonSchema } from './shared.ts'; export class PartyAliasResolutionCycle extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/write-rejected.ts b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/write-rejected.ts index 5e69c28a0..aec13939f 100644 --- a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/write-rejected.ts +++ b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/write-rejected.ts @@ -1,12 +1,10 @@ import { Schema } from 'effect'; + import { PartyRefSchema } from '../../resources/party.ts'; -export class PartyAliasWriteRejected extends Schema.TaggedError()( - 'PartyAliasWriteRejected', - { - aliasPartyRef: PartyRefSchema, - canonicalPartyRef: PartyRefSchema, - code: Schema.Literal('party_alias_write_rejected'), - reason: Schema.String, - }, -) {} +export class PartyAliasWriteRejected extends Schema.TaggedError()('PartyAliasWriteRejected', { + aliasPartyRef: PartyRefSchema, + canonicalPartyRef: PartyRefSchema, + code: Schema.Literal('party_alias_write_rejected'), + reason: Schema.String, +}) {} diff --git a/app/verticals/party-registry/shared/domain/merge-readiness.ts b/app/verticals/party-registry/shared/domain/merge-readiness.ts index da030e0fa..39a6265c4 100644 --- a/app/verticals/party-registry/shared/domain/merge-readiness.ts +++ b/app/verticals/party-registry/shared/domain/merge-readiness.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; const MergeReadinessOwnerKeySchema = Schema.String.check(Schema.isMinLength(1)).pipe( diff --git a/app/verticals/party-registry/shared/domain/merge-selection.ts b/app/verticals/party-registry/shared/domain/merge-selection.ts index f76606868..fc987f762 100644 --- a/app/verticals/party-registry/shared/domain/merge-selection.ts +++ b/app/verticals/party-registry/shared/domain/merge-selection.ts @@ -1,4 +1,5 @@ import { DateTime, Option, Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; import { IsoTimestampSchema } from './identity-contracts.ts'; @@ -45,13 +46,9 @@ const MergeEvaluatedCandidateSnapshotSchema = Schema.Struct({ }); export const MergeSelectionEvidenceStepSchema = Schema.Struct({ candidatePartyRefs: Schema.Array(PartyRefSchema).check(Schema.isMinLength(2)), - candidateSnapshots: Schema.Array(MergeEvaluatedCandidateSnapshotSchema).check( - Schema.isMinLength(2), - ), + candidateSnapshots: Schema.Array(MergeEvaluatedCandidateSnapshotSchema).check(Schema.isMinLength(2)), criterion: MergeSelectionEvidenceCriterionSchema, - evidenceRefs: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check( - Schema.isMinLength(1), - ), + evidenceRefs: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(Schema.isMinLength(1)), explanation: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)), winnerPartyRef: Schema.toEncoded(Schema.OptionFromNullOr(PartyRefSchema)), }); @@ -68,9 +65,7 @@ const ConfirmedDuplicateSetSchema = Schema.Struct({ confirmedDuplicateDecisionId: Schema.toEncoded(ConfirmedDuplicateDecisionIdSchema), confirmedPartyRefs: Schema.Array(PartyRefSchema).check(Schema.isMinLength(2)), decisionActorPrincipalId: Schema.toEncoded(DecisionActorPrincipalIdSchema), - evidenceRefs: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check( - Schema.isMinLength(1), - ), + evidenceRefs: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(Schema.isMinLength(1)), }); export type ConfirmedDuplicateSet = typeof ConfirmedDuplicateSetSchema.Type; diff --git a/app/verticals/party-registry/shared/domain/relationship-contract.ts b/app/verticals/party-registry/shared/domain/relationship-contract.ts index 8d8a66553..d71706ec6 100644 --- a/app/verticals/party-registry/shared/domain/relationship-contract.ts +++ b/app/verticals/party-registry/shared/domain/relationship-contract.ts @@ -1,6 +1,7 @@ import { DateTime, Option, Schema, SchemaGetter } from 'effect'; -import { PartyRefSchema } from '../resources/party.ts'; + import { PartyRelationshipRefSchema } from '../resources/party-relationship.ts'; +import { PartyRefSchema } from '../resources/party.ts'; export { PartyRelationshipCorrectionRequired, @@ -17,11 +18,7 @@ export { export const ContactPersonOfRelationshipType = 'CONTACT_PERSON_OF' as const; export const PartyRelationshipTypeSchema = Schema.Literal(ContactPersonOfRelationshipType); -export const RelationshipPartyTypeSchema = Schema.Literals([ - 'PERSON', - 'ORGANIZATION', - 'UNRESOLVED', -]); +export const RelationshipPartyTypeSchema = Schema.Literals(['PERSON', 'ORGANIZATION', 'UNRESOLVED']); export const RelationshipIsoTimestampSchema = Schema.String.pipe( Schema.check( @@ -42,10 +39,7 @@ export type RelationshipIsoTimestamp = typeof RelationshipIsoTimestampSchema.Typ const BoundedTextSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(300)); const ReasonSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(1000)); -const PositiveRevisionSchema = Schema.Finite.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), -); +const PositiveRevisionSchema = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)); export const PartyRelationshipProvenanceSchema = Schema.Struct({ method: BoundedTextSchema, @@ -107,12 +101,7 @@ const RelationshipStoredEndpointSchema = Schema.Struct({ const PartyRelationshipStateSchema = Schema.Literals(['SCHEDULED', 'CURRENT', 'HISTORICAL']); export type PartyRelationshipState = typeof PartyRelationshipStateSchema.Type; -export const PartyRelationshipAssertionStateSchema = Schema.Literals([ - 'ACTIVE', - 'SUPERSEDED', - 'RETRACTED', - 'DISPUTED', -]); +export const PartyRelationshipAssertionStateSchema = Schema.Literals(['ACTIVE', 'SUPERSEDED', 'RETRACTED', 'DISPUTED']); export const RelationshipEndEvidenceSchema = Schema.Struct({ effectiveAt: RelationshipIsoTimestampSchema, @@ -133,9 +122,7 @@ export const UpdateRelationshipAuditEvidenceSchema = Schema.Struct({ previousValidTo: Schema.OptionFromNullOr(RelationshipIsoTimestampSchema), relationshipRef: PartyRelationshipRefSchema, }); -export const UpdateRelationshipAuditEvidenceJsonSchema = Schema.toEncoded( - UpdateRelationshipAuditEvidenceSchema, -); +export const UpdateRelationshipAuditEvidenceJsonSchema = Schema.toEncoded(UpdateRelationshipAuditEvidenceSchema); export const EndRelationshipAuditEvidenceSchema = Schema.Struct({ effectiveAt: RelationshipIsoTimestampSchema, newProvenance: PartyRelationshipProvenanceSchema, @@ -143,9 +130,7 @@ export const EndRelationshipAuditEvidenceSchema = Schema.Struct({ reason: Schema.OptionFromNullOr(ReasonSchema), relationshipRef: PartyRelationshipRefSchema, }); -export const EndRelationshipAuditEvidenceJsonSchema = Schema.toEncoded( - EndRelationshipAuditEvidenceSchema, -); +export const EndRelationshipAuditEvidenceJsonSchema = Schema.toEncoded(EndRelationshipAuditEvidenceSchema); export const PartyRelationshipDetailSchema = Schema.Struct({ assertionState: PartyRelationshipAssertionStateSchema, diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/correction-required.ts b/app/verticals/party-registry/shared/domain/relationship-errors/correction-required.ts index e24eb69fd..b0a2c697f 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/correction-required.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/correction-required.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipCorrectionRequired extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-not-found.ts b/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-not-found.ts index 00979bd42..a3d84828a 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-not-found.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyRefSchema } from '../../resources/party.ts'; import { RelationshipEndpointSchema, RelationshipErrorBase } from './shared.ts'; diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-type-mismatch.ts b/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-type-mismatch.ts index 66e069d2d..69f027e3d 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-type-mismatch.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/endpoint-type-mismatch.ts @@ -1,9 +1,6 @@ import { Schema } from 'effect'; -import { - RelationshipEndpointSchema, - RelationshipErrorBase, - RelationshipErrorPartyTypeSchema, -} from './shared.ts'; + +import { RelationshipEndpointSchema, RelationshipErrorBase, RelationshipErrorPartyTypeSchema } from './shared.ts'; export class PartyRelationshipEndpointTypeMismatch extends Schema.TaggedError()( 'PartyRelationshipEndpointTypeMismatch', diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/index.ts b/app/verticals/party-registry/shared/domain/relationship-errors/index.ts index 8510f0148..ad52e86e8 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/index.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/index.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyAliasWriteRejected } from '../merge-alias-resolution.ts'; import { PartyRelationshipCorrectionRequired } from './correction-required.ts'; import { PartyRelationshipEndpointNotFound } from './endpoint-not-found.ts'; diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/invalid-interval.ts b/app/verticals/party-registry/shared/domain/relationship-errors/invalid-interval.ts index 92f6030be..defb905cd 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/invalid-interval.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/invalid-interval.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipInvalidInterval extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/not-found.ts b/app/verticals/party-registry/shared/domain/relationship-errors/not-found.ts index 89ce598a4..7c8461f1d 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/not-found.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/not-found.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipNotFound extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/overlap-conflict.ts b/app/verticals/party-registry/shared/domain/relationship-errors/overlap-conflict.ts index 29ff2501a..378ff871b 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/overlap-conflict.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/overlap-conflict.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PartyRelationshipRefSchema } from '../../resources/party-relationship.ts'; import { RelationshipErrorBase } from './shared.ts'; diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/persistence-unavailable.ts b/app/verticals/party-registry/shared/domain/relationship-errors/persistence-unavailable.ts index 48c4d7f25..0ba1ce9e3 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/persistence-unavailable.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/persistence-unavailable.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipPersistenceUnavailable extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/revision-conflict.ts b/app/verticals/party-registry/shared/domain/relationship-errors/revision-conflict.ts index 0c436f709..f39c40e46 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/revision-conflict.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/revision-conflict.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { PositiveRelationshipRevisionSchema, RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipRevisionConflict extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/shared.ts b/app/verticals/party-registry/shared/domain/relationship-errors/shared.ts index 6436c8438..83eb57537 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/shared.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/shared.ts @@ -6,13 +6,6 @@ export const RelationshipErrorBase = { export const RelationshipEndpointSchema = Schema.Literals(['from', 'to']); -export const RelationshipErrorPartyTypeSchema = Schema.Literals([ - 'PERSON', - 'ORGANIZATION', - 'UNRESOLVED', -]); +export const RelationshipErrorPartyTypeSchema = Schema.Literals(['PERSON', 'ORGANIZATION', 'UNRESOLVED']); -export const PositiveRelationshipRevisionSchema = Schema.Finite.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), -); +export const PositiveRelationshipRevisionSchema = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)); diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/type-unsupported.ts b/app/verticals/party-registry/shared/domain/relationship-errors/type-unsupported.ts index 734bb6e10..41c79ca81 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/type-unsupported.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/type-unsupported.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect'; + import { RelationshipErrorBase } from './shared.ts'; export class PartyRelationshipTypeUnsupported extends Schema.TaggedError()( diff --git a/app/verticals/party-registry/shared/domain/relationship-temporal.ts b/app/verticals/party-registry/shared/domain/relationship-temporal.ts index 9776d85ee..671fa5887 100644 --- a/app/verticals/party-registry/shared/domain/relationship-temporal.ts +++ b/app/verticals/party-registry/shared/domain/relationship-temporal.ts @@ -1,4 +1,5 @@ import { DateTime, Option } from 'effect'; + import type { PartyRelationshipState, RelationshipIsoTimestamp } from './relationship-contract.ts'; interface RelationshipPeriod { @@ -28,7 +29,10 @@ interface RelationshipEndState extends RelationshipUpdateState { interface RelationshipEndRequest { readonly effectiveAt: RelationshipIsoTimestamp; readonly expectedRevision: number; - readonly provenance: Readonly<{ readonly method: string; readonly source: string }>; + readonly provenance: Readonly<{ + readonly method: string; + readonly source: string; + }>; readonly reason?: string | undefined; } @@ -40,9 +44,7 @@ export const classifyRelationshipValidity = ( if (Option.isSome(validFrom) && DateTime.Order(now, validFrom.value) < 0) { return 'SCHEDULED'; } - return Option.isNone(validTo) || DateTime.Order(now, validTo.value) < 0 - ? 'CURRENT' - : 'HISTORICAL'; + return Option.isNone(validTo) || DateTime.Order(now, validTo.value) < 0 ? 'CURRENT' : 'HISTORICAL'; }; const lowerBeforeUpper = ( @@ -57,13 +59,10 @@ const sameOptionalInstant = ( left: Option.Option, right: Option.Option, ): boolean => - Option.isNone(left) - ? Option.isNone(right) - : Option.isSome(right) && sameInstant(left.value, right.value); + Option.isNone(left) ? Option.isNone(right) : Option.isSome(right) && sameInstant(left.value, right.value); const overlaps = (left: RelationshipPeriod, right: RelationshipPeriod): boolean => - lowerBeforeUpper(left.validFrom, right.validTo) && - lowerBeforeUpper(right.validFrom, left.validTo); + lowerBeforeUpper(left.validFrom, right.validTo) && lowerBeforeUpper(right.validFrom, left.validTo); export const decideRelationshipCreate = ( existingPeriods: readonly RelationshipPeriod[], @@ -94,8 +93,7 @@ const requiresStartCorrection = ( request.validFrom !== undefined && Option.isSome(current.validFrom) && !sameInstant(request.validFrom, current.validFrom.value) && - (DateTime.Order(current.validFrom.value, now) <= 0 || - DateTime.Order(request.validFrom, now) <= 0); + (DateTime.Order(current.validFrom.value, now) <= 0 || DateTime.Order(request.validFrom, now) <= 0); const requiresEndCorrection = ( current: RelationshipUpdateState, @@ -122,16 +120,21 @@ export const decideRelationshipUpdate = ( request: RelationshipUpdateRequest, now: RelationshipIsoTimestamp, ): - | Readonly<{ readonly _tag: 'correction_required'; readonly fact: 'validFrom' | 'validTo' }> + | Readonly<{ + readonly _tag: 'correction_required'; + readonly fact: 'validFrom' | 'validTo'; + }> | Readonly<{ readonly _tag: 'end_required' }> | Readonly<{ readonly _tag: 'invalid_interval' }> - | Readonly<{ readonly _tag: 'revision_conflict'; readonly actualRevision: number }> + | Readonly<{ + readonly _tag: 'revision_conflict'; + readonly actualRevision: number; + }> | Readonly<{ readonly _tag: 'update' }> => { if (current.revision !== request.expectedRevision) { return { _tag: 'revision_conflict', actualRevision: current.revision }; } - const nextValidFrom = - request.validFrom === undefined ? current.validFrom : Option.some(request.validFrom); + const nextValidFrom = request.validFrom === undefined ? current.validFrom : Option.some(request.validFrom); const nextValidTo = request.validTo === undefined ? current.validTo : request.validTo; if ( Option.isSome(nextValidFrom) && @@ -152,10 +155,7 @@ export const decideRelationshipUpdate = ( return { _tag: 'update' }; }; -const decideRepeatedRelationshipEnd = ( - current: RelationshipEndState, - request: RelationshipEndRequest, -) => { +const decideRepeatedRelationshipEnd = (current: RelationshipEndState, request: RelationshipEndRequest) => { if ( current.endReason === (request.reason ?? null) && current.endProvenanceMethod === request.provenance.method && @@ -163,11 +163,7 @@ const decideRepeatedRelationshipEnd = ( ) { return { _tag: 'unchanged' } as const; } - if ( - current.endReason === null && - current.endProvenanceMethod === null && - current.endProvenanceSource === null - ) { + if (current.endReason === null && current.endProvenanceMethod === null && current.endProvenanceSource === null) { return { _tag: 'attach_end_evidence' } as const; } return { _tag: 'correction_required', fact: 'validTo' } as const; @@ -182,16 +178,16 @@ export const decideRelationshipEnd = ( | Readonly<{ readonly _tag: 'correction_required'; readonly fact: 'validTo' }> | Readonly<{ readonly _tag: 'end' }> | Readonly<{ readonly _tag: 'invalid_interval' }> - | Readonly<{ readonly _tag: 'revision_conflict'; readonly actualRevision: number }> + | Readonly<{ + readonly _tag: 'revision_conflict'; + readonly actualRevision: number; + }> | Readonly<{ readonly _tag: 'unchanged' }> | Readonly<{ readonly _tag: 'update_required' }> => { if (current.revision !== request.expectedRevision) { return { _tag: 'revision_conflict', actualRevision: current.revision }; } - if ( - Option.isSome(current.validFrom) && - DateTime.Order(request.effectiveAt, current.validFrom.value) <= 0 - ) { + if (Option.isSome(current.validFrom) && DateTime.Order(request.effectiveAt, current.validFrom.value) <= 0) { return { _tag: 'invalid_interval' }; } if (Option.isSome(current.validTo) && sameInstant(current.validTo.value, request.effectiveAt)) { diff --git a/app/verticals/party-registry/shared/domain/search-projection-gateway.ts b/app/verticals/party-registry/shared/domain/search-projection-gateway.ts index 40c2a345a..6eda58901 100644 --- a/app/verticals/party-registry/shared/domain/search-projection-gateway.ts +++ b/app/verticals/party-registry/shared/domain/search-projection-gateway.ts @@ -1,10 +1,11 @@ import { Context } from 'effect'; import type { Effect, Schema } from 'effect'; + import type { CounterpartyRef } from '../resources/counterparty.ts'; import type { PartyRef } from '../resources/party.ts'; import type { CounterpartyIsoTimestampSchema } from './counterparty-contract.ts'; -import type { CurrentCounterpartyRole, SearchLegalEntityContext } from './search-result.ts'; import type { PartySearchProjectionUnavailable } from './search-projection-error.ts'; +import type { CurrentCounterpartyRole, SearchLegalEntityContext } from './search-result.ts'; export interface PartySearchProjectionQuery { readonly includeArchived: boolean; diff --git a/app/verticals/party-registry/shared/domain/search-result.ts b/app/verticals/party-registry/shared/domain/search-result.ts index b39ab384b..a0bd3d089 100644 --- a/app/verticals/party-registry/shared/domain/search-result.ts +++ b/app/verticals/party-registry/shared/domain/search-result.ts @@ -1,17 +1,13 @@ import { Schema } from 'effect'; + import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { PartyRefSchema } from '../resources/party.ts'; const BoundedTitleSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(300)); const TenantIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TenantId')); -const LegalEntityIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('LegalEntityId'), -); - -export const PartySearchQuerySchema = Schema.Trim.check( - Schema.isMinLength(1), - Schema.isMaxLength(200), -); +const LegalEntityIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('LegalEntityId')); + +export const PartySearchQuerySchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(200)); export const CurrentCounterpartyRoleSchema = Schema.Literals(['CUSTOMER', 'SUPPLIER']); export type CurrentCounterpartyRole = typeof CurrentCounterpartyRoleSchema.Type; diff --git a/app/verticals/party-registry/shared/domain/search-semantics.ts b/app/verticals/party-registry/shared/domain/search-semantics.ts index e66ebb2ea..2a0ccedfb 100644 --- a/app/verticals/party-registry/shared/domain/search-semantics.ts +++ b/app/verticals/party-registry/shared/domain/search-semantics.ts @@ -1,15 +1,9 @@ import { DateTime, Option, Schema } from 'effect'; + import type { CounterpartyRef } from '../resources/counterparty.ts'; import type { PartyRef } from '../resources/party.ts'; -import type { - CounterpartySearchProjectionHit, - PartySearchProjectionHit, -} from './search-projection-gateway.ts'; -import type { - CounterpartySearchResult, - CurrentCounterpartyRole, - PartySearchResult, -} from './search-result.ts'; +import type { CounterpartySearchProjectionHit, PartySearchProjectionHit } from './search-projection-gateway.ts'; +import type { CounterpartySearchResult, CurrentCounterpartyRole, PartySearchResult } from './search-result.ts'; const SearchProjectionViolationSchema = Schema.TaggedStruct('SearchProjectionViolation', { reason: Schema.String, @@ -24,16 +18,14 @@ export type SearchResults = SearchResultsTag & { export type SearchNormalizationResult = SearchProjectionViolation | SearchResults; -const violation = (reason: string): SearchProjectionViolation => - SearchProjectionViolationSchema.make({ reason }); +const violation = (reason: string): SearchProjectionViolation => SearchProjectionViolationSchema.make({ reason }); const searchResults = (items: readonly Result[]): SearchResults => ({ ...SearchResultsTagSchema.make({}), items, }); -const refKey = (ref: PartyRef | CounterpartyRef): string => - `${ref.tenantId}:${ref.resourceType}:${ref.resourceId}`; +const refKey = (ref: PartyRef | CounterpartyRef): string => `${ref.tenantId}:${ref.resourceType}:${ref.resourceId}`; const samePartyRef = (left: PartyRef, right: PartyRef): boolean => refKey(left) === refKey(right); @@ -46,7 +38,10 @@ const partyHitViolatesScope = (hit: PartySearchProjectionHit, tenantId: string): hit.title.trim().length === 0; export const normalizePartySearchHits = ( - scope: Readonly<{ readonly includeArchived: boolean; readonly tenantId: string }>, + scope: Readonly<{ + readonly includeArchived: boolean; + readonly tenantId: string; + }>, hits: readonly PartySearchProjectionHit[], ): SearchNormalizationResult => { const byCanonicalParty = new Map(); @@ -56,10 +51,7 @@ export const normalizePartySearchHits = ( } const key = refKey(hit.canonicalPartyRef); const existing = byCanonicalParty.get(key); - if ( - existing !== undefined && - (existing.archived !== hit.archived || existing.title !== hit.title.trim()) - ) { + if (existing !== undefined && (existing.archived !== hit.archived || existing.title !== hit.title.trim())) { return violation('Party Search projection returned conflicting canonical Party facts'); } const matchedViaAlias = isAliasHit(hit.canonicalPartyRef, hit.matchedPartyRef); @@ -75,9 +67,7 @@ export const normalizePartySearchHits = ( } } - return searchResults( - [...byCanonicalParty.values()].filter(({ archived }) => scope.includeArchived || !archived), - ); + return searchResults([...byCanonicalParty.values()].filter(({ archived }) => scope.includeArchived || !archived)); }; const parseInstant = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString); @@ -198,10 +188,7 @@ export const normalizeCounterpartySearchHits = ( }, ref: hit.counterpartyRef, }); - } else if ( - isAliasHit(hit.canonicalPartyRef, hit.matchedPartyRef) && - !existing.party.matchedViaAlias - ) { + } else if (isAliasHit(hit.canonicalPartyRef, hit.matchedPartyRef) && !existing.party.matchedViaAlias) { byCounterparty.set(key, { ...existing, party: { ...existing.party, matchedViaAlias: true }, diff --git a/app/verticals/party-registry/shared/engagement-profile-api.ts b/app/verticals/party-registry/shared/engagement-profile-api.ts index 4e1eecfbd..3e41fe042 100644 --- a/app/verticals/party-registry/shared/engagement-profile-api.ts +++ b/app/verticals/party-registry/shared/engagement-profile-api.ts @@ -1,16 +1,9 @@ -import { - makeProblemDetailsSchema, - makeRetryableProblemDetailsSchema, -} from '@app/shared-contracts/problem-details'; -/* eslint-disable oxc/no-barrel-file -- This is the generated public contract aggregate; remove-when: Codesmith emits direct re-exports. */ -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - Schema, -} from '@modern-js/plugin-bff/effect-client'; import { createMicroVerticalOperationContext } from '@app/shared-contracts'; import type { MicroVerticalOperationContext } from '@app/shared-contracts'; +import { makeProblemDetailsSchema, makeRetryableProblemDetailsSchema } from '@app/shared-contracts/problem-details'; +/* eslint-disable oxc/no-barrel-file -- This is the generated public contract aggregate; remove-when: Codesmith emits direct re-exports. */ +import { HttpApi, HttpApiEndpoint, HttpApiGroup, Schema } from '@modern-js/plugin-bff/effect-client'; + import { AttachOrganizationEngagementPayloadSchema, AttachPersonEngagementPayloadSchema, @@ -25,54 +18,30 @@ export * from './apis/organization-engagement-profile.ts'; export * from './apis/person-engagement-profile.ts'; export const ContactsMutationHeadersSchema = Schema.Struct({ - 'idempotency-key': Schema.optionalKey( - Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), - ), + 'idempotency-key': Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200))), }); -export const ContactsInvalidRequestProblemSchema = makeProblemDetailsSchema( - 'ContactsInvalidRequestProblem', - 400, -); -export const ContactsAuthenticationProblemSchema = makeProblemDetailsSchema( - 'ContactsAuthenticationProblem', - 401, -); -export const ContactsForbiddenProblemSchema = makeProblemDetailsSchema( - 'ContactsForbiddenProblem', - 403, -); -export const ContactsNotFoundProblemSchema = makeProblemDetailsSchema( - 'ContactsNotFoundProblem', - 404, -); -export const ContactsConflictProblemSchema = makeProblemDetailsSchema( - 'ContactsConflictProblem', - 409, - { - code: Schema.Literals([ - 'contacts_counterparty_customer_role_required', - 'contacts_engagement_profile_already_exists', - 'contacts_engagement_profile_lifecycle_conflict', - 'contacts_party_counterparty_mismatch', - 'contacts_party_alias_requires_canonical_reference', - 'contacts_party_archived', - 'contacts_party_type_mismatch', - ]), - }, -); +export const ContactsInvalidRequestProblemSchema = makeProblemDetailsSchema('ContactsInvalidRequestProblem', 400); +export const ContactsAuthenticationProblemSchema = makeProblemDetailsSchema('ContactsAuthenticationProblem', 401); +export const ContactsForbiddenProblemSchema = makeProblemDetailsSchema('ContactsForbiddenProblem', 403); +export const ContactsNotFoundProblemSchema = makeProblemDetailsSchema('ContactsNotFoundProblem', 404); +export const ContactsConflictProblemSchema = makeProblemDetailsSchema('ContactsConflictProblem', 409, { + code: Schema.Literals([ + 'contacts_counterparty_customer_role_required', + 'contacts_engagement_profile_already_exists', + 'contacts_engagement_profile_lifecycle_conflict', + 'contacts_party_counterparty_mismatch', + 'contacts_party_alias_requires_canonical_reference', + 'contacts_party_archived', + 'contacts_party_type_mismatch', + ]), +}); export const ContactsPreconditionRequiredProblemSchema = makeProblemDetailsSchema( 'ContactsPreconditionRequiredProblem', 428, ); -export const ContactsUnavailableProblemSchema = makeRetryableProblemDetailsSchema( - 'ContactsUnavailableProblem', - 503, -); -export const ContactsInternalProblemSchema = makeProblemDetailsSchema( - 'ContactsInternalProblem', - 500, -); +export const ContactsUnavailableProblemSchema = makeRetryableProblemDetailsSchema('ContactsUnavailableProblem', 503); +export const ContactsInternalProblemSchema = makeProblemDetailsSchema('ContactsInternalProblem', 500); export type ContactsProblem = | typeof ContactsInvalidRequestProblemSchema.Type @@ -95,9 +64,7 @@ const mutationErrors = [ ] as const; const lifecycleErrors = [...mutationErrors, ContactsNotFoundProblemSchema] as const; -export const organizationEngagementMutationApi = HttpApi.make( - 'OrganizationEngagementMutationApi', -).add( +export const organizationEngagementMutationApi = HttpApi.make('OrganizationEngagementMutationApi').add( HttpApiGroup.make('organizationEngagementMutations') .add( HttpApiEndpoint.post('attach', '/contacts/engagement/organizations/attach', { @@ -153,10 +120,7 @@ export const personEngagementMutationApi = HttpApi.make('PersonEngagementMutatio ), ); -const operation = ( - method: Method, - routePath: RoutePath, -) => +const operation = (method: Method, routePath: RoutePath) => createMicroVerticalOperationContext({ method, operationId: `PartyRegistryApi:${routePath}` as const, @@ -170,9 +134,6 @@ export const engagementProfileOperationContexts = { attachPersonEngagement: operation('POST', '/contacts/engagement/people/attach'), organizationEngagementProfile: operation('POST', '/reads/organization-engagement-profile'), personEngagementProfile: operation('POST', '/reads/person-engagement-profile'), - unarchiveOrganizationEngagement: operation( - 'POST', - '/contacts/engagement/organizations/unarchive', - ), + unarchiveOrganizationEngagement: operation('POST', '/contacts/engagement/organizations/unarchive'), unarchivePersonEngagement: operation('POST', '/contacts/engagement/people/unarchive'), } satisfies Record; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-added-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-added-v1.ts index 29b593dc3..ef85e1d64 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-added-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-added-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.contact-point-added.v1 import { Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-ended-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-ended-v1.ts index 64cf6f9e2..822a2927b 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-ended-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-ended-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.contact-point-ended.v1 import { Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-updated-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-updated-v1.ts index d85707c08..7ca70073c 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-contact-point-updated-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-contact-point-updated-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.contact-point-updated.v1 import { Schema } from 'effect'; + import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-created-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-created-v1.ts index 471f0cad5..5e27b845f 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-created-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-created-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.counterparty-created.v1 import { Schema } from 'effect'; + import { LegalEntityRefSchema } from '../domain/counterparty-contract.ts'; import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-added-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-added-v1.ts index 6b72c9206..742777aec 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-added-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-added-v1.ts @@ -2,12 +2,10 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.counterparty-role-added.v1 import { Schema } from 'effect'; -import { - CounterpartyIsoTimestampSchema, - CounterpartyRoleTypeSchema, -} from '../domain/counterparty-contract.ts'; -import { CounterpartyRefSchema } from '../resources/counterparty.ts'; + +import { CounterpartyIsoTimestampSchema, CounterpartyRoleTypeSchema } from '../domain/counterparty-contract.ts'; import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; +import { CounterpartyRefSchema } from '../resources/counterparty.ts'; export const OutboxPayloadSchema = Schema.Struct({ counterpartyRef: CounterpartyRefSchema, diff --git a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-ended-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-ended-v1.ts index 0747148fd..07a5372ce 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-ended-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-counterparty-role-ended-v1.ts @@ -2,12 +2,10 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.counterparty-role-ended.v1 import { Schema } from 'effect'; -import { - CounterpartyIsoTimestampSchema, - CounterpartyRoleTypeSchema, -} from '../domain/counterparty-contract.ts'; -import { CounterpartyRefSchema } from '../resources/counterparty.ts'; + +import { CounterpartyIsoTimestampSchema, CounterpartyRoleTypeSchema } from '../domain/counterparty-contract.ts'; import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; +import { CounterpartyRefSchema } from '../resources/counterparty.ts'; export const OutboxPayloadSchema = Schema.Struct({ counterpartyRef: CounterpartyRefSchema, diff --git a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-added-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-added-v1.ts index bcc857f9d..0a0d43e7e 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-added-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-added-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.official-identifier-added.v1 import { Schema } from 'effect'; + import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-ended-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-ended-v1.ts index 807e9f939..67929f0ba 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-ended-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-ended-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.official-identifier-ended.v1 import { Schema } from 'effect'; + import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-updated-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-updated-v1.ts index 772335961..da7ace0bf 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-updated-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-official-identifier-updated-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.official-identifier-updated.v1 import { Schema } from 'effect'; + import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-party-archived-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-party-archived-v1.ts index 882586409..06e4ff030 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-party-archived-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-party-archived-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.party-archived.v1 import { Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; export const OutboxPayloadSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/outbox/party-registry-party-created-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-party-created-v1.ts index 1510782fb..0a6cfb030 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-party-created-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-party-created-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.party-created.v1 import { Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; export const OutboxPayloadSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/outbox/party-registry-party-fact-corrected-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-party-fact-corrected-v1.ts index e35e2fb4a..da04f905d 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-party-fact-corrected-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-party-fact-corrected-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.party-fact-corrected.v1 import { Schema } from 'effect'; + import { PartyCorrectionRefSchema } from '../resources/party-correction.ts'; import { PartyRefSchema } from '../resources/party.ts'; diff --git a/app/verticals/party-registry/shared/outbox/party-registry-party-unarchived-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-party-unarchived-v1.ts index e3c8d5f57..b0de88d68 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-party-unarchived-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-party-unarchived-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.party-unarchived.v1 import { Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; export const OutboxPayloadSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/outbox/party-registry-party-updated-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-party-updated-v1.ts index c3e740458..49b2bd17c 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-party-updated-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-party-updated-v1.ts @@ -2,6 +2,7 @@ // @ontos-outbox-producer party.registry // @ontos-outbox-topic party.registry.party-updated.v1 import { Schema } from 'effect'; + import { PartyRefSchema } from '../resources/party.ts'; export const OutboxPayloadSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/outbox/party-registry-search-rebuild-requested-v1.ts b/app/verticals/party-registry/shared/outbox/party-registry-search-rebuild-requested-v1.ts index 122a47285..b0b896c6a 100644 --- a/app/verticals/party-registry/shared/outbox/party-registry-search-rebuild-requested-v1.ts +++ b/app/verticals/party-registry/shared/outbox/party-registry-search-rebuild-requested-v1.ts @@ -3,9 +3,7 @@ // @ontos-outbox-topic party.registry.search-rebuild-requested.v1 import { Schema } from 'effect'; -const SearchRebuildRequestIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('SearchRebuildRequestId'), -); +const SearchRebuildRequestIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('SearchRebuildRequestId')); export const OutboxPayloadSchema = Schema.Struct({ requestId: Schema.toEncoded(SearchRebuildRequestIdSchema), diff --git a/app/verticals/party-registry/shared/party-registry-references.ts b/app/verticals/party-registry/shared/party-registry-references.ts index 0890db3f5..7caa5cbdd 100644 --- a/app/verticals/party-registry/shared/party-registry-references.ts +++ b/app/verticals/party-registry/shared/party-registry-references.ts @@ -1,5 +1,2 @@ -export { - CounterpartyRefSchema, - type CounterpartyRef, -} from '@app/party-registry/resources/counterparty'; +export { CounterpartyRefSchema, type CounterpartyRef } from '@app/party-registry/resources/counterparty'; export { PartyRefSchema, type PartyRef } from '@app/party-registry/resources/party'; diff --git a/app/verticals/party-registry/shared/resources/counterparty-role-period.ts b/app/verticals/party-registry/shared/resources/counterparty-role-period.ts index a6c12dcd5..63daebfb9 100644 --- a/app/verticals/party-registry/shared/resources/counterparty-role-period.ts +++ b/app/verticals/party-registry/shared/resources/counterparty-role-period.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug counterparty-role-period import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const CounterpartyRolePeriodRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/counterparty.ts b/app/verticals/party-registry/shared/resources/counterparty.ts index 1d719f762..934ea7076 100644 --- a/app/verticals/party-registry/shared/resources/counterparty.ts +++ b/app/verticals/party-registry/shared/resources/counterparty.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug counterparty import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const CounterpartyRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/organization-engagement-profile.ts b/app/verticals/party-registry/shared/resources/organization-engagement-profile.ts index 657f35ee8..590426f93 100644 --- a/app/verticals/party-registry/shared/resources/organization-engagement-profile.ts +++ b/app/verticals/party-registry/shared/resources/organization-engagement-profile.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug organization-engagement-profile import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const OrganizationEngagementProfileRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/party-alias.ts b/app/verticals/party-registry/shared/resources/party-alias.ts index 824c05f1c..00a4553d5 100644 --- a/app/verticals/party-registry/shared/resources/party-alias.ts +++ b/app/verticals/party-registry/shared/resources/party-alias.ts @@ -3,13 +3,11 @@ // @ontos-resource-slug party-alias import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; + import { IsoTimestampSchema } from '../domain/identity-contracts.ts'; import { PartyMergeRefSchema } from './party-merge.ts'; import { PartyRefSchema } from './party.ts'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PartyAliasRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), @@ -28,17 +26,17 @@ export const PartyAliasSchema = Schema.Struct({ }).check( Schema.makeFilter(({ aliasPartyRef, mergeRef, survivorPartyRef }) => { const issues: Schema.FilterIssue[] = []; - if ( - aliasPartyRef.tenantId !== survivorPartyRef.tenantId || - aliasPartyRef.tenantId !== mergeRef.tenantId - ) { + if (aliasPartyRef.tenantId !== survivorPartyRef.tenantId || aliasPartyRef.tenantId !== mergeRef.tenantId) { issues.push({ issue: 'Party Alias, survivor, and merge Resource must share one tenant', path: ['survivorPartyRef'], }); } if (aliasPartyRef.resourceId === survivorPartyRef.resourceId) { - issues.push({ issue: 'Party Alias cannot target itself', path: ['survivorPartyRef'] }); + issues.push({ + issue: 'Party Alias cannot target itself', + path: ['survivorPartyRef'], + }); } return issues; }), diff --git a/app/verticals/party-registry/shared/resources/party-contact-point.ts b/app/verticals/party-registry/shared/resources/party-contact-point.ts index 235be5228..3dc3303a4 100644 --- a/app/verticals/party-registry/shared/resources/party-contact-point.ts +++ b/app/verticals/party-registry/shared/resources/party-contact-point.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug party-contact-point import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PartyContactPointRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/party-merge.ts b/app/verticals/party-registry/shared/resources/party-merge.ts index 3e2db167a..393825b81 100644 --- a/app/verticals/party-registry/shared/resources/party-merge.ts +++ b/app/verticals/party-registry/shared/resources/party-merge.ts @@ -3,6 +3,7 @@ // @ontos-resource-slug party-merge import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; + import { IsoTimestampSchema } from '../domain/identity-contracts.ts'; import { ConfirmedDuplicateDecisionIdSchema, @@ -12,10 +13,7 @@ import { } from '../domain/merge-selection.ts'; import { PartyRefSchema } from './party.ts'; import type { PartyRef } from './party.ts'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PartyMergeRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), @@ -46,77 +44,54 @@ const selectionEvidenceIsInvalid = ( */ export const PartyMergeSchema = Schema.Struct({ absorbedPartyRefs: Schema.Array(PartyRefSchema).check(Schema.isMinLength(1)), - confirmedDuplicateDecisionId: Schema.toEncoded(ConfirmedDuplicateDecisionIdSchema).check( - Schema.isMaxLength(300), - ), + confirmedDuplicateDecisionId: Schema.toEncoded(ConfirmedDuplicateDecisionIdSchema).check(Schema.isMaxLength(300)), createdAt: IsoTimestampSchema, - decisionActorPrincipalId: Schema.toEncoded(DecisionActorPrincipalIdSchema).check( - Schema.isMaxLength(300), - ), + decisionActorPrincipalId: Schema.toEncoded(DecisionActorPrincipalIdSchema).check(Schema.isMaxLength(300)), mergeRef: PartyMergeRefSchema, policyVersion: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)), - selectionEvidenceChain: Schema.Array(MergeSelectionEvidenceStepSchema).check( - Schema.isMinLength(3), - ), + selectionEvidenceChain: Schema.Array(MergeSelectionEvidenceStepSchema).check(Schema.isMinLength(3)), selectionReason: MergeSurvivorSelectionReasonSchema, state: Schema.Literal('PREPARED'), survivorPartyRef: PartyRefSchema, }).check( - Schema.makeFilter( - ({ - absorbedPartyRefs, - mergeRef, - selectionEvidenceChain, - selectionReason, - survivorPartyRef, - }) => { - const identities = new Set(absorbedPartyRefs.map(({ resourceId }) => resourceId)); - const allRefs = [survivorPartyRef, ...absorbedPartyRefs]; - const issues: Schema.FilterIssue[] = []; - if (allRefs.some(({ tenantId }) => tenantId !== mergeRef.tenantId)) { - issues.push({ - issue: 'prepared merge Parties and merge Resource must share one tenant', - path: ['mergeRef'], - }); - } - if ( - identities.size !== absorbedPartyRefs.length || - identities.has(survivorPartyRef.resourceId) - ) { - issues.push({ - issue: 'survivor and absorbed Parties must be distinct', - path: ['absorbedPartyRefs'], - }); - } - if (selectionEvidenceIsInvalid(selectionEvidenceChain, selectionReason, survivorPartyRef)) { - issues.push({ - issue: - 'selection evidence must prove confirmation, safety, and the recorded survivor reason', - path: ['selectionEvidenceChain'], - }); - } - const mergeKeys = allRefs - .map(({ resourceId, tenantId }) => `${tenantId}:${resourceId}`) - .toSorted(); - if ( - selectionEvidenceChain.some(({ candidatePartyRefs }) => { - const evidenceKeys = candidatePartyRefs - .map(({ resourceId, tenantId }) => `${tenantId}:${resourceId}`) - .toSorted(); - return ( - evidenceKeys.length !== mergeKeys.length || - evidenceKeys.some((key, index) => key !== mergeKeys[index]) - ); - }) - ) { - issues.push({ - issue: 'selection evidence must describe exactly the prepared merge Party set', - path: ['selectionEvidenceChain'], - }); - } - return issues; - }, - ), + Schema.makeFilter(({ absorbedPartyRefs, mergeRef, selectionEvidenceChain, selectionReason, survivorPartyRef }) => { + const identities = new Set(absorbedPartyRefs.map(({ resourceId }) => resourceId)); + const allRefs = [survivorPartyRef, ...absorbedPartyRefs]; + const issues: Schema.FilterIssue[] = []; + if (allRefs.some(({ tenantId }) => tenantId !== mergeRef.tenantId)) { + issues.push({ + issue: 'prepared merge Parties and merge Resource must share one tenant', + path: ['mergeRef'], + }); + } + if (identities.size !== absorbedPartyRefs.length || identities.has(survivorPartyRef.resourceId)) { + issues.push({ + issue: 'survivor and absorbed Parties must be distinct', + path: ['absorbedPartyRefs'], + }); + } + if (selectionEvidenceIsInvalid(selectionEvidenceChain, selectionReason, survivorPartyRef)) { + issues.push({ + issue: 'selection evidence must prove confirmation, safety, and the recorded survivor reason', + path: ['selectionEvidenceChain'], + }); + } + const mergeKeys = allRefs.map(({ resourceId, tenantId }) => `${tenantId}:${resourceId}`).toSorted(); + if ( + selectionEvidenceChain.some(({ candidatePartyRefs }) => { + const evidenceKeys = candidatePartyRefs + .map(({ resourceId, tenantId }) => `${tenantId}:${resourceId}`) + .toSorted(); + return evidenceKeys.length !== mergeKeys.length || evidenceKeys.some((key, index) => key !== mergeKeys[index]); + }) + ) { + issues.push({ + issue: 'selection evidence must describe exactly the prepared merge Party set', + path: ['selectionEvidenceChain'], + }); + } + return issues; + }), ); export type PartyMerge = typeof PartyMergeSchema.Type; diff --git a/app/verticals/party-registry/shared/resources/party-relationship.ts b/app/verticals/party-registry/shared/resources/party-relationship.ts index 969b72250..26c3cd6bf 100644 --- a/app/verticals/party-registry/shared/resources/party-relationship.ts +++ b/app/verticals/party-registry/shared/resources/party-relationship.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug party-relationship import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PartyRelationshipRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/party.ts b/app/verticals/party-registry/shared/resources/party.ts index e7a6ec8e9..c673f6d2b 100644 --- a/app/verticals/party-registry/shared/resources/party.ts +++ b/app/verticals/party-registry/shared/resources/party.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug party import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PartyRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/person-engagement-profile.ts b/app/verticals/party-registry/shared/resources/person-engagement-profile.ts index 05e76e58f..f74c18174 100644 --- a/app/verticals/party-registry/shared/resources/person-engagement-profile.ts +++ b/app/verticals/party-registry/shared/resources/person-engagement-profile.ts @@ -3,10 +3,8 @@ // @ontos-resource-slug person-engagement-profile import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; + +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; export const PersonEngagementProfileRefSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts b/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts index 3baa8c8f4..ab0a2429f 100644 --- a/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts +++ b/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts @@ -1,12 +1,9 @@ import { Schema } from 'effect'; -const PartyRegistryResourceIdSchema = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(300), -).pipe(Schema.brand('PartyRegistryResourceId')); -const PartyRegistryTenantIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand('TenantId'), +const PartyRegistryResourceIdSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)).pipe( + Schema.brand('PartyRegistryResourceId'), ); +const PartyRegistryTenantIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TenantId')); /** JSON-compatible views keep published ResourceRef fields as strings. */ export const PartyRegistryResourceIdJsonSchema = Schema.toEncoded(PartyRegistryResourceIdSchema); diff --git a/app/verticals/party-registry/shared/resources/timeline-resource.ts b/app/verticals/party-registry/shared/resources/timeline-resource.ts index 6fdb3ec47..862f77581 100644 --- a/app/verticals/party-registry/shared/resources/timeline-resource.ts +++ b/app/verticals/party-registry/shared/resources/timeline-resource.ts @@ -1,14 +1,9 @@ import type { OntosResourceType } from '@app/core-runtime'; import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; -export const timelineResource = ( - slug: Slug, - label: Label, -) => { +import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema } from './resource-ref-identifiers.ts'; + +export const timelineResource = (slug: Slug, label: Label) => { const resourceType = `party.registry.${slug}` as const; const refSchema = Schema.Struct({ moduleId: Schema.Literal('party.registry'), diff --git a/app/verticals/party-registry/shared/ultramodern-build.ts b/app/verticals/party-registry/shared/ultramodern-build.ts index cbf1703e3..93be0641a 100644 --- a/app/verticals/party-registry/shared/ultramodern-build.ts +++ b/app/verticals/party-registry/shared/ultramodern-build.ts @@ -1,25 +1,11 @@ -import { withUltramodernBuildIdentity } from '@app/shared-contracts/ultramodern-build'; +import { resolveUltramodernBuildArtifact } from '@modern-js/runtime-extensions/build-identity'; declare const ULTRAMODERN_BUILD_MARKER: string; declare const ULTRAMODERN_SOURCE_REVISION: string; -const ultramodernGeneratedBuildArtifact = { - deliveryUnit: { - appId: 'party-registry', - build: '3f023644c8a07e9a', - buildMarker: '3f023644c8a07e9a', - deployProfile: 'cloudflare-ssr-mf-effect-v1', - kind: 'microvertical-delivery-unit', - packageName: '@app/party-registry', - schemaVersion: 1, - sourceRevision: 'workspace', - unitId: 'app/party-registry', - version: '0.1.0', - }, - kind: 'ultramodern-build-artifact', - schemaVersion: 1, - surfaces: { - api: { +const ultramodernBuildArtifact = resolveUltramodernBuildArtifact( + { + deliveryUnit: { appId: 'party-registry', build: '3f023644c8a07e9a', buildMarker: '3f023644c8a07e9a', @@ -28,47 +14,44 @@ const ultramodernGeneratedBuildArtifact = { packageName: '@app/party-registry', schemaVersion: 1, sourceRevision: 'workspace', - surface: 'api', unitId: 'app/party-registry', version: '0.1.0', }, - ui: { - appId: 'party-registry', - build: '3f023644c8a07e9a', - buildMarker: '3f023644c8a07e9a', - deployProfile: 'cloudflare-ssr-mf-effect-v1', - kind: 'microvertical-delivery-unit', - packageName: '@app/party-registry', - schemaVersion: 1, - sourceRevision: 'workspace', - surface: 'ui', - unitId: 'app/party-registry', - version: '0.1.0', + kind: 'ultramodern-build-artifact', + schemaVersion: 1, + surfaces: { + api: { + appId: 'party-registry', + build: '3f023644c8a07e9a', + buildMarker: '3f023644c8a07e9a', + deployProfile: 'cloudflare-ssr-mf-effect-v1', + kind: 'microvertical-delivery-unit', + packageName: '@app/party-registry', + schemaVersion: 1, + sourceRevision: 'workspace', + surface: 'api', + unitId: 'app/party-registry', + version: '0.1.0', + }, + ui: { + appId: 'party-registry', + build: '3f023644c8a07e9a', + buildMarker: '3f023644c8a07e9a', + deployProfile: 'cloudflare-ssr-mf-effect-v1', + kind: 'microvertical-delivery-unit', + packageName: '@app/party-registry', + schemaVersion: 1, + sourceRevision: 'workspace', + surface: 'ui', + unitId: 'app/party-registry', + version: '0.1.0', + }, }, + } as const, + { + buildMarker: () => ULTRAMODERN_BUILD_MARKER, + sourceRevision: () => ULTRAMODERN_SOURCE_REVISION, }, -} as const; -const readInjectedBuildMarker = (): string => { - try { - return ULTRAMODERN_BUILD_MARKER; - } catch { - return ultramodernGeneratedBuildArtifact.deliveryUnit.buildMarker; - } -}; - -const readInjectedSourceRevision = (): string => { - try { - return ULTRAMODERN_SOURCE_REVISION; - } catch { - return ultramodernGeneratedBuildArtifact.deliveryUnit.sourceRevision; - } -}; - -const ultramodernBuildMarker = readInjectedBuildMarker(); -const ultramodernSourceRevision = readInjectedSourceRevision(); -const ultramodernBuildArtifact = withUltramodernBuildIdentity( - ultramodernGeneratedBuildArtifact, - ultramodernBuildMarker, - ultramodernSourceRevision, ); export const ultramodernDeliveryUnit = ultramodernBuildArtifact.deliveryUnit; diff --git a/app/verticals/party-registry/src/actions/add-contact-point.action.ts b/app/verticals/party-registry/src/actions/add-contact-point.action.ts index 40d913a4d..1ba8deb35 100644 --- a/app/verticals/party-registry/src/actions/add-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/add-contact-point.action.ts @@ -4,6 +4,15 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Schema } from 'effect'; + +import { AddContactPointPayloadSchema, AddContactPointResultSchema } from '../../shared/actions/add-contact-point.ts'; +import type { AddContactPointPayload } from '../../shared/actions/add-contact-point.ts'; +import { + PartyContactPointAlreadyExists, + PartyContactPointInvalid, + PartyContactPointPartyNotFound, + PartyContactPointPersistenceUnavailable, +} from '../../shared/domain/contact-point-errors.ts'; import { PartyContactPointSchema, assertAddressPurposeRules, @@ -17,22 +26,10 @@ import type { ContactPointVerification, PartyContactPoint, } from '../../shared/domain/contact-point.ts'; -import { - PartyContactPointAlreadyExists, - PartyContactPointInvalid, - PartyContactPointPartyNotFound, - PartyContactPointPersistenceUnavailable, -} from '../../shared/domain/contact-point-errors.ts'; import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; import { addContactPointRecord } from '../services/party-contact-point-persistence.service.ts'; import { createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -import { - AddContactPointPayloadSchema, - AddContactPointResultSchema, -} from '../../shared/actions/add-contact-point.ts'; -import type { AddContactPointPayload } from '../../shared/actions/add-contact-point.ts'; - export { AddContactPointPayloadSchema } from '../../shared/actions/add-contact-point.ts'; export type { AddContactPointPayload } from '../../shared/actions/add-contact-point.ts'; @@ -94,52 +91,52 @@ const validateAndNormalize = (payload: AddContactPointPayload) => }, }); -const handleAddContactPoint = Effect.fn('AddContactPointAction.handleAddContactPoint')( - function* addContactPoint( - payload: AddContactPointPayload, - context: ActionHandlerContext< - Readonly<{ 'party.registry.contact-point-added.v1': typeof ContactPointAddedEventSchema }>, - Services - >, - ) { - const command = yield* validateAndNormalize(payload); - const contactPoint = yield* context.services.add({ - ...command, - acceptedByActionInvocationId: context.actionInvocationId, - acceptedByPrincipalId: context.scope.principalId, - validFrom: DateTime.formatIso(command.validFrom), - }); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `party-contact-point-add:${contactPoint.partyRef.resourceId}`, - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: contactPoint.partyRef.resourceId, - targetResourceType: contactPoint.partyRef.resourceType, - }); - const event = yield* context.addDomainEvent({ - eventType: 'party.registry.contact-point-added.v1', - payloadJson: { - contactPointRef: contactPoint.contactPointRef, - partyRef: contactPoint.partyRef, - revision: contactPoint.revision, - }, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: contactPoint.contactPointRef.resourceId, - subjectResourceType: contactPoint.contactPointRef.resourceType, - }); - yield* context.addOutboxMessage( - event, - createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage({ - contactPointRef: contactPoint.contactPointRef, - partyRef: contactPoint.partyRef, - }), - ); - return contactPoint; - }, -); +const handleAddContactPoint = Effect.fn('AddContactPointAction.handleAddContactPoint')(function* addContactPoint( + payload: AddContactPointPayload, + context: ActionHandlerContext< + Readonly<{ + 'party.registry.contact-point-added.v1': typeof ContactPointAddedEventSchema; + }>, + Services + >, +) { + const command = yield* validateAndNormalize(payload); + const contactPoint = yield* context.services.add({ + ...command, + acceptedByActionInvocationId: context.actionInvocationId, + acceptedByPrincipalId: context.scope.principalId, + validFrom: DateTime.formatIso(command.validFrom), + }); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `party-contact-point-add:${contactPoint.partyRef.resourceId}`, + resultCount: 1, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: contactPoint.partyRef.resourceId, + targetResourceType: contactPoint.partyRef.resourceType, + }); + const event = yield* context.addDomainEvent({ + eventType: 'party.registry.contact-point-added.v1', + payloadJson: { + contactPointRef: contactPoint.contactPointRef, + partyRef: contactPoint.partyRef, + revision: contactPoint.revision, + }, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: contactPoint.contactPointRef.resourceId, + subjectResourceType: contactPoint.contactPointRef.resourceType, + }); + yield* context.addOutboxMessage( + event, + createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage({ + contactPointRef: contactPoint.contactPointRef, + partyRef: contactPoint.partyRef, + }), + ); + return contactPoint; +}); export const addContactPointAction = defineAction( { @@ -150,10 +147,15 @@ export const addContactPointAction = defineAction( actionKey: 'party.registry.add-contact-point', auditProfile: 'sensitive', domainErrorSchema: AddContactPointErrorSchema, - domainEvents: { 'party.registry.contact-point-added.v1': ContactPointAddedEventSchema }, + domainEvents: { + 'party.registry.contact-point-added.v1': ContactPointAddedEventSchema, + }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.add-contact-point', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts index 7e760fbd4..f1870d7ef 100644 --- a/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts @@ -1,8 +1,5 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; export const createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts index ef7b150c9..724cae2d8 100644 --- a/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts @@ -2,27 +2,32 @@ // @ontos-action-owner party.registry // @ontos-action-slug add-party-official-identifier import { createHash } from 'node:crypto'; + import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Match, Schema } from 'effect'; + import { - partyIdFromString, - PartyNotFound, - PartyPersistenceUnavailable, - PartyTypeSchema, -} from '../../shared/domain/identity-contracts.ts'; + AddPartyOfficialIdentifierPayloadSchema, + AddPartyOfficialIdentifierResultSchema, +} from '../../shared/actions/add-party-official-identifier.ts'; import type { - PartyNotFoundError, - PartyPersistenceUnavailableError, -} from '../../shared/domain/identity-contracts.ts'; + AddPartyOfficialIdentifierPayload, + AddPartyOfficialIdentifierResult, +} from '../../shared/actions/add-party-official-identifier.ts'; import { OfficialIdentifierClaimConflict, OfficialIdentifierInvalid, normalizeOfficialIdentifier, qualifiesForExclusiveClaim, } from '../../shared/domain/identifier-contracts.ts'; -import { PartyOfficialIdentifierRefSchema } from '../../shared/resources/party-official-identifier.ts'; -import { PartyRefSchema } from '../../shared/resources/party.ts'; +import { + partyIdFromString, + PartyNotFound, + PartyPersistenceUnavailable, + PartyTypeSchema, +} from '../../shared/domain/identity-contracts.ts'; +import type { PartyNotFoundError, PartyPersistenceUnavailableError } from '../../shared/domain/identity-contracts.ts'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -31,6 +36,8 @@ import { PartyAliasWriteRejected, } from '../../shared/domain/merge-alias-resolution.ts'; import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; +import { PartyOfficialIdentifierRefSchema } from '../../shared/resources/party-official-identifier.ts'; +import { PartyRefSchema } from '../../shared/resources/party.ts'; import { lockAndResolveClaims } from '../services/party-identifier-claim.service.ts'; import { PARTY_EXACT_CLAIM_RULE_VERSION, @@ -39,15 +46,6 @@ import { } from '../services/party-official-identifier-persistence.service.ts'; import { createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; -import { - AddPartyOfficialIdentifierPayloadSchema, - AddPartyOfficialIdentifierResultSchema, -} from '../../shared/actions/add-party-official-identifier.ts'; -import type { - AddPartyOfficialIdentifierPayload, - AddPartyOfficialIdentifierResult, -} from '../../shared/actions/add-party-official-identifier.ts'; - export type { AddPartyOfficialIdentifierPayload } from '../../shared/actions/add-party-official-identifier.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, @@ -129,7 +127,10 @@ export const addPartyOfficialIdentifierAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.add-party-official-identifier', moduleKey: 'party.registry', role: 'action', @@ -184,9 +185,9 @@ export const addPartyOfficialIdentifierAction = defineAction( Match.exhaustive, ); const identifier = normalizeOfficialIdentifier(payload.identifier); - const partyType = yield* Schema.decodeUnknownEffect(PartyTypeSchema)( - party.currentType, - ).pipe(Effect.mapError(persistenceUnavailable)); + const partyType = yield* Schema.decodeUnknownEffect(PartyTypeSchema)(party.currentType).pipe( + Effect.mapError(persistenceUnavailable), + ); if (qualifiesForExclusiveClaim(identifier, partyType, PARTY_EXACT_CLAIM_RULE_VERSION)) { const [claim] = yield* lockAndResolveClaims(transaction, scope.tenantId, [identifier]); if (claim?.partyId !== undefined && claim.partyId !== payload.partyRef.resourceId) { diff --git a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts index c23323351..709b87857 100644 --- a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts @@ -3,6 +3,7 @@ // @ontos-action-slug archive-organization-engagement import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { OrganizationEngagementLifecyclePayloadSchema, OrganizationEngagementProfileSchema, @@ -23,9 +24,7 @@ export const archiveOrganizationEngagementAction = defineAction( payloadSchema: OrganizationEngagementLifecyclePayloadSchema, resultSchema: OrganizationEngagementProfileSchema, }, - handleEngagementLifecycle( - 'archived', - ), + handleEngagementLifecycle('archived'), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/actions/archive-party.action.ts b/app/verticals/party-registry/src/actions/archive-party.action.ts index 4ce3a6728..73b6d6c96 100644 --- a/app/verticals/party-registry/src/actions/archive-party.action.ts +++ b/app/verticals/party-registry/src/actions/archive-party.action.ts @@ -1,13 +1,14 @@ -// @generated by OntOS Codesmith Action v1 -// @ontos-action-owner party.registry -// @ontos-action-slug archive-party -import { - recordPartyInvariantAccess, - resolvePartyLifecycle, -} from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; + +import { ArchivePartyPayloadSchema, ArchivePartyResultSchema } from '../../shared/actions/archive-party.ts'; +import type { ArchivePartyPayload } from '../../shared/actions/archive-party.ts'; +import { + PartyLifecycleConflict, + PartyNotFound, + PartyPersistenceUnavailable, +} from '../../shared/domain/identity-contracts.ts'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -15,20 +16,13 @@ import { PartyAliasResolutionUnavailable, PartyAliasWriteRejected, } from '../../shared/domain/merge-alias-resolution.ts'; -import { - PartyLifecycleConflict, - PartyNotFound, - PartyPersistenceUnavailable, -} from '../../shared/domain/identity-contracts.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import { transitionPartyRecord } from '../services/party-identity-persistence.service.ts'; import { createArchivePartyPartyRegistryPartyArchivedV1OutboxMessage } from './archive-party.party-registry-party-archived-v1.outbox-message.ts'; - -import { - ArchivePartyPayloadSchema, - ArchivePartyResultSchema, -} from '../../shared/actions/archive-party.ts'; -import type { ArchivePartyPayload } from '../../shared/actions/archive-party.ts'; +// @generated by OntOS Codesmith Action v1 +// @ontos-action-owner party.registry +// @ontos-action-slug archive-party +import { recordPartyInvariantAccess, resolvePartyLifecycle } from './party-lifecycle-action-helpers.ts'; export type { ArchivePartyPayload } from '../../shared/actions/archive-party.ts'; const ErrorSchema = Schema.Union([ @@ -42,7 +36,9 @@ const ErrorSchema = Schema.Union([ PartyPersistenceUnavailable, ]); const domainEvents = { - 'party.registry.party-archived.v1': Schema.Struct({ partyRef: PartyRefSchema }), + 'party.registry.party-archived.v1': Schema.Struct({ + partyRef: PartyRefSchema, + }), } as const; interface Services { readonly transition: (payload: ArchivePartyPayload) => ReturnType; @@ -86,7 +82,10 @@ export const archivePartyAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.archive-party', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts index 4965cc77f..9bc09cfc4 100644 --- a/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts @@ -1,16 +1,11 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-party-archived-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-party-archived-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-archived-v1'; const ArchivePartyPartyRegistryPartyArchivedV1OutboxProducerModuleKey = outboxProducerModuleKey; const ArchivePartyPartyRegistryPartyArchivedV1OutboxTopic = outboxTopic; -export const createArchivePartyPartyRegistryPartyArchivedV1OutboxMessage = ( - payload: OutboxPayload, -): OutboxMessage => ({ +export const createArchivePartyPartyRegistryPartyArchivedV1OutboxMessage = (payload: OutboxPayload): OutboxMessage => ({ payloadJson: payload, producerModuleKey: ArchivePartyPartyRegistryPartyArchivedV1OutboxProducerModuleKey, topic: ArchivePartyPartyRegistryPartyArchivedV1OutboxTopic, diff --git a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts index 9df1d4435..c0a73ec61 100644 --- a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts @@ -3,6 +3,7 @@ // @ontos-action-slug archive-person-engagement import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PersonEngagementLifecyclePayloadSchema, PersonEngagementProfileSchema, @@ -17,9 +18,7 @@ import { engagementLifecycleRegistration } from './engagement-lifecycle-registra export const archivePersonEngagementAction = defineAction( { - ...engagementLifecycleRegistration( - 'party.registry.archive-person-engagement', - ), + ...engagementLifecycleRegistration('party.registry.archive-person-engagement'), payloadSchema: PersonEngagementLifecyclePayloadSchema, resultSchema: PersonEngagementProfileSchema, }, @@ -34,8 +33,7 @@ export const archivePersonEngagementAction = defineAction( ); } return Effect.succeed({ - transition: (profileId) => - transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'archived'), + transition: (profileId) => transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'archived'), }); }, ); diff --git a/app/verticals/party-registry/src/actions/attach-engagement-handler.ts b/app/verticals/party-registry/src/actions/attach-engagement-handler.ts index 60f6a768e..02168325f 100644 --- a/app/verticals/party-registry/src/actions/attach-engagement-handler.ts +++ b/app/verticals/party-registry/src/actions/attach-engagement-handler.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from 'effect'; + import { EngagementProfileConflict, EngagementProfilePersistenceUnavailable, @@ -20,12 +21,10 @@ interface EngagementServices { ) => Effect.Effect; } -export const handleAttachEngagement = Effect.fn('AttachEngagementAction.handle')( - function* handleAttachEngagement( - payload: Payload, - context: { readonly services: EngagementServices }, - ) { - yield* context.services.validate(payload); - return yield* context.services.create(payload); - }, -); +export const handleAttachEngagement = Effect.fn('AttachEngagementAction.handle')(function* handleAttachEngagement< + Payload, + Result, +>(payload: Payload, context: { readonly services: EngagementServices }) { + yield* context.services.validate(payload); + return yield* context.services.create(payload); +}); diff --git a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts index de4f8d26d..effd11523 100644 --- a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts @@ -1,12 +1,9 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug attach-organization-engagement -import { - defineAction, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, defineTenantModuleEntrypoint, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { AttachOrganizationEngagementPayloadSchema, OrganizationEngagementProfileSchema, @@ -20,7 +17,6 @@ import { partyRegistryReferenceOperations, validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; - import { AttachEngagementError, handleAttachEngagement } from './attach-engagement-handler.ts'; export const attachOrganizationEngagementAction = defineAction( @@ -35,7 +31,10 @@ export const attachOrganizationEngagementAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.attach-organization-engagement', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts index c74d1234f..ebad73065 100644 --- a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts @@ -1,12 +1,9 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug attach-person-engagement -import { - defineAction, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, defineTenantModuleEntrypoint, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { AttachPersonEngagementPayloadSchema, PersonEngagementProfileSchema, @@ -20,7 +17,6 @@ import { partyRegistryReferenceOperations, validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; - import { AttachEngagementError, handleAttachEngagement } from './attach-engagement-handler.ts'; export const attachPersonEngagementAction = defineAction( @@ -35,7 +31,10 @@ export const attachPersonEngagementAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.attach-person-engagement', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts b/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts index 896612fc4..bcfc3d97b 100644 --- a/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts +++ b/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts @@ -1,5 +1,6 @@ import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import type { AddPartyOfficialIdentifierResult, AddPartyOfficialIdentifierResultSchema, @@ -30,9 +31,7 @@ export const publishAttachedOfficialIdentifiers = ( }); yield* context.addOutboxMessage( event, - createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage( - payload, - ), + createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage(payload), ); }), { concurrency: 1, discard: true }, diff --git a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts index ee11eddaf..49f06728b 100644 --- a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts +++ b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts @@ -2,16 +2,16 @@ // @ontos-action-owner party.registry // @ontos-action-slug confirm-duplicate-parties import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; + import { ConfirmDuplicatePartiesPayloadSchema, ConfirmDuplicatePartiesResultSchema, } from '../../shared/actions/confirm-duplicate-parties.ts'; - +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; import { DuplicateCaseResolutionErrorSchema, duplicateCaseResolutionService, } from './duplicate-case-resolution-service.ts'; -import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export const confirmDuplicatePartiesAction = defineAction( { @@ -25,7 +25,10 @@ export const confirmDuplicatePartiesAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.confirm-duplicate-parties', moduleKey: 'party.registry', role: 'action', @@ -40,8 +43,7 @@ export const confirmDuplicatePartiesAction = defineAction( tenantPermission: () => 'review_party_identity', }, handleDuplicateCaseResolution, - (transaction, scope) => - duplicateCaseResolutionService(transaction, scope.tenantId, 'CONFIRMED_DUPLICATE_PARTIES'), + (transaction, scope) => duplicateCaseResolutionService(transaction, scope.tenantId, 'CONFIRMED_DUPLICATE_PARTIES'), ); // Production merge remains deliberately absent: this Action records reviewed readiness only. // diff --git a/app/verticals/party-registry/src/actions/correct-party-fact.action.ts b/app/verticals/party-registry/src/actions/correct-party-fact.action.ts index 343ec5acf..339136641 100644 --- a/app/verticals/party-registry/src/actions/correct-party-fact.action.ts +++ b/app/verticals/party-registry/src/actions/correct-party-fact.action.ts @@ -4,6 +4,11 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Option, Schema } from 'effect'; + +import { + CorrectPartyFactPayloadSchema, + CorrectPartyFactResultSchema, +} from '../../shared/actions/correct-party-fact.ts'; import { PartyCorrectionConflict, PartyCorrectionResultJsonSchema, @@ -15,16 +20,7 @@ import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolut import { correctPartyFactRecord } from '../services/party-correction.service.ts'; import { createCorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxMessage } from './correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts'; -import { - CorrectPartyFactPayloadSchema, - CorrectPartyFactResultSchema, -} from '../../shared/actions/correct-party-fact.ts'; - -const ErrorSchema = Schema.Union([ - PartyCorrectionConflict, - PartyPersistenceUnavailable, - PartyAliasWriteRejected, -]); +const ErrorSchema = Schema.Union([PartyCorrectionConflict, PartyPersistenceUnavailable, PartyAliasWriteRejected]); const domainEvents = { 'party.registry.party-fact-corrected.v1': PartyCorrectionResultJsonSchema, } as const; @@ -49,9 +45,7 @@ const handle = Effect.fn('CorrectPartyFactAction.handle')(function* handleCorrec targetResourceId: relationshipRef?.resourceId ?? result.partyRef.resourceId, targetResourceType: relationshipRef?.resourceType ?? result.partyRef.resourceType, }); - const payloadJson = yield* Schema.encodeEffect(PartyCorrectionResultSchema)(result).pipe( - Effect.orDie, - ); + const payloadJson = yield* Schema.encodeEffect(PartyCorrectionResultSchema)(result).pipe(Effect.orDie); const event = yield* context.addDomainEvent({ eventType: 'party.registry.party-fact-corrected.v1', payloadJson, @@ -81,7 +75,10 @@ export const correctPartyFactAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.correct-party-fact', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts index 667204a05..108b324d4 100644 --- a/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts @@ -5,8 +5,7 @@ import { } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; -const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxTopic = outboxTopic; export const createCorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/counterparty-create.action.ts b/app/verticals/party-registry/src/actions/counterparty-create.action.ts index bebb5c06c..7e85a6040 100644 --- a/app/verticals/party-registry/src/actions/counterparty-create.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-create.action.ts @@ -2,12 +2,14 @@ // @ontos-action-owner party.registry // @ontos-action-slug counterparty-create import type { ActionHandlerContext } from '@app/core-runtime'; -import { - defineAction, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, defineTenantModuleEntrypoint, OperationContextUnavailable } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + +import { + CounterpartyCreatePayloadSchema, + CounterpartyCreateResultSchema, +} from '../../shared/actions/counterparty-create.ts'; +import type { CounterpartyCreatePayload } from '../../shared/actions/counterparty-create.ts'; import { CounterpartyAuditEvidenceSchema } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyEvidenceInsufficient, @@ -23,12 +25,6 @@ import { createCounterpartyRecord } from '../services/counterparty-persistence.s import type { CreateCounterpartyResult as PersistenceResult } from '../services/counterparty-persistence.service.ts'; import { createCounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxMessage } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -import { - CounterpartyCreatePayloadSchema, - CounterpartyCreateResultSchema, -} from '../../shared/actions/counterparty-create.ts'; -import type { CounterpartyCreatePayload } from '../../shared/actions/counterparty-create.ts'; - export { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema, @@ -164,7 +160,10 @@ export const counterpartyCreateAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.counterparty-create', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts index a8718ddaf..8119d2e0d 100644 --- a/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts @@ -5,8 +5,7 @@ import { } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; -const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxTopic = outboxTopic; export const createCounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts b/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts index 296144311..746d97d3c 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts @@ -1,10 +1,9 @@ import { Effect } from 'effect'; + import type { CounterpartyRoleAddPayload } from '../../shared/actions/counterparty-role-add.ts'; import { CounterpartyNotFound } from '../../shared/domain/counterparty-errors.ts'; -export const counterpartyRoleWritePermission = ( - payload: Pick, -) => ({ +export const counterpartyRoleWritePermission = (payload: Pick) => ({ permission: 'write' as const, resource: { moduleId: payload.counterpartyRef.moduleId, @@ -13,9 +12,7 @@ export const counterpartyRoleWritePermission = ( }, }); -export const failCounterpartyNotFound = ({ - counterpartyId, -}: Pick) => +export const failCounterpartyNotFound = ({ counterpartyId }: Pick) => Effect.fail( new CounterpartyNotFound({ code: 'counterparty_not_found', diff --git a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts index 8fe9a64bd..e2564dd85 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts @@ -9,10 +9,12 @@ import { OperationContextUnavailable, } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + import { - counterpartyRoleWritePermission, - failCounterpartyNotFound, -} from './counterparty-role-action-support.ts'; + CounterpartyRoleAddPayloadSchema, + CounterpartyRoleAddResultSchema, +} from '../../shared/actions/counterparty-role-add.ts'; +import type { CounterpartyRoleAddPayload } from '../../shared/actions/counterparty-role-add.ts'; import { CounterpartyAuditEvidenceSchema } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyEvidenceInsufficient, @@ -27,14 +29,9 @@ import { roleEvidenceIsSufficient } from '../../shared/domain/counterparty-role- import { OutboxPayloadSchema as CounterpartyRoleAddedEventSchema } from '../../shared/outbox/party-registry-counterparty-role-added-v1.ts'; import { addCounterpartyRoleRecord } from '../services/counterparty-persistence.service.ts'; import type { AddCounterpartyRoleResult as PersistenceResult } from '../services/counterparty-persistence.service.ts'; +import { counterpartyRoleWritePermission, failCounterpartyNotFound } from './counterparty-role-action-support.ts'; import { createCounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxMessage } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -import { - CounterpartyRoleAddPayloadSchema, - CounterpartyRoleAddResultSchema, -} from '../../shared/actions/counterparty-role-add.ts'; -import type { CounterpartyRoleAddPayload } from '../../shared/actions/counterparty-role-add.ts'; - export { CounterpartyRoleAddPayloadSchema, CounterpartyRoleAddResultSchema, @@ -157,7 +154,10 @@ export const counterpartyRoleAddAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.counterparty-role-add', moduleKey: 'party.registry', role: 'action', @@ -167,9 +167,7 @@ export const counterpartyRoleAddAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: CounterpartyRoleAddPayloadSchema, policies: [], - resourcePermission: defineActionResourcePermission( - counterpartyRoleWritePermission, - ), + resourcePermission: defineActionResourcePermission(counterpartyRoleWritePermission), resultSchema: CounterpartyRoleAddResultSchema, schemaVersion: '1', }, diff --git a/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts index 32f6c03bb..7a8ea23c4 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts @@ -5,8 +5,7 @@ import { } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; -const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxTopic = outboxTopic; export const createCounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts index 22e7e48ea..a042bc475 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts @@ -9,10 +9,12 @@ import { OperationContextUnavailable, } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + import { - counterpartyRoleWritePermission, - failCounterpartyNotFound, -} from './counterparty-role-action-support.ts'; + CounterpartyRoleEndPayloadSchema, + CounterpartyRoleEndResultSchema, +} from '../../shared/actions/counterparty-role-end.ts'; +import type { CounterpartyRoleEndPayload } from '../../shared/actions/counterparty-role-end.ts'; import { CounterpartyAuditEvidenceSchema } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyNotFound, @@ -26,14 +28,9 @@ import { import { OutboxPayloadSchema as CounterpartyRoleEndedEventSchema } from '../../shared/outbox/party-registry-counterparty-role-ended-v1.ts'; import { endCounterpartyRoleRecord } from '../services/counterparty-persistence.service.ts'; import type { EndCounterpartyRoleResult as PersistenceResult } from '../services/counterparty-persistence.service.ts'; +import { counterpartyRoleWritePermission, failCounterpartyNotFound } from './counterparty-role-action-support.ts'; import { createCounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxMessage } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -import { - CounterpartyRoleEndPayloadSchema, - CounterpartyRoleEndResultSchema, -} from '../../shared/actions/counterparty-role-end.ts'; -import type { CounterpartyRoleEndPayload } from '../../shared/actions/counterparty-role-end.ts'; - export { CounterpartyRoleEndPayloadSchema, CounterpartyRoleEndResultSchema, @@ -177,7 +174,10 @@ export const counterpartyRoleEndAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.counterparty-role-end', moduleKey: 'party.registry', role: 'action', @@ -187,9 +187,7 @@ export const counterpartyRoleEndAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: CounterpartyRoleEndPayloadSchema, policies: [], - resourcePermission: defineActionResourcePermission( - counterpartyRoleWritePermission, - ), + resourcePermission: defineActionResourcePermission(counterpartyRoleWritePermission), resultSchema: CounterpartyRoleEndResultSchema, schemaVersion: '1', }, diff --git a/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts index 68976958d..e272ed864 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts @@ -5,8 +5,7 @@ import { } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; -const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxTopic = outboxTopic; export const createCounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts index 865d3e90f..bab6ee86c 100644 --- a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts @@ -1,9 +1,10 @@ +import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { ActionHandlerContext } from '@app/core-runtime'; // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug create-party-relationship import { Effect } from 'effect'; -import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; + import { CreatePartyRelationshipPayloadSchema, CreatePartyRelationshipResultSchema, @@ -17,7 +18,6 @@ import type { RelationshipMutationError, } from '../services/party-relationship-persistence.service.ts'; import { createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; - import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; interface Services { @@ -28,48 +28,44 @@ interface Services { ) => Effect.Effect; } -const handleCreatePartyRelationship = Effect.fn( - 'CreatePartyRelationshipAction.handleCreatePartyRelationship', -)(function* handleCreateRelationship( - payload: Payload, - context: ActionHandlerContext< - Readonly<{ - 'party.registry.relationship-created.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; - }>, - Services - >, -) { - const result = yield* context.services.create( - payload, - context.scope.principalId, - context.actionInvocationId, - ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `party-relationship-endpoints:${payload.fromPartyRef.resourceId}:${payload.toPartyRef.resourceId}`, - resultCount: 2, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.relationship.relationshipRef.resourceId, - targetResourceType: result.relationship.relationshipRef.resourceType, - }); - if (result.outcome === 'CREATED') { - const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); - const domainEvent = yield* context.addDomainEvent({ - eventType: 'party.registry.relationship-created.v1', - payloadJson, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: result.relationship.relationshipRef.resourceId, - subjectResourceType: result.relationship.relationshipRef.resourceType, +const handleCreatePartyRelationship = Effect.fn('CreatePartyRelationshipAction.handleCreatePartyRelationship')( + function* handleCreateRelationship( + payload: Payload, + context: ActionHandlerContext< + Readonly<{ + 'party.registry.relationship-created.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; + }>, + Services + >, + ) { + const result = yield* context.services.create(payload, context.scope.principalId, context.actionInvocationId); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `party-relationship-endpoints:${payload.fromPartyRef.resourceId}:${payload.toPartyRef.resourceId}`, + resultCount: 2, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: result.relationship.relationshipRef.resourceId, + targetResourceType: result.relationship.relationshipRef.resourceType, }); - yield* context.addOutboxMessage( - domainEvent, - createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage(payloadJson), - ); - } - return result; -}); + if (result.outcome === 'CREATED') { + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); + const domainEvent = yield* context.addDomainEvent({ + eventType: 'party.registry.relationship-created.v1', + payloadJson, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: result.relationship.relationshipRef.resourceId, + subjectResourceType: result.relationship.relationshipRef.resourceType, + }); + yield* context.addOutboxMessage( + domainEvent, + createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage(payloadJson), + ); + } + return result; + }, +); export const createPartyRelationshipAction = defineAction( { @@ -85,7 +81,10 @@ export const createPartyRelationshipAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.create-party-relationship', moduleKey: 'party.registry', role: 'action', @@ -103,12 +102,6 @@ export const createPartyRelationshipAction = defineAction( (transaction, scope) => Effect.succeed({ create: (payload, principalId, actionInvocationId) => - createPartyRelationshipRecord( - transaction, - scope.tenantId, - principalId, - actionInvocationId, - payload, - ), + createPartyRelationshipRecord(transaction, scope.tenantId, principalId, actionInvocationId, payload), }), ); diff --git a/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts index 940bb48b7..401cbca20 100644 --- a/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts @@ -5,15 +5,13 @@ import { } from '@app/party-registry/outbox/party-registry-relationship-created-v1'; import type { OutboxPayloadJson } from '@app/party-registry/outbox/party-registry-relationship-created-v1'; -const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxTopic = outboxTopic; export const createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage = ( payload: OutboxPayloadJson, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey, + producerModuleKey: CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey, topic: CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/create-party.action.ts b/app/verticals/party-registry/src/actions/create-party.action.ts index 935709c9e..411e33a30 100644 --- a/app/verticals/party-registry/src/actions/create-party.action.ts +++ b/app/verticals/party-registry/src/actions/create-party.action.ts @@ -4,31 +4,19 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; -import { - PartyEvidenceInsufficient, - PartyPersistenceUnavailable, -} from '../../shared/domain/identity-contracts.ts'; -import type { PartyCandidate } from '../../shared/domain/identity-contracts.ts'; + import { AddPartyOfficialIdentifierResultSchema } from '../../shared/actions/add-party-official-identifier.ts'; +import { CreatePartyPayloadSchema, CreatePartyResultSchema } from '../../shared/actions/create-party.ts'; +import type { CreatePartyPayload } from '../../shared/actions/create-party.ts'; +import { PartyEvidenceInsufficient, PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; +import type { PartyCandidate } from '../../shared/domain/identity-contracts.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; -import { - candidateFingerprint, - createOrMatchParty, -} from '../services/party-matching-persistence.service.ts'; -import { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; +import { candidateFingerprint, createOrMatchParty } from '../services/party-matching-persistence.service.ts'; import { publishAttachedOfficialIdentifiers } from './attached-official-identifier-events.ts'; - -import { - CreatePartyPayloadSchema, - CreatePartyResultSchema, -} from '../../shared/actions/create-party.ts'; -import type { CreatePartyPayload } from '../../shared/actions/create-party.ts'; +import { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; export type { CreatePartyPayload } from '../../shared/actions/create-party.ts'; -const CreatePartyErrorSchema = Schema.Union([ - PartyEvidenceInsufficient, - PartyPersistenceUnavailable, -]); +const CreatePartyErrorSchema = Schema.Union([PartyEvidenceInsufficient, PartyPersistenceUnavailable]); const PartyCreatedEventSchema = Schema.Struct({ partyRef: PartyRefSchema }); const domainEvents = { 'party.registry.official-identifier-added.v1': AddPartyOfficialIdentifierResultSchema, @@ -71,15 +59,13 @@ const handleCreateParty = Effect.fn('CreatePartyAction.handleCreateParty')(funct }); yield* context.addOutboxMessage( event, - createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage({ partyRef: result.partyRef }), + createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage({ + partyRef: result.partyRef, + }), ); } if (result.outcome === 'MATCHED_EXISTING') { - yield* publishAttachedOfficialIdentifiers( - context, - result.partyRef, - addedOfficialIdentifierRefs, - ); + yield* publishAttachedOfficialIdentifiers(context, result.partyRef, addedOfficialIdentifierRefs); } return result; }); @@ -96,7 +82,10 @@ export const createPartyAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.create-party', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts index 4aaab4167..601f6e5ac 100644 --- a/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts @@ -1,16 +1,11 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-party-created-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-party-created-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-created-v1'; const CreatePartyPartyRegistryPartyCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; const CreatePartyPartyRegistryPartyCreatedV1OutboxTopic = outboxTopic; -export const createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage = ( - payload: OutboxPayload, -): OutboxMessage => ({ +export const createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage = (payload: OutboxPayload): OutboxMessage => ({ payloadJson: payload, producerModuleKey: CreatePartyPartyRegistryPartyCreatedV1OutboxProducerModuleKey, topic: CreatePartyPartyRegistryPartyCreatedV1OutboxTopic, diff --git a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts index d7ebe5041..7c2256b48 100644 --- a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts +++ b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts @@ -2,16 +2,16 @@ // @ontos-action-owner party.registry // @ontos-action-slug dismiss-duplicate-candidate import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; + import { DismissDuplicateCandidatePayloadSchema, DismissDuplicateCandidateResultSchema, } from '../../shared/actions/dismiss-duplicate-candidate.ts'; - +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; import { DuplicateCaseResolutionErrorSchema, duplicateCaseResolutionService, } from './duplicate-case-resolution-service.ts'; -import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export const dismissDuplicateCandidateAction = defineAction( { @@ -25,7 +25,10 @@ export const dismissDuplicateCandidateAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.dismiss-duplicate-candidate', moduleKey: 'party.registry', role: 'action', @@ -40,8 +43,7 @@ export const dismissDuplicateCandidateAction = defineAction( tenantPermission: () => 'review_party_identity', }, handleDuplicateCaseResolution, - (transaction, scope) => - duplicateCaseResolutionService(transaction, scope.tenantId, 'DISMISSED_AS_NON_SUBJECT'), + (transaction, scope) => duplicateCaseResolutionService(transaction, scope.tenantId, 'DISMISSED_AS_NON_SUBJECT'), ); // // diff --git a/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts b/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts index 2f05ef3be..25dacd389 100644 --- a/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts +++ b/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; + import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; import type { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; @@ -18,9 +20,7 @@ export const handleDuplicateCaseResolution = ( Effect.tap((result) => context.recordDataAccess({ accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), + queryHash: createHash('sha256').update(`duplicate-case-invariants:${payload.caseRef.resourceId}`).digest('hex'), resultCount: 1, servingModuleKey: 'party.registry', targetModuleKey: 'party.registry', diff --git a/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts b/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts index 2085c0967..b0e5b4e16 100644 --- a/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts +++ b/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts @@ -1,7 +1,8 @@ import { Effect, Schema } from 'effect'; + +import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; -import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; import { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; export const DuplicateCaseResolutionErrorSchema = Schema.Union([ diff --git a/app/verticals/party-registry/src/actions/end-contact-point.action.ts b/app/verticals/party-registry/src/actions/end-contact-point.action.ts index eac3b6f19..c0cf90b6b 100644 --- a/app/verticals/party-registry/src/actions/end-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/end-contact-point.action.ts @@ -4,15 +4,9 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Schema } from 'effect'; -import { - ContactPointTimestampSchema, - PartyContactPointSchema, -} from '../../shared/domain/contact-point.ts'; -import type { - AddressPurposeTarget, - ContactPointProvenance, - PartyContactPoint, -} from '../../shared/domain/contact-point.ts'; + +import { EndContactPointPayloadSchema, EndContactPointResultSchema } from '../../shared/actions/end-contact-point.ts'; +import type { EndContactPointPayload } from '../../shared/actions/end-contact-point.ts'; import { PartyContactPointCorrectionRequired, PartyContactPointInvalid, @@ -20,15 +14,15 @@ import { PartyContactPointNotFound, PartyContactPointPersistenceUnavailable, } from '../../shared/domain/contact-point-errors.ts'; +import { ContactPointTimestampSchema, PartyContactPointSchema } from '../../shared/domain/contact-point.ts'; +import type { + AddressPurposeTarget, + ContactPointProvenance, + PartyContactPoint, +} from '../../shared/domain/contact-point.ts'; import { endContactPointRecord } from '../services/party-contact-point-persistence.service.ts'; import { createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -import { - EndContactPointPayloadSchema, - EndContactPointResultSchema, -} from '../../shared/actions/end-contact-point.ts'; -import type { EndContactPointPayload } from '../../shared/actions/end-contact-point.ts'; - export { EndContactPointPayloadSchema } from '../../shared/actions/end-contact-point.ts'; export type { EndContactPointPayload } from '../../shared/actions/end-contact-point.ts'; @@ -56,7 +50,10 @@ export interface EndContactPointCommand { readonly reason: string; readonly target: | Readonly<{ readonly type: 'WHOLE_CONTACT_POINT' }> - | Readonly<{ readonly target: AddressPurposeTarget; readonly type: 'ADDRESS_PURPOSE' }>; + | Readonly<{ + readonly target: AddressPurposeTarget; + readonly type: 'ADDRESS_PURPOSE'; + }>; } type EndError = @@ -72,47 +69,47 @@ interface Services { ) => Effect.Effect, EndError>; } -const handleEndContactPoint = Effect.fn('EndContactPointAction.handleEndContactPoint')( - function* endContactPoint( - payload: EndContactPointPayload, - context: ActionHandlerContext< - Readonly<{ 'party.registry.contact-point-ended.v1': typeof ContactPointEndedEventSchema }>, - Services - >, - ) { - const result = yield* context.services.end({ - ...payload, - acceptedByActionInvocationId: context.actionInvocationId, - acceptedByPrincipalId: context.scope.principalId, - effectiveEnd: DateTime.formatIso(payload.effectiveEnd), - }); - if (!result.changed) { - return result.contactPoint; - } - const { contactPoint } = result; - const event = yield* context.addDomainEvent({ - eventType: 'party.registry.contact-point-ended.v1', - payloadJson: { - contactPointRef: contactPoint.contactPointRef, - effectiveEnd: payload.effectiveEnd, - partyRef: contactPoint.partyRef, - revision: contactPoint.revision, - }, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: contactPoint.contactPointRef.resourceId, - subjectResourceType: contactPoint.contactPointRef.resourceType, - }); - yield* context.addOutboxMessage( - event, - createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage({ - contactPointRef: contactPoint.contactPointRef, - partyRef: contactPoint.partyRef, - }), - ); - return contactPoint; - }, -); +const handleEndContactPoint = Effect.fn('EndContactPointAction.handleEndContactPoint')(function* endContactPoint( + payload: EndContactPointPayload, + context: ActionHandlerContext< + Readonly<{ + 'party.registry.contact-point-ended.v1': typeof ContactPointEndedEventSchema; + }>, + Services + >, +) { + const result = yield* context.services.end({ + ...payload, + acceptedByActionInvocationId: context.actionInvocationId, + acceptedByPrincipalId: context.scope.principalId, + effectiveEnd: DateTime.formatIso(payload.effectiveEnd), + }); + if (!result.changed) { + return result.contactPoint; + } + const { contactPoint } = result; + const event = yield* context.addDomainEvent({ + eventType: 'party.registry.contact-point-ended.v1', + payloadJson: { + contactPointRef: contactPoint.contactPointRef, + effectiveEnd: payload.effectiveEnd, + partyRef: contactPoint.partyRef, + revision: contactPoint.revision, + }, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: contactPoint.contactPointRef.resourceId, + subjectResourceType: contactPoint.contactPointRef.resourceType, + }); + yield* context.addOutboxMessage( + event, + createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage({ + contactPointRef: contactPoint.contactPointRef, + partyRef: contactPoint.partyRef, + }), + ); + return contactPoint; +}); export const endContactPointAction = defineAction( { @@ -123,10 +120,15 @@ export const endContactPointAction = defineAction( actionKey: 'party.registry.end-contact-point', auditProfile: 'sensitive', domainErrorSchema: EndContactPointErrorSchema, - domainEvents: { 'party.registry.contact-point-ended.v1': ContactPointEndedEventSchema }, + domainEvents: { + 'party.registry.contact-point-ended.v1': ContactPointEndedEventSchema, + }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.end-contact-point', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts index 7941d45f8..f6c8778a0 100644 --- a/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts @@ -1,8 +1,5 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; export const createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts index 38f081d31..0d55ff9fa 100644 --- a/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts @@ -2,19 +2,26 @@ // @ontos-action-owner party.registry // @ontos-action-slug end-party-official-identifier import { createHash } from 'node:crypto'; + import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Match, Schema } from 'effect'; + +import { + EndPartyOfficialIdentifierPayloadSchema, + EndPartyOfficialIdentifierResultSchema, +} from '../../shared/actions/end-party-official-identifier.ts'; +import type { + EndPartyOfficialIdentifierPayload, + EndPartyOfficialIdentifierResult, +} from '../../shared/actions/end-party-official-identifier.ts'; +import { OfficialIdentifierClaimConflict } from '../../shared/domain/identifier-contracts.ts'; import { partyIdFromString, PartyNotFound, PartyPersistenceUnavailable, } from '../../shared/domain/identity-contracts.ts'; -import type { - PartyNotFoundError, - PartyPersistenceUnavailableError, -} from '../../shared/domain/identity-contracts.ts'; -import { OfficialIdentifierClaimConflict } from '../../shared/domain/identifier-contracts.ts'; +import type { PartyNotFoundError, PartyPersistenceUnavailableError } from '../../shared/domain/identity-contracts.ts'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -28,15 +35,6 @@ import { PartyRefSchema } from '../../shared/resources/party.ts'; import { endOfficialIdentifierRecord } from '../services/party-official-identifier-persistence.service.ts'; import { createEndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxMessage } from './end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts'; -import { - EndPartyOfficialIdentifierPayloadSchema, - EndPartyOfficialIdentifierResultSchema, -} from '../../shared/actions/end-party-official-identifier.ts'; -import type { - EndPartyOfficialIdentifierPayload, - EndPartyOfficialIdentifierResult, -} from '../../shared/actions/end-party-official-identifier.ts'; - export type { EndPartyOfficialIdentifierPayload } from '../../shared/actions/end-party-official-identifier.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, @@ -96,10 +94,7 @@ const handle = Effect.fn('EndPartyOfficialIdentifierAction.handle')(function* en ); return result; }); -const makeEndService = ( - transaction: Parameters[0], - tenantId: string, -) => +const makeEndService = (transaction: Parameters[0], tenantId: string) => Effect.fn('endPartyOfficialIdentifierAction.end')(function* endIdentifier( payload: EndPartyOfficialIdentifierPayload, ) { @@ -158,7 +153,10 @@ export const endPartyOfficialIdentifierAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.end-party-official-identifier', moduleKey: 'party.registry', role: 'action', @@ -174,5 +172,7 @@ export const endPartyOfficialIdentifierAction = defineAction( }, handle, (transaction, scope) => - Effect.succeed({ end: makeEndService(transaction, scope.tenantId) } satisfies Services), + Effect.succeed({ + end: makeEndService(transaction, scope.tenantId), + } satisfies Services), ); diff --git a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts index ba53158ea..04656bc04 100644 --- a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts @@ -1,9 +1,10 @@ +import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { ActionHandlerContext } from '@app/core-runtime'; // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug end-party-relationship import { Effect, Option, Schema } from 'effect'; -import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; + import { ChangePartyRelationshipResultSchema, EndPartyRelationshipPayloadSchema, @@ -19,7 +20,6 @@ import type { RelationshipMutationError, } from '../services/party-relationship-persistence.service.ts'; import { createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; - import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; interface Services { @@ -30,56 +30,52 @@ interface Services { ) => Effect.Effect; } -const handleEndPartyRelationship = Effect.fn( - 'EndPartyRelationshipAction.handleEndPartyRelationship', -)(function* handleEndRelationship( - payload: Payload, - context: ActionHandlerContext< - Readonly<{ - 'party.registry.relationship-ended.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; - }>, - Services - >, -) { - const result = yield* context.services.end( - payload, - context.scope.principalId, - context.actionInvocationId, - ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `party-relationship-end:${payload.relationshipRef.resourceId}:${payload.expectedRevision}`, - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: payload.relationshipRef.resourceId, - targetResourceType: payload.relationshipRef.resourceType, - }); - if (result.outcome === 'CHANGED') { - const auditEvidence = yield* Schema.encodeEffect(EndRelationshipAuditEvidenceSchema)({ - effectiveAt: payload.effectiveAt, - newProvenance: payload.provenance, - previousValidTo: result.previous.validTo, - reason: Option.fromNullishOr(payload.reason), - relationshipRef: payload.relationshipRef, - }).pipe(Effect.orDie); - yield* context.recordAuditEvidence(auditEvidence); - const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); - const domainEvent = yield* context.addDomainEvent({ - eventType: 'party.registry.relationship-ended.v1', - payloadJson, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: result.relationship.relationshipRef.resourceId, - subjectResourceType: result.relationship.relationshipRef.resourceType, +const handleEndPartyRelationship = Effect.fn('EndPartyRelationshipAction.handleEndPartyRelationship')( + function* handleEndRelationship( + payload: Payload, + context: ActionHandlerContext< + Readonly<{ + 'party.registry.relationship-ended.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; + }>, + Services + >, + ) { + const result = yield* context.services.end(payload, context.scope.principalId, context.actionInvocationId); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `party-relationship-end:${payload.relationshipRef.resourceId}:${payload.expectedRevision}`, + resultCount: 1, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: payload.relationshipRef.resourceId, + targetResourceType: payload.relationshipRef.resourceType, }); - yield* context.addOutboxMessage( - domainEvent, - createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage(payloadJson), - ); - } - return { outcome: result.outcome, relationship: result.relationship }; -}); + if (result.outcome === 'CHANGED') { + const auditEvidence = yield* Schema.encodeEffect(EndRelationshipAuditEvidenceSchema)({ + effectiveAt: payload.effectiveAt, + newProvenance: payload.provenance, + previousValidTo: result.previous.validTo, + reason: Option.fromNullishOr(payload.reason), + relationshipRef: payload.relationshipRef, + }).pipe(Effect.orDie); + yield* context.recordAuditEvidence(auditEvidence); + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); + const domainEvent = yield* context.addDomainEvent({ + eventType: 'party.registry.relationship-ended.v1', + payloadJson, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: result.relationship.relationshipRef.resourceId, + subjectResourceType: result.relationship.relationshipRef.resourceType, + }); + yield* context.addOutboxMessage( + domainEvent, + createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage(payloadJson), + ); + } + return { outcome: result.outcome, relationship: result.relationship }; + }, +); export const endPartyRelationshipAction = defineAction( { @@ -96,7 +92,10 @@ export const endPartyRelationshipAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.end-party-relationship', moduleKey: 'party.registry', role: 'action', @@ -114,12 +113,6 @@ export const endPartyRelationshipAction = defineAction( (transaction, scope) => Effect.succeed({ end: (payload, principalId, actionInvocationId) => - endPartyRelationshipRecord( - transaction, - scope.tenantId, - principalId, - actionInvocationId, - payload, - ), + endPartyRelationshipRecord(transaction, scope.tenantId, principalId, actionInvocationId, payload), }), ); diff --git a/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts index e71162c67..e02b3a0ab 100644 --- a/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts @@ -1,12 +1,8 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; import type { OutboxPayloadJson } from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; -const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxProducerModuleKey = - outboxProducerModuleKey; +const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxProducerModuleKey = outboxProducerModuleKey; const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxTopic = outboxTopic; export const createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage = ( diff --git a/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts b/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts index c09307e90..0a3ce59c9 100644 --- a/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts +++ b/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts @@ -1,5 +1,6 @@ import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; + import { EngagementProfileConflict, EngagementProfileNotFound, @@ -26,15 +27,10 @@ export const handleEngagementLifecycle = ) => ( payload: Payload, - context: Pick< - ActionHandlerContext>, LifecycleServices>, - 'services' - >, + context: Pick>, LifecycleServices>, 'services'>, ) => context.services .transition(payload.profileRef.resourceId) .pipe( - Effect.flatMap((result) => - resolveEngagementLifecycle(result, payload.profileRef.resourceId, requestedState), - ), + Effect.flatMap((result) => resolveEngagementLifecycle(result, payload.profileRef.resourceId, requestedState)), ); diff --git a/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts b/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts index fd6f3bc9a..8f040d41a 100644 --- a/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts +++ b/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts @@ -1,15 +1,13 @@ import { defineActionResourcePermission, defineTenantModuleEntrypoint } from '@app/core-runtime'; + import type { OrganizationEngagementLifecyclePayload, PersonEngagementLifecyclePayload, } from '../../shared/domain/engagement-profile.ts'; import { EngagementLifecycleErrorSchema } from './engagement-lifecycle-handler.ts'; -type EngagementLifecyclePayload = - | OrganizationEngagementLifecyclePayload - | PersonEngagementLifecyclePayload; -type EngagementLifecycleActionKey = - `party.registry.${'archive' | 'unarchive'}-${'person' | 'organization'}-engagement`; +type EngagementLifecyclePayload = OrganizationEngagementLifecyclePayload | PersonEngagementLifecyclePayload; +type EngagementLifecycleActionKey = `party.registry.${'archive' | 'unarchive'}-${'person' | 'organization'}-engagement`; /** The shared governed-write contract; schemas and transaction services stay owner-specific. */ export const engagementLifecycleRegistration = ( @@ -26,7 +24,10 @@ export const engagementLifecycleRegistration = ( diff --git a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts index ffc5a4721..1e24799bd 100644 --- a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts +++ b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts @@ -2,16 +2,16 @@ // @ontos-action-owner party.registry // @ontos-action-slug mark-duplicate-candidate-needs-evidence import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; + import { MarkDuplicateCandidateNeedsEvidencePayloadSchema, MarkDuplicateCandidateNeedsEvidenceResultSchema, } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; - +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; import { DuplicateCaseResolutionErrorSchema, duplicateCaseResolutionService, } from './duplicate-case-resolution-service.ts'; -import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export const markDuplicateCandidateNeedsEvidenceAction = defineAction( { @@ -25,7 +25,10 @@ export const markDuplicateCandidateNeedsEvidenceAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.mark-duplicate-candidate-needs-evidence', moduleKey: 'party.registry', role: 'action', @@ -40,8 +43,7 @@ export const markDuplicateCandidateNeedsEvidenceAction = defineAction( tenantPermission: () => 'review_party_identity', }, handleDuplicateCaseResolution, - (transaction, scope) => - duplicateCaseResolutionService(transaction, scope.tenantId, 'NEEDS_EVIDENCE'), + (transaction, scope) => duplicateCaseResolutionService(transaction, scope.tenantId, 'NEEDS_EVIDENCE'), ); // // diff --git a/app/verticals/party-registry/src/actions/match-party.action.ts b/app/verticals/party-registry/src/actions/match-party.action.ts index 892406200..dcf8a4ac0 100644 --- a/app/verticals/party-registry/src/actions/match-party.action.ts +++ b/app/verticals/party-registry/src/actions/match-party.action.ts @@ -1,40 +1,25 @@ +import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { ActionHandlerContext } from '@app/core-runtime'; // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug match-party import { Effect, Schema } from 'effect'; -import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { - PartyEvidenceInsufficient, - PartyPersistenceUnavailable, -} from '../../shared/domain/identity-contracts.ts'; -import { RuleKeySchema } from '../../shared/domain/matching-contracts.ts'; -import { - candidateFingerprint, - matchParty, -} from '../services/party-matching-persistence.service.ts'; /** Only the durable Action accepts an explicit prior review case for material new evidence. */ -import { - MatchPartyPayloadSchema, - MatchPartyResultSchema, -} from '../../shared/actions/match-party.ts'; +import { MatchPartyPayloadSchema, MatchPartyResultSchema } from '../../shared/actions/match-party.ts'; import type { MatchPartyPayload } from '../../shared/actions/match-party.ts'; +import { PartyEvidenceInsufficient, PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; +import { RuleKeySchema } from '../../shared/domain/matching-contracts.ts'; +import { candidateFingerprint, matchParty } from '../services/party-matching-persistence.service.ts'; export type { MatchPartyPayload } from '../../shared/actions/match-party.ts'; interface Services { - readonly match: ( - payload: MatchPartyPayload, - invocationId: string, - ) => ReturnType; + readonly match: (payload: MatchPartyPayload, invocationId: string) => ReturnType; } const handleMatchParty = Effect.fn('MatchPartyAction.handleMatchParty')( - ( - payload: MatchPartyPayload, - context: ActionHandlerContext>, Services>, - ) => + (payload: MatchPartyPayload, context: ActionHandlerContext>, Services>) => context.services.match(payload, context.actionInvocationId).pipe( Effect.map((result) => ({ ...result, @@ -69,7 +54,10 @@ export const matchPartyAction = defineAction( domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.match-party', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts b/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts index b385a8dd8..2e931c4c1 100644 --- a/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts +++ b/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; + import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + import { partyIdFromString, PartyLifecycleConflict, @@ -14,7 +16,7 @@ type PersistedParty = typeof PartySchema.Type | typeof PartySchema.Encoded; export const decodeParty = (party: PersistedParty) => Schema.is(PartySchema)(party) ? Effect.succeed(party) - : Schema.decodeUnknownEffect(PartySchema)(party).pipe( + : Schema.decodeEffect(PartySchema)(party).pipe( Effect.mapError((cause) => Object.defineProperty( new PartyPersistenceUnavailable({ @@ -54,9 +56,7 @@ export const recordPartyInvariantAccess = ( ) => context.recordDataAccess({ accessKind: 'read', - queryHash: createHash('sha256') - .update(`${queryPrefix}:${party.partyRef.resourceId}`) - .digest('hex'), + queryHash: createHash('sha256').update(`${queryPrefix}:${party.partyRef.resourceId}`).digest('hex'), resultCount: 1, servingModuleKey: 'party.registry', targetModuleKey: 'party.registry', diff --git a/app/verticals/party-registry/src/actions/relationship-event-payload.ts b/app/verticals/party-registry/src/actions/relationship-event-payload.ts index 740fe8dc7..23ed7d930 100644 --- a/app/verticals/party-registry/src/actions/relationship-event-payload.ts +++ b/app/verticals/party-registry/src/actions/relationship-event-payload.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from 'effect'; + import { PartyRelationshipLifecycleEventPayloadSchema } from '../../shared/domain/relationship-contract.ts'; import type { PartyRelationshipDetail } from '../../shared/domain/relationship-contract.ts'; diff --git a/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts b/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts index 29ce7dfe5..57b08ecd8 100644 --- a/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts +++ b/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts @@ -1,11 +1,9 @@ +import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { ActionHandlerContext } from '@app/core-runtime'; // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug request-search-rebuild import { Effect, Schema } from 'effect'; -import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { OutboxPayloadSchema } from '../../shared/outbox/party-registry-search-rebuild-requested-v1.ts'; -import { createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; import { RequestSearchRebuildPayloadSchema, @@ -13,6 +11,8 @@ import { } from '../../shared/actions/request-search-rebuild.ts'; import type { RequestSearchRebuildPayload } from '../../shared/actions/request-search-rebuild.ts'; import { ActionInvocationIdSchema } from '../../shared/domain/correction-contracts.ts'; +import { OutboxPayloadSchema } from '../../shared/outbox/party-registry-search-rebuild-requested-v1.ts'; +import { createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; export type { RequestSearchRebuildPayload } from '../../shared/actions/request-search-rebuild.ts'; @@ -21,28 +21,28 @@ const domainEvents = { } as const; /** Queues committed intent only; projection I/O belongs to the post-commit Worker. */ -const handleRequestSearchRebuild = Effect.fn( - 'RequestSearchRebuildAction.handleRequestSearchRebuild', -)(function* requestSearchRebuild( - _payload: RequestSearchRebuildPayload, - context: ActionHandlerContext>>, -) { - const requestId = ActionInvocationIdSchema.make(context.actionInvocationId); - const payload = { requestId }; - const event = yield* context.addDomainEvent({ - eventType: 'party.registry.search-rebuild-requested.v1', - payloadJson: payload, - producerModuleKey: 'party.registry', - subjectModuleKey: 'core.identity', - subjectResourceId: context.scope.tenantId, - subjectResourceType: 'tenant', - }); - yield* context.addOutboxMessage( - event, - createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage(payload), - ); - return { requestId, status: 'QUEUED' as const }; -}); +const handleRequestSearchRebuild = Effect.fn('RequestSearchRebuildAction.handleRequestSearchRebuild')( + function* requestSearchRebuild( + _payload: RequestSearchRebuildPayload, + context: ActionHandlerContext>>, + ) { + const requestId = ActionInvocationIdSchema.make(context.actionInvocationId); + const payload = { requestId }; + const event = yield* context.addDomainEvent({ + eventType: 'party.registry.search-rebuild-requested.v1', + payloadJson: payload, + producerModuleKey: 'party.registry', + subjectModuleKey: 'core.identity', + subjectResourceId: context.scope.tenantId, + subjectResourceType: 'tenant', + }); + yield* context.addOutboxMessage( + event, + createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage(payload), + ); + return { requestId, status: 'QUEUED' as const }; + }, +); export const requestSearchRebuildAction = defineAction( { @@ -56,7 +56,10 @@ export const requestSearchRebuildAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.request-search-rebuild', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts index c39410306..8301da639 100644 --- a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts +++ b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts @@ -2,31 +2,28 @@ // @ontos-action-owner party.registry // @ontos-action-slug resolve-duplicate-candidate-create import { createHash } from 'node:crypto'; + import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; -import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; -import { - PartyEvidenceInsufficient, - PartyPersistenceUnavailable, -} from '../../shared/domain/identity-contracts.ts'; -import { PartyRefSchema } from '../../shared/resources/party.ts'; -import { resolveDuplicateCandidateCreate } from '../services/party-matching-persistence.service.ts'; -import { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; + import { ResolveDuplicateCandidateCreatePayloadSchema, ResolveDuplicateCandidateCreateResultSchema, } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; import type { ResolveDuplicateCandidateCreatePayload } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; +import { PartyEvidenceInsufficient, PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; +import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; +import { PartyRefSchema } from '../../shared/resources/party.ts'; +import { resolveDuplicateCandidateCreate } from '../services/party-matching-persistence.service.ts'; +import { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; export type { ResolveDuplicateCandidateCreatePayload } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; -const ErrorSchema = Schema.Union([ - DuplicateCandidateConflict, - PartyEvidenceInsufficient, - PartyPersistenceUnavailable, -]); +const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyEvidenceInsufficient, PartyPersistenceUnavailable]); const domainEvents = { - 'party.registry.party-created.v1': Schema.Struct({ partyRef: PartyRefSchema }), + 'party.registry.party-created.v1': Schema.Struct({ + partyRef: PartyRefSchema, + }), } as const; interface Services { readonly resolve: ( @@ -41,9 +38,7 @@ const handle = Effect.fn('ResolveDuplicateCandidateCreateAction.handle')(functio const result = yield* context.services.resolve(payload, context.actionInvocationId); yield* context.recordDataAccess({ accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), + queryHash: createHash('sha256').update(`duplicate-case-invariants:${payload.caseRef.resourceId}`).digest('hex'), resultCount: 1, servingModuleKey: 'party.registry', targetModuleKey: 'party.registry', @@ -66,7 +61,9 @@ const handle = Effect.fn('ResolveDuplicateCandidateCreateAction.handle')(functio }); yield* context.addOutboxMessage( event, - createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage({ partyRef: result.partyRef }), + createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage({ + partyRef: result.partyRef, + }), ); return result; }); @@ -82,7 +79,10 @@ export const resolveDuplicateCandidateCreateAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.resolve-duplicate-candidate-create', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts index 126bcc9e6..8b5b708ef 100644 --- a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts +++ b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts @@ -2,16 +2,19 @@ // @ontos-action-owner party.registry // @ontos-action-slug resolve-duplicate-candidate-match import { createHash } from 'node:crypto'; + import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; + import { AddPartyOfficialIdentifierResultSchema } from '../../shared/actions/add-party-official-identifier.ts'; -import { publishAttachedOfficialIdentifiers } from './attached-official-identifier-events.ts'; import { - ClaimOwnedByDifferentParty, - DuplicateCandidateConflict, -} from '../../shared/domain/matching-contracts.ts'; + ResolveDuplicateCandidateMatchPayloadSchema, + ResolveDuplicateCandidateMatchResultSchema, +} from '../../shared/actions/resolve-duplicate-candidate-match.ts'; +import type { ResolveDuplicateCandidateMatchPayload } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; +import { ClaimOwnedByDifferentParty, DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -20,11 +23,7 @@ import { PartyAliasWriteRejected, } from '../../shared/domain/merge-alias-resolution.ts'; import { resolveDuplicateCandidateMatch } from '../services/party-matching-persistence.service.ts'; -import { - ResolveDuplicateCandidateMatchPayloadSchema, - ResolveDuplicateCandidateMatchResultSchema, -} from '../../shared/actions/resolve-duplicate-candidate-match.ts'; -import type { ResolveDuplicateCandidateMatchPayload } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; +import { publishAttachedOfficialIdentifiers } from './attached-official-identifier-events.ts'; export type { ResolveDuplicateCandidateMatchPayload } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; const ErrorSchema = Schema.Union([ @@ -56,9 +55,7 @@ const handle = Effect.fn('ResolveDuplicateCandidateMatchAction.handle')(function ); yield* context.recordDataAccess({ accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), + queryHash: createHash('sha256').update(`duplicate-case-invariants:${payload.caseRef.resourceId}`).digest('hex'), resultCount: 1, servingModuleKey: 'party.registry', targetModuleKey: 'party.registry', @@ -66,11 +63,7 @@ const handle = Effect.fn('ResolveDuplicateCandidateMatchAction.handle')(function targetResourceType: result.caseRef.resourceType, }); if (result.partyRef !== null) { - yield* publishAttachedOfficialIdentifiers( - context, - result.partyRef, - addedOfficialIdentifierRefs, - ); + yield* publishAttachedOfficialIdentifiers(context, result.partyRef, addedOfficialIdentifierRefs); } return result; }); @@ -86,7 +79,10 @@ export const resolveDuplicateCandidateMatchAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.resolve-duplicate-candidate-match', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts index 81f00251a..6b6f97876 100644 --- a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts @@ -3,6 +3,7 @@ // @ontos-action-slug unarchive-organization-engagement import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { OrganizationEngagementLifecyclePayloadSchema, OrganizationEngagementProfileSchema, @@ -23,9 +24,7 @@ export const unarchiveOrganizationEngagementAction = defineAction( payloadSchema: OrganizationEngagementLifecyclePayloadSchema, resultSchema: OrganizationEngagementProfileSchema, }, - handleEngagementLifecycle( - 'active', - ), + handleEngagementLifecycle('active'), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/actions/unarchive-party.action.ts b/app/verticals/party-registry/src/actions/unarchive-party.action.ts index afd8c5a57..cc0131724 100644 --- a/app/verticals/party-registry/src/actions/unarchive-party.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-party.action.ts @@ -1,17 +1,15 @@ -// @generated by OntOS Codesmith Action v1 -// @ontos-action-owner party.registry -// @ontos-action-slug unarchive-party -import { decodeParty, recordPartyInvariantAccess } from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + +import { UnarchivePartyPayloadSchema, UnarchivePartyResultSchema } from '../../shared/actions/unarchive-party.ts'; +import type { UnarchivePartyPayload } from '../../shared/actions/unarchive-party.ts'; import { partyIdFromString, PartyLifecycleConflict, PartyNotFound, PartyPersistenceUnavailable, } from '../../shared/domain/identity-contracts.ts'; -import { PartyRefSchema } from '../../shared/resources/party.ts'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -19,15 +17,14 @@ import { PartyAliasResolutionUnavailable, PartyAliasWriteRejected, } from '../../shared/domain/merge-alias-resolution.ts'; +import { PartyRefSchema } from '../../shared/resources/party.ts'; import { unarchivePartyWithReview } from '../services/party-identity-persistence.service.ts'; +// @generated by OntOS Codesmith Action v1 +// @ontos-action-owner party.registry +// @ontos-action-slug unarchive-party +import { decodeParty, recordPartyInvariantAccess } from './party-lifecycle-action-helpers.ts'; import { createUnarchivePartyPartyRegistryPartyUnarchivedV1OutboxMessage } from './unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts'; -import { - UnarchivePartyPayloadSchema, - UnarchivePartyResultSchema, -} from '../../shared/actions/unarchive-party.ts'; -import type { UnarchivePartyPayload } from '../../shared/actions/unarchive-party.ts'; - export type { UnarchivePartyPayload } from '../../shared/actions/unarchive-party.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, @@ -40,7 +37,9 @@ const ErrorSchema = Schema.Union([ PartyAliasWriteRejected, ]); const domainEvents = { - 'party.registry.party-unarchived.v1': Schema.Struct({ partyRef: PartyRefSchema }), + 'party.registry.party-unarchived.v1': Schema.Struct({ + partyRef: PartyRefSchema, + }), } as const; export interface UnarchivePartyServices { readonly unarchive: ( @@ -75,7 +74,12 @@ const handle = Effect.fn('UnarchivePartyAction.handle')(function* unarchiveParty Match.tag('blocked', ({ value }) => decodeParty(value.party).pipe( Effect.map( - (party) => ({ actionResult: { ...value, party }, changed: false, party }) as const, + (party) => + ({ + actionResult: { ...value, party }, + changed: false, + party, + }) as const, ), ), ), @@ -124,7 +128,10 @@ export const unarchivePartyAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.unarchive-party', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts index 34cfa9933..b59b299f5 100644 --- a/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts @@ -1,8 +1,5 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxProducerModuleKey = outboxProducerModuleKey; diff --git a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts index 194889e77..f8a61669f 100644 --- a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts @@ -3,6 +3,7 @@ // @ontos-action-slug unarchive-person-engagement import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PersonEngagementLifecyclePayloadSchema, PersonEngagementProfileSchema, @@ -17,9 +18,7 @@ import { engagementLifecycleRegistration } from './engagement-lifecycle-registra export const unarchivePersonEngagementAction = defineAction( { - ...engagementLifecycleRegistration( - 'party.registry.unarchive-person-engagement', - ), + ...engagementLifecycleRegistration('party.registry.unarchive-person-engagement'), payloadSchema: PersonEngagementLifecyclePayloadSchema, resultSchema: PersonEngagementProfileSchema, }, @@ -34,8 +33,7 @@ export const unarchivePersonEngagementAction = defineAction( ); } return Effect.succeed({ - transition: (profileId) => - transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'active'), + transition: (profileId) => transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'active'), }); }, ); diff --git a/app/verticals/party-registry/src/actions/update-contact-point.action.ts b/app/verticals/party-registry/src/actions/update-contact-point.action.ts index 9280d2e31..7a482b6f4 100644 --- a/app/verticals/party-registry/src/actions/update-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/update-contact-point.action.ts @@ -4,6 +4,22 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Match, Schema } from 'effect'; + +import { + UpdateContactPointPayloadSchema, + UpdateContactPointResultSchema, +} from '../../shared/actions/update-contact-point.ts'; +import type { UpdateContactPointPayload } from '../../shared/actions/update-contact-point.ts'; +import { + PartyContactPointAlreadyExists, + PartyContactPointCorrectionRequired, + PartyContactPointInvalid, + PartyContactPointLifecycleConflict, + PartyContactPointNotFound, + PartyContactPointPersistenceUnavailable, + PartyContactPointPartyNotFound, + PartyContactPointRevisionConflict, +} from '../../shared/domain/contact-point-errors.ts'; import { PartyContactPointSchema, assertAddressPurposeRules, @@ -19,26 +35,10 @@ import type { ContactPointVerification, PartyContactPoint, } from '../../shared/domain/contact-point.ts'; -import { - PartyContactPointAlreadyExists, - PartyContactPointCorrectionRequired, - PartyContactPointInvalid, - PartyContactPointLifecycleConflict, - PartyContactPointNotFound, - PartyContactPointPersistenceUnavailable, - PartyContactPointPartyNotFound, - PartyContactPointRevisionConflict, -} from '../../shared/domain/contact-point-errors.ts'; import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; import { updateContactPointRecord } from '../services/party-contact-point-persistence.service.ts'; import { createUpdateContactPointPartyRegistryContactPointUpdatedV1OutboxMessage } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -import { - UpdateContactPointPayloadSchema, - UpdateContactPointResultSchema, -} from '../../shared/actions/update-contact-point.ts'; -import type { UpdateContactPointPayload } from '../../shared/actions/update-contact-point.ts'; - export { UpdateContactPointPayloadSchema } from '../../shared/actions/update-contact-point.ts'; export type { UpdateContactPointPayload } from '../../shared/actions/update-contact-point.ts'; @@ -64,7 +64,10 @@ export interface UpdateContactPointCommand { readonly acceptedByActionInvocationId: string; readonly acceptedByPrincipalId: string; readonly change: - | Readonly<{ readonly preferred: boolean; readonly type: 'SET_CHANNEL_PREFERRED' }> + | Readonly<{ + readonly preferred: boolean; + readonly type: 'SET_CHANNEL_PREFERRED'; + }> | Readonly<{ readonly assignment: AddressPurposeAssignment; readonly type: 'SET_ADDRESS_PURPOSE'; @@ -79,7 +82,10 @@ export interface UpdateContactPointCommand { readonly type: 'ENRICH_VERIFICATION'; readonly verification: ContactPointVerification; }> - | Readonly<{ readonly provenance: ContactPointProvenance; readonly type: 'ADD_PROVENANCE' }> + | Readonly<{ + readonly provenance: ContactPointProvenance; + readonly type: 'ADD_PROVENANCE'; + }> | Readonly<{ readonly evidenceReferences: readonly string[]; readonly reason: string; @@ -109,14 +115,15 @@ type UpdateError = | PartyContactPointRevisionConflict; interface Services { - readonly update: ( - command: UpdateContactPointCommand, - ) => Effect.Effect; + readonly update: (command: UpdateContactPointCommand) => Effect.Effect; } const invalidContactPoint = (reason: string, cause: unknown) => Object.defineProperty( - new PartyContactPointInvalid({ code: 'party_contact_point_invalid', reason }), + new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason, + }), 'cause', { configurable: true, value: cause }, ); @@ -131,9 +138,7 @@ const persistenceUnavailable = (cause: unknown) => { configurable: true, value: cause }, ); -const persistenceChange = ( - change: UpdateContactPointPayload['change'], -): UpdateContactPointCommand['change'] => +const persistenceChange = (change: UpdateContactPointPayload['change']): UpdateContactPointCommand['change'] => Match.value(change).pipe( Match.discriminatorsExhaustive('type')({ ADD_PROVENANCE: (value) => value, @@ -172,22 +177,14 @@ const handleUpdateContactPoint = Effect.fn('UpdateContactPointAction.handleUpdat if (payload.change.type === 'ENRICH_VERIFICATION') { const { verification } = payload.change; yield* Effect.try({ - catch: (cause) => - invalidContactPoint('Verification enrichment is missing its required evidence', cause), + catch: (cause) => invalidContactPoint('Verification enrichment is missing its required evidence', cause), try: () => assertVerificationRules(verification), }); } - if ( - payload.change.type === 'CORRECT_CONTACT_POINT' && - payload.change.replacement !== undefined - ) { + if (payload.change.type === 'CORRECT_CONTACT_POINT' && payload.change.replacement !== undefined) { const { replacement } = payload.change; yield* Effect.try({ - catch: (cause) => - invalidContactPoint( - 'The correction replacement does not satisfy Contact Point rules', - cause, - ), + catch: (cause) => invalidContactPoint('The correction replacement does not satisfy Contact Point rules', cause), try: () => { assertVerificationRules(replacement.verification); if (replacement.contactPoint.type === 'ADDRESS') { @@ -204,13 +201,9 @@ const handleUpdateContactPoint = Effect.fn('UpdateContactPointAction.handleUpdat change: persistenceChange(payload.change), }); const eventContactPointRef = - payload.change.type === 'CORRECT_CONTACT_POINT' - ? payload.contactPointRef - : contactPoint.contactPointRef; + payload.change.type === 'CORRECT_CONTACT_POINT' ? payload.contactPointRef : contactPoint.contactPointRef; const eventRevision = - payload.change.type === 'CORRECT_CONTACT_POINT' - ? payload.expectedRevision + 1 - : contactPoint.revision; + payload.change.type === 'CORRECT_CONTACT_POINT' ? payload.expectedRevision + 1 : contactPoint.revision; const event = yield* context.addDomainEvent({ eventType: 'party.registry.contact-point-updated.v1', payloadJson: { @@ -243,10 +236,15 @@ export const updateContactPointAction = defineAction( actionKey: 'party.registry.update-contact-point', auditProfile: 'sensitive', domainErrorSchema: UpdateContactPointErrorSchema, - domainEvents: { 'party.registry.contact-point-updated.v1': ContactPointUpdatedEventSchema }, + domainEvents: { + 'party.registry.contact-point-updated.v1': ContactPointUpdatedEventSchema, + }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.update-contact-point', moduleKey: 'party.registry', role: 'action', @@ -265,12 +263,10 @@ export const updateContactPointAction = defineAction( Effect.succeed({ update: (command: UpdateContactPointCommand) => updateContactPointRecord(transaction, scope, command).pipe( - Effect.flatMap((contactPoint) => - Schema.is(PartyContactPointSchema)(contactPoint) - ? Effect.succeed(contactPoint) - : Schema.decodeUnknownEffect(PartyContactPointSchema)(contactPoint).pipe( - Effect.mapError(persistenceUnavailable), - ), + Effect.filterOrElse(Schema.is(PartyContactPointSchema), (contactPoint) => + Schema.decodeUnknownEffect(PartyContactPointSchema)(contactPoint).pipe( + Effect.mapError(persistenceUnavailable), + ), ), ), }), diff --git a/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts index 1e27f4f86..2603b06f5 100644 --- a/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts @@ -3,9 +3,21 @@ // @ontos-action-slug update-party-official-identifier /* eslint-disable anti-slop-effect/no-service-constructor-imports -- make*Ref helpers construct plain ResourceRef values, not Effect services. expires: 2026-12-31. */ import { createHash } from 'node:crypto'; + import type { ActionHandlerContext } from '@app/core-runtime'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { DateTime, Effect, Match, Option, Schema } from 'effect'; + +// Value/type/namespace/Party reassignment requires correct-party-fact. A legitimate new +// identifier is represented by END_VALIDITY followed by add-party-official-identifier. +import { + UpdatePartyOfficialIdentifierPayloadSchema, + UpdatePartyOfficialIdentifierResultSchema, +} from '../../shared/actions/update-party-official-identifier.ts'; +import type { + UpdatePartyOfficialIdentifierPayload, + UpdatePartyOfficialIdentifierResult, +} from '../../shared/actions/update-party-official-identifier.ts'; import { IdentifierVerificationSchema, OfficialIdentifierAssertionStateSchema, @@ -35,47 +47,29 @@ import { endOfficialIdentifierRecord, updateOfficialIdentifierVerificationRecord, } from '../services/party-official-identifier-persistence.service.ts'; - -// Value/type/namespace/Party reassignment requires correct-party-fact. A legitimate new -// identifier is represented by END_VALIDITY followed by add-party-official-identifier. -import { - UpdatePartyOfficialIdentifierPayloadSchema, - UpdatePartyOfficialIdentifierResultSchema, -} from '../../shared/actions/update-party-official-identifier.ts'; -import type { - UpdatePartyOfficialIdentifierPayload, - UpdatePartyOfficialIdentifierResult, -} from '../../shared/actions/update-party-official-identifier.ts'; import { createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; export { UpdatePartyOfficialIdentifierPayloadSchema } from '../../shared/actions/update-party-official-identifier.ts'; export type { UpdatePartyOfficialIdentifierPayload } from '../../shared/actions/update-party-official-identifier.ts'; -const PartyOfficialIdentifierNotFoundContract = Schema.TaggedStruct( +const PartyOfficialIdentifierNotFoundContract = Schema.TaggedStruct('PartyOfficialIdentifierNotFound', { + code: Schema.Literal('party_official_identifier_not_found'), + reason: Schema.String, +}); +const PartyOfficialIdentifierNotFound = Schema.TaggedError()( 'PartyOfficialIdentifierNotFound', - { - code: Schema.Literal('party_official_identifier_not_found'), - reason: Schema.String, - }, + PartyOfficialIdentifierNotFoundContract.fields, ); -const PartyOfficialIdentifierNotFound = Schema.TaggedError< - typeof PartyOfficialIdentifierNotFoundContract.Type ->()('PartyOfficialIdentifierNotFound', PartyOfficialIdentifierNotFoundContract.fields); type PartyOfficialIdentifierNotFoundError = InstanceType; -const PartyOfficialIdentifierUpdateConflictContract = Schema.TaggedStruct( - 'PartyOfficialIdentifierUpdateConflict', - { - code: Schema.Literal('party_official_identifier_update_conflict'), - reason: Schema.String, - }, -); +const PartyOfficialIdentifierUpdateConflictContract = Schema.TaggedStruct('PartyOfficialIdentifierUpdateConflict', { + code: Schema.Literal('party_official_identifier_update_conflict'), + reason: Schema.String, +}); const PartyOfficialIdentifierUpdateConflict = Schema.TaggedError< typeof PartyOfficialIdentifierUpdateConflictContract.Type >()('PartyOfficialIdentifierUpdateConflict', PartyOfficialIdentifierUpdateConflictContract.fields); -type PartyOfficialIdentifierUpdateConflictError = InstanceType< - typeof PartyOfficialIdentifierUpdateConflict ->; +type PartyOfficialIdentifierUpdateConflictError = InstanceType; const ErrorSchema = Schema.Union([ OfficialIdentifierClaimConflict, @@ -183,7 +177,10 @@ export const updatePartyOfficialIdentifierAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.update-party-official-identifier', moduleKey: 'party.registry', role: 'action', @@ -261,17 +258,13 @@ export const updatePartyOfficialIdentifierAction = defineAction( after: metadata(value), before: metadata(previous), result: { - officialIdentifierRef: makePartyOfficialIdentifierRef( - scope.tenantId, - value.officialIdentifierId, - ), + officialIdentifierRef: makePartyOfficialIdentifierRef(scope.tenantId, value.officialIdentifierId), partyRef: makePartyRef(scope.tenantId, value.partyId), // SAFETY: the owner database CHECK constrains assertion state to this contract. state: value.state as UpdatePartyOfficialIdentifierResult['state'], validTo: Option.fromNullOr(value.validTo).pipe(Option.map(DateTime.makeUnsafe)), // SAFETY: the owner database CHECK constrains verification to this contract. - verification: - value.verificationState as UpdatePartyOfficialIdentifierResult['verification'], + verification: value.verificationState as UpdatePartyOfficialIdentifierResult['verification'], }, }); }), diff --git a/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts index ae09a5844..ebd0628c3 100644 --- a/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts @@ -5,9 +5,10 @@ import { } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; -export const createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage = - (payload: OutboxPayload): OutboxMessage => ({ - payloadJson: payload, - producerModuleKey: outboxProducerModuleKey, - topic: outboxTopic, - }); +export const createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage = ( + payload: OutboxPayload, +): OutboxMessage => ({ + payloadJson: payload, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, +}); diff --git a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts index 00ba8c8f0..5cf0095f1 100644 --- a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts @@ -1,9 +1,10 @@ +import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { ActionHandlerContext } from '@app/core-runtime'; // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug update-party-relationship import { Effect, Schema } from 'effect'; -import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; + import { ChangePartyRelationshipResultSchema, PartyRelationshipLifecycleEventPayloadJsonSchema, @@ -18,9 +19,8 @@ import type { RelationshipChangeResult, RelationshipMutationError, } from '../services/party-relationship-persistence.service.ts'; -import { createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; - import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; +import { createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; interface Services { readonly update: ( @@ -30,61 +30,57 @@ interface Services { ) => Effect.Effect; } -const handleUpdatePartyRelationship = Effect.fn( - 'UpdatePartyRelationshipAction.handleUpdatePartyRelationship', -)(function* handleUpdateRelationship( - payload: Payload, - context: ActionHandlerContext< - Readonly<{ - 'party.registry.relationship-updated.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; - }>, - Services - >, -) { - const result = yield* context.services.update( - payload, - context.scope.principalId, - context.actionInvocationId, - ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: `party-relationship-update:${payload.relationshipRef.resourceId}:${payload.expectedRevision}`, - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: payload.relationshipRef.resourceId, - targetResourceType: payload.relationshipRef.resourceType, - }); - if (result.outcome === 'CHANGED') { - const auditEvidence = yield* Schema.encodeEffect(UpdateRelationshipAuditEvidenceSchema)({ - changeReason: payload.changeReason, - newEndHistory: result.relationship.endHistory, - newProvenance: payload.provenance, - newValidFrom: result.relationship.validFrom, - newValidTo: result.relationship.validTo, - previousEndHistory: result.previous.endHistory, - previousProvenance: result.previous.provenance, - previousValidFrom: result.previous.validFrom, - previousValidTo: result.previous.validTo, - relationshipRef: payload.relationshipRef, - }).pipe(Effect.orDie); - yield* context.recordAuditEvidence(auditEvidence); - const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); - const domainEvent = yield* context.addDomainEvent({ - eventType: 'party.registry.relationship-updated.v1', - payloadJson, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: result.relationship.relationshipRef.resourceId, - subjectResourceType: result.relationship.relationshipRef.resourceType, +const handleUpdatePartyRelationship = Effect.fn('UpdatePartyRelationshipAction.handleUpdatePartyRelationship')( + function* handleUpdateRelationship( + payload: Payload, + context: ActionHandlerContext< + Readonly<{ + 'party.registry.relationship-updated.v1': typeof PartyRelationshipLifecycleEventPayloadJsonSchema; + }>, + Services + >, + ) { + const result = yield* context.services.update(payload, context.scope.principalId, context.actionInvocationId); + yield* context.recordDataAccess({ + accessKind: 'read', + queryHash: `party-relationship-update:${payload.relationshipRef.resourceId}:${payload.expectedRevision}`, + resultCount: 1, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: payload.relationshipRef.resourceId, + targetResourceType: payload.relationshipRef.resourceType, }); - yield* context.addOutboxMessage( - domainEvent, - createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage(payloadJson), - ); - } - return { outcome: result.outcome, relationship: result.relationship }; -}); + if (result.outcome === 'CHANGED') { + const auditEvidence = yield* Schema.encodeEffect(UpdateRelationshipAuditEvidenceSchema)({ + changeReason: payload.changeReason, + newEndHistory: result.relationship.endHistory, + newProvenance: payload.provenance, + newValidFrom: result.relationship.validFrom, + newValidTo: result.relationship.validTo, + previousEndHistory: result.previous.endHistory, + previousProvenance: result.previous.provenance, + previousValidFrom: result.previous.validFrom, + previousValidTo: result.previous.validTo, + relationshipRef: payload.relationshipRef, + }).pipe(Effect.orDie); + yield* context.recordAuditEvidence(auditEvidence); + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); + const domainEvent = yield* context.addDomainEvent({ + eventType: 'party.registry.relationship-updated.v1', + payloadJson, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: result.relationship.relationshipRef.resourceId, + subjectResourceType: result.relationship.relationshipRef.resourceType, + }); + yield* context.addOutboxMessage( + domainEvent, + createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage(payloadJson), + ); + } + return { outcome: result.outcome, relationship: result.relationship }; + }, +); export const updatePartyRelationshipAction = defineAction( { @@ -101,7 +97,10 @@ export const updatePartyRelationshipAction = defineAction( }, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.update-party-relationship', moduleKey: 'party.registry', role: 'action', @@ -119,12 +118,6 @@ export const updatePartyRelationshipAction = defineAction( (transaction, scope) => Effect.succeed({ update: (payload, principalId, actionInvocationId) => - updatePartyRelationshipRecord( - transaction, - scope.tenantId, - principalId, - actionInvocationId, - payload, - ), + updatePartyRelationshipRecord(transaction, scope.tenantId, principalId, actionInvocationId, payload), }), ); diff --git a/app/verticals/party-registry/src/actions/update-party.action.ts b/app/verticals/party-registry/src/actions/update-party.action.ts index 6fe2cf5b3..6479fbd95 100644 --- a/app/verticals/party-registry/src/actions/update-party.action.ts +++ b/app/verticals/party-registry/src/actions/update-party.action.ts @@ -1,21 +1,9 @@ -// @generated by OntOS Codesmith Action v1 -// @ontos-action-owner party.registry -// @ontos-action-slug update-party -import { - recordPartyInvariantAccess, - resolvePartyLifecycle, -} from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { DateTime, Effect, Schema } from 'effect'; -import { - PartyAliasResolutionBrokenChain, - PartyAliasResolutionCrossTenant, - PartyAliasResolutionCycle, - PartyAliasResolutionUnavailable, - PartyAliasWriteRejected, -} from '../../shared/domain/merge-alias-resolution.ts'; -import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; + +import { UpdatePartyPayloadSchema, UpdatePartyResultSchema } from '../../shared/actions/update-party.ts'; +import type { UpdatePartyPayload } from '../../shared/actions/update-party.ts'; import { PartyLifecycleConflict, PartyNotFound, @@ -26,17 +14,23 @@ import type { PartyEvidenceInsufficientError, PartyPersistenceUnavailableError, } from '../../shared/domain/identity-contracts.ts'; +import { + PartyAliasResolutionBrokenChain, + PartyAliasResolutionCrossTenant, + PartyAliasResolutionCycle, + PartyAliasResolutionUnavailable, + PartyAliasWriteRejected, +} from '../../shared/domain/merge-alias-resolution.ts'; +import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import { updatePartyIdentityRecord } from '../services/party-identity-persistence.service.ts'; import type { PartyLifecycle } from '../services/party-identity-persistence.service.ts'; +// @generated by OntOS Codesmith Action v1 +// @ontos-action-owner party.registry +// @ontos-action-slug update-party +import { recordPartyInvariantAccess, resolvePartyLifecycle } from './party-lifecycle-action-helpers.ts'; import { createUpdatePartyPartyRegistryPartyUpdatedV1OutboxMessage } from './update-party.party-registry-party-updated-v1.outbox-message.ts'; -import { - UpdatePartyPayloadSchema, - UpdatePartyResultSchema, -} from '../../shared/actions/update-party.ts'; -import type { UpdatePartyPayload } from '../../shared/actions/update-party.ts'; - export type { UpdatePartyPayload } from '../../shared/actions/update-party.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, @@ -50,7 +44,9 @@ const ErrorSchema = Schema.Union([ PartyAliasWriteRejected, ]); const domainEvents = { - 'party.registry.party-updated.v1': Schema.Struct({ partyRef: PartyRefSchema }), + 'party.registry.party-updated.v1': Schema.Struct({ + partyRef: PartyRefSchema, + }), } as const; type UpdatePartyPersistenceError = | PartyAliasResolutionError @@ -109,7 +105,10 @@ export const updatePartyAction = defineAction( domainEvents, entrypoint: defineTenantModuleEntrypoint({ access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + authorization: { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }, entrypointKey: 'party.registry.update-party', moduleKey: 'party.registry', role: 'action', diff --git a/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts index df95208df..35360ce36 100644 --- a/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts @@ -1,16 +1,11 @@ import type { OutboxMessage } from '@app/core-runtime'; -import { - outboxProducerModuleKey, - outboxTopic, -} from '@app/party-registry/outbox/party-registry-party-updated-v1'; +import { outboxProducerModuleKey, outboxTopic } from '@app/party-registry/outbox/party-registry-party-updated-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-updated-v1'; const UpdatePartyPartyRegistryPartyUpdatedV1OutboxProducerModuleKey = outboxProducerModuleKey; const UpdatePartyPartyRegistryPartyUpdatedV1OutboxTopic = outboxTopic; -export const createUpdatePartyPartyRegistryPartyUpdatedV1OutboxMessage = ( - payload: OutboxPayload, -): OutboxMessage => ({ +export const createUpdatePartyPartyRegistryPartyUpdatedV1OutboxMessage = (payload: OutboxPayload): OutboxMessage => ({ payloadJson: payload, producerModuleKey: UpdatePartyPartyRegistryPartyUpdatedV1OutboxProducerModuleKey, topic: UpdatePartyPartyRegistryPartyUpdatedV1OutboxTopic, diff --git a/app/verticals/party-registry/src/api/action-gateway.ts b/app/verticals/party-registry/src/api/action-gateway.ts index 88ee58336..917058ff6 100644 --- a/app/verticals/party-registry/src/api/action-gateway.ts +++ b/app/verticals/party-registry/src/api/action-gateway.ts @@ -1,16 +1,21 @@ // @generated by OntOS Codesmith MicroVertical Action Boundary v1 // @ontos-action-boundary-owner party-registry // @ontos-action-boundary-audience party-registry -import { - issueGatewayContext, - makeOperationGateway as makeSharedOperationGateway, -} from '@app/shared-contracts'; +import { issueGatewayContext, makeOperationGateway as makeSharedOperationGateway } from '@app/shared-contracts'; import type { GatewayContextClientError, GatewayContextClientOptions, OperationGatewayIssuer as SharedOperationGatewayIssuer, } from '@app/shared-contracts'; import { DateTime, Effect, Match, Option, Schema } from 'effect'; + +import type { AddContactPointPayload, AddContactPointResult } from '../../shared/actions/add-contact-point.ts'; +import type { + AddPartyOfficialIdentifierPayload, + AddPartyOfficialIdentifierResult, +} from '../../shared/actions/add-party-official-identifier.ts'; +import type { CorrectPartyFactPayload } from '../../shared/actions/correct-party-fact.ts'; +import type { UpdatePartyPayload, UpdatePartyResult } from '../../shared/actions/update-party.ts'; import * as AresApplication from '../../shared/domain/ares-application.ts'; import type { AresAppliedEvidence, @@ -23,29 +28,16 @@ import type { AresSubjectEvidence } from '../../shared/domain/ares-evidence.ts'; import type { StructuredAddress } from '../../shared/domain/contact-point.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; import type { executeAresLookupWithAuthorization } from './ares-lookup-client.ts'; -import type { executePartyContactPointsWithAuthorization } from './party-contact-points-client.ts'; -import type { executePartyDetailWithAuthorization } from './party-detail-client.ts'; -import type { executePartyOfficialIdentifierHistoryWithAuthorization } from './party-official-identifier-history-client.ts'; import type { addContactPointWithAuthorization, addPartyOfficialIdentifierWithAuthorization, updatePartyWithAuthorization, } from './party-command-client.ts'; -import type { - AddContactPointPayload, - AddContactPointResult, -} from '../../shared/actions/add-contact-point.ts'; -import type { - AddPartyOfficialIdentifierPayload, - AddPartyOfficialIdentifierResult, -} from '../../shared/actions/add-party-official-identifier.ts'; -import type { CorrectPartyFactPayload } from '../../shared/actions/correct-party-fact.ts'; -import type { UpdatePartyPayload, UpdatePartyResult } from '../../shared/actions/update-party.ts'; +import type { executePartyContactPointsWithAuthorization } from './party-contact-points-client.ts'; +import type { executePartyDetailWithAuthorization } from './party-detail-client.ts'; +import type { executePartyOfficialIdentifierHistoryWithAuthorization } from './party-official-identifier-history-client.ts'; -export type { - AresCanonicalFactEvidence, - AresCorrectionReviewHandoff, -} from '../../shared/domain/ares-application.ts'; +export type { AresCanonicalFactEvidence, AresCorrectionReviewHandoff } from '../../shared/domain/ares-application.ts'; export const ACTION_GATEWAY_AUDIENCE = 'party-registry' as const; @@ -59,12 +51,14 @@ export const makeOperationGateway = (acquire: OperationGatewayIssuer = issueGate export const operationGateway = makeOperationGateway(); -export const { deriveAresCorrectionReviewHandoffs, prefillPartyCandidateFromAres } = - AresApplication; +export const { deriveAresCorrectionReviewHandoffs, prefillPartyCandidateFromAres } = AresApplication; export class AresApplySelectionInvalid extends Schema.TaggedError()( 'AresApplySelectionInvalid', - { code: Schema.Literal('ares_apply_selection_invalid'), reason: Schema.String }, + { + code: Schema.Literal('ares_apply_selection_invalid'), + reason: Schema.String, + }, ) {} export type AresApplySelection = { readonly idempotencyKey: string } & ( @@ -234,7 +228,10 @@ const invalidSelection = (reason: string, cause?: unknown) => { reason, }); if (cause !== undefined) { - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); } return failure; }; @@ -265,27 +262,20 @@ const validateSelection = (selection: AresApplySelection, partyRef: PartyRef | n return Effect.fail(invalidSelection('The selected fact requires a supported canonical route')); } if (selection.idempotencyKey.trim().length === 0) { - return Effect.fail( - invalidSelection('Every selected Action requires its own stable idempotency key'), - ); + return Effect.fail(invalidSelection('Every selected Action requires its own stable idempotency key')); } if (partyRef === null) { return Effect.fail(invalidSelection('Enrichment requires one explicit existing Party')); } else if (selection.route === 'PARTY_CORRECTION') { - const expectedFactKind = - selection.fact === 'BUSINESS_NAME' ? 'DISPLAY_NAME' : 'OFFICIAL_IDENTIFIER'; + const expectedFactKind = selection.fact === 'BUSINESS_NAME' ? 'DISPLAY_NAME' : 'OFFICIAL_IDENTIFIER'; if (selection.payload.factKind !== expectedFactKind) { - return Effect.fail( - invalidSelection('Correction review must nominate the selected supported fact'), - ); + return Effect.fail(invalidSelection('Correction review must nominate the selected supported fact')); } if (selection.payload.partyId !== partyRef.resourceId) { return Effect.fail(invalidSelection('Correction target does not match the selected Party')); } } else if (!sameParty(selection.payload.partyRef, partyRef)) { - return Effect.fail( - invalidSelection('All selected facts must target the same tenant-qualified Party'), - ); + return Effect.fail(invalidSelection('All selected facts must target the same tenant-qualified Party')); } return Effect.void; @@ -293,31 +283,23 @@ const validateSelection = (selection: AresApplySelection, partyRef: PartyRef | n const validateRequest = (request: AresApplyRequest) => { if (!request.userConfirmed || request.correlationId.trim().length === 0) { - return Effect.fail( - invalidSelection('Explicit user confirmation and a correlation ID are required'), - ); + return Effect.fail(invalidSelection('Explicit user confirmation and a correlation ID are required')); } const facts = request.selections.map(({ fact }) => fact); if (facts.length > 4 || new Set(facts).size !== facts.length) { return Effect.fail(invalidSelection('Select each bounded ARES fact at most once')); } - return Effect.forEach( - request.selections, - (selection) => validateSelection(selection, request.partyRef), - { concurrency: 1, discard: true }, - ).pipe( + return Effect.forEach(request.selections, (selection) => validateSelection(selection, request.partyRef), { + concurrency: 1, + discard: true, + }).pipe( Effect.andThen(() => - Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)(request.observation).pipe( - Effect.mapError(invalidObservation), - ), + Schema.decodeEffect(AresSubjectEvidenceSchema)(request.observation).pipe(Effect.mapError(invalidObservation)), ), ); }; -const matchesObservation = ( - selection: ExecutableSelection, - observation: AresSubjectEvidence, -): boolean => { +const matchesObservation = (selection: ExecutableSelection, observation: AresSubjectEvidence): boolean => { const { subject } = observation; const businessName = Option.getOrNull(subject.businessName); const registeredAddress = Option.getOrNull(subject.registeredAddress); @@ -332,8 +314,7 @@ const matchesObservation = ( Match.when( { route: 'IDENTIFIER_ADD' }, (selected) => - selected.payload.identifier.identifierType === 'ICO' && - selected.payload.identifier.value === subject.ico, + selected.payload.identifier.identifierType === 'ICO' && selected.payload.identifier.value === subject.ico, ), Match.when( { route: 'CONTACT_POINT_ADD' }, @@ -344,10 +325,7 @@ const matchesObservation = ( selected.payload.contactPoint.purposes[0]?.purpose === 'REGISTERED' && selected.payload.contactPoint.purposes[0]?.registryContext?.jurisdiction === 'CZ' && selected.payload.contactPoint.purposes[0]?.registryContext?.registryKey === 'ARES' && - AresApplication.aresRegisteredAddressMatches( - registeredAddress, - selected.payload.contactPoint.address, - ), + AresApplication.aresRegisteredAddressMatches(registeredAddress, selected.payload.contactPoint.address), ), Match.exhaustive, ); @@ -355,16 +333,12 @@ const matchesObservation = ( const validateSelectedValues = (request: AresApplyRequest, observation: AresSubjectEvidence) => request.selections.some( - (selection) => - selection.route !== 'PARTY_CORRECTION' && !matchesObservation(selection, observation), + (selection) => selection.route !== 'PARTY_CORRECTION' && !matchesObservation(selection, observation), ) ? Effect.fail(invalidSelection('Selected values do not match the confirmed observation')) : Effect.void; -const needsConfirmation = ( - application: AresEvidenceApplication, - reasonCode: string, -): AresEvidenceApplication => ({ +const needsConfirmation = (application: AresEvidenceApplication, reasonCode: string): AresEvidenceApplication => ({ ...application, factDecisions: application.factDecisions.map((decision) => ({ ...decision, @@ -415,11 +389,7 @@ const invokeSelection = ( gateway .invoke( (authorization) => - invoker.updateParty( - { ...selected.payload, externalEvidence: evidence }, - authorization, - commandOptions, - ), + invoker.updateParty({ ...selected.payload, externalEvidence: evidence }, authorization, commandOptions), options, ) .pipe( @@ -454,13 +424,13 @@ const invokeSelection = ( Match.when({ route: 'CONTACT_POINT_ADD' }, (selected) => { const payload = { ...selected.payload, - provenance: { ...selected.payload.provenance, externalEvidence: evidence }, + provenance: { + ...selected.payload.provenance, + externalEvidence: evidence, + }, }; return gateway - .invoke( - (authorization) => invoker.addContactPoint(payload, authorization, commandOptions), - options, - ) + .invoke((authorization) => invoker.addContactPoint(payload, authorization, commandOptions), options) .pipe( Effect.map((result) => ({ evidence, @@ -478,10 +448,8 @@ const observationIsStale = ( observation: AresSubjectEvidence, decisionTime: DateTime.Utc, ): boolean => { - const ageMillis = - DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(observation.observedAt); - const suppliedAgeMillis = - DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(supplied.observedAt); + const ageMillis = DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(observation.observedAt); + const suppliedAgeMillis = DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(supplied.observedAt); return ( ageMillis < 0 || ageMillis > 300_000 || @@ -515,14 +483,11 @@ const correctionTargetChanged = ( selection.route === 'PARTY_CORRECTION' && candidates.some( (candidate) => - candidate.fact === selection.fact && - candidate.targetAssertionId !== selection.payload.targetAssertionId, + candidate.fact === selection.fact && candidate.targetAssertionId !== selection.payload.targetAssertionId, ); -const selectionRevisionChanged = ( - selection: ExecutableSelection, - revision: number | null, -): boolean => selection.route === 'PARTY_UPDATE' && selection.payload.expectedRevision !== revision; +const selectionRevisionChanged = (selection: ExecutableSelection, revision: number | null): boolean => + selection.route === 'PARTY_UPDATE' && selection.payload.expectedRevision !== revision; const permitsSelectedAction = ( decision: AresEvidenceApplication['factDecisions'][number], @@ -567,7 +532,11 @@ const planSelectedActions = ( }; } if (decision.outcome === 'NO_CHANGE' && matchesObservation(selection, observation)) { - skipped.push({ fact: selection.fact, reason: 'ALREADY_SATISFIED', route: selection.route }); + skipped.push({ + fact: selection.fact, + reason: 'ALREADY_SATISFIED', + route: selection.route, + }); continue; } const permitted = permitsSelectedAction(decision, selection); @@ -615,130 +584,122 @@ const planSelectedActions = ( return { executable, skipped, deferred: null }; }; -const loadCanonicalSnapshot = Effect.fn('AresApply.loadCanonicalSnapshot')( - function* loadCanonicalSnapshotEffect( - request: AresApplyRequest, - observation: AresSubjectEvidence, - reads: AresApplyReads, - gateway: ReturnType, - options: AresApplyOptions, - decidedAt: string, - decisionEpochMillis: number, - clientOptions: { readonly baseUrl?: string | URL }, - ) { - if (request.partyRef !== null) { - const { partyRef } = request; - const detail = yield* gateway.invoke( - (authorization) => - reads.party( - request.selections.some((selection) => selection.route === 'PARTY_CORRECTION') - ? { includeFactHistory: true, partyRef } - : { partyRef }, - authorization, - request.correlationId, - clientOptions, - ), - options.gatewayContext, - ); - const { party } = detail; - const { revision: loadedRevision } = party; - if (!sameParty(party.partyRef, partyRef) || detail.resolution.kind === 'ALIAS') { - return yield* invalidSelection( - 'Choose the canonical Party explicitly before applying ARES evidence', - ); - } - const [identifiers, contactPoints] = yield* Effect.all( - [ - gateway.invoke( - (authorization) => - reads.identifiers({ partyRef }, authorization, request.correlationId, clientOptions), - options.gatewayContext, - ), - gateway.invoke( - (authorization) => - reads.contactPoints( - { includeHistorical: false, partyRef, type: 'ADDRESS' }, - authorization, - request.correlationId, - clientOptions, - ), - options.gatewayContext, - ), - ], - { concurrency: 2 }, - ); - const activeIdentifiers = identifiers.items.filter( - (identifier) => - identifier.state === 'ACTIVE' && - identifier.validFrom <= decidedAt && - (identifier.validTo === null || identifier.validTo > decidedAt), - ); - const registeredAddresses = contactPoints.items.flatMap((point) => - point.current && - point.value.type === 'ADDRESS' && - point.value.purposes.some((purpose) => purpose.current && purpose.purpose === 'REGISTERED') - ? [structuredAddress(point.value.address)] - : [], - ); - const canonical: AresCanonicalSnapshot = { - archived: Option.isSome(party.archivedAt), - displayName: Option.getOrNull(party.displayName), - factEvidence: [ - ...detail.currentFactAssertions.flatMap((assertion) => - assertion.isCurrent && - assertion.state === 'ACTIVE' && - assertion.factKind === 'DISPLAY_NAME' && - sameParty(assertion.partyRef, partyRef) && - DateTime.toEpochMillis(assertion.validFrom) <= decisionEpochMillis && - (Option.isNone(assertion.validTo) || - DateTime.toEpochMillis(assertion.validTo.value) > decisionEpochMillis) - ? [ - { - assertionId: assertion.assertionId, - externalEvidence: Option.getOrNull(assertion.externalEvidence), - fact: 'BUSINESS_NAME' as const, - validFrom: DateTime.formatIso(assertion.validFrom), - value: assertion.value, - }, - ] - : [], - ), - ...activeIdentifiers.flatMap((assertion) => - assertion.identifierType === 'ICO' && sameParty(assertion.partyRef, partyRef) - ? [ - { - assertionId: assertion.officialIdentifierRef.resourceId, - externalEvidence: assertion.externalEvidence ?? null, - fact: 'ICO' as const, - validFrom: assertion.validFrom, - value: assertion.normalizedValue, - }, - ] - : [], - ), - ], - icoValues: activeIdentifiers - .filter(({ identifierType }) => identifierType === 'ICO') - .map(({ normalizedValue }) => normalizedValue), - identityAmbiguous: activeIdentifiers.some( - ({ identifierType, normalizedValue, verification }) => { - const observedDic = Option.getOrNull(observation.subject.dic); - return ( - identifierType === 'CZ_DIC' && - verification === 'VERIFIED' && - observedDic !== null && - normalizedValue !== observedDic - ); - }, +const loadCanonicalSnapshot = Effect.fn('AresApply.loadCanonicalSnapshot')(function* loadCanonicalSnapshotEffect( + request: AresApplyRequest, + observation: AresSubjectEvidence, + reads: AresApplyReads, + gateway: ReturnType, + options: AresApplyOptions, + decidedAt: string, + decisionEpochMillis: number, + clientOptions: { readonly baseUrl?: string | URL }, +) { + if (request.partyRef !== null) { + const { partyRef } = request; + const detail = yield* gateway.invoke( + (authorization) => + reads.party( + request.selections.some((selection) => selection.route === 'PARTY_CORRECTION') + ? { includeFactHistory: true, partyRef } + : { partyRef }, + authorization, + request.correlationId, + clientOptions, ), - partyType: party.partyType, - registeredAddresses, - }; - return { canonical, revision: loadedRevision }; + options.gatewayContext, + ); + const { party } = detail; + const { revision: loadedRevision } = party; + if (!sameParty(party.partyRef, partyRef) || detail.resolution.kind === 'ALIAS') { + return yield* invalidSelection('Choose the canonical Party explicitly before applying ARES evidence'); } - return { canonical: null, revision: null }; - }, -); + const [identifiers, contactPoints] = yield* Effect.all( + [ + gateway.invoke( + (authorization) => reads.identifiers({ partyRef }, authorization, request.correlationId, clientOptions), + options.gatewayContext, + ), + gateway.invoke( + (authorization) => + reads.contactPoints( + { includeHistorical: false, partyRef, type: 'ADDRESS' }, + authorization, + request.correlationId, + clientOptions, + ), + options.gatewayContext, + ), + ], + { concurrency: 2 }, + ); + const activeIdentifiers = identifiers.items.filter( + (identifier) => + identifier.state === 'ACTIVE' && + identifier.validFrom <= decidedAt && + (identifier.validTo === null || identifier.validTo > decidedAt), + ); + const registeredAddresses = contactPoints.items.flatMap((point) => + point.current && + point.value.type === 'ADDRESS' && + point.value.purposes.some((purpose) => purpose.current && purpose.purpose === 'REGISTERED') + ? [structuredAddress(point.value.address)] + : [], + ); + const canonical: AresCanonicalSnapshot = { + archived: Option.isSome(party.archivedAt), + displayName: Option.getOrNull(party.displayName), + factEvidence: [ + ...detail.currentFactAssertions.flatMap((assertion) => + assertion.isCurrent && + assertion.state === 'ACTIVE' && + assertion.factKind === 'DISPLAY_NAME' && + sameParty(assertion.partyRef, partyRef) && + DateTime.toEpochMillis(assertion.validFrom) <= decisionEpochMillis && + (Option.isNone(assertion.validTo) || DateTime.toEpochMillis(assertion.validTo.value) > decisionEpochMillis) + ? [ + { + assertionId: assertion.assertionId, + externalEvidence: Option.getOrNull(assertion.externalEvidence), + fact: 'BUSINESS_NAME' as const, + validFrom: DateTime.formatIso(assertion.validFrom), + value: assertion.value, + }, + ] + : [], + ), + ...activeIdentifiers.flatMap((assertion) => + assertion.identifierType === 'ICO' && sameParty(assertion.partyRef, partyRef) + ? [ + { + assertionId: assertion.officialIdentifierRef.resourceId, + externalEvidence: assertion.externalEvidence ?? null, + fact: 'ICO' as const, + validFrom: assertion.validFrom, + value: assertion.normalizedValue, + }, + ] + : [], + ), + ], + icoValues: activeIdentifiers + .filter(({ identifierType }) => identifierType === 'ICO') + .map(({ normalizedValue }) => normalizedValue), + identityAmbiguous: activeIdentifiers.some(({ identifierType, normalizedValue, verification }) => { + const observedDic = Option.getOrNull(observation.subject.dic); + return ( + identifierType === 'CZ_DIC' && + verification === 'VERIFIED' && + observedDic !== null && + normalizedValue !== observedDic + ); + }), + partyType: party.partyType, + registeredAddresses, + }; + return { canonical, revision: loadedRevision }; + } + return { canonical: null, revision: null }; +}); /** * Refreshes provider evidence and canonical facts through governed Reads, evaluates the closed @@ -746,174 +707,159 @@ const loadCanonicalSnapshot = Effect.fn('AresApply.loadCanonicalSnapshot')( * to the ordinary reviewed workflow; a caller-supplied route never proves historical error. * Each Action persists its bounded external evidence and owns its independent idempotent commit. */ -export const applyAresObservationWithActions = Effect.fn( - 'ActionGateway.applyAresObservationWithActions', -)(function* applyAresObservationWithActionsEffect( - request: AresApplyRequest, - invoker: PartyRegistryStandardActionInvoker, - options: AresApplyOptions = {}, -) { - if (request.selections.length === 0) { - return { - _tag: 'AresApplyNotRequested' as const, - completed: [] as const, - skipped: [] as const, - }; - } - const supplied = yield* validateRequest(request); - yield* validateSelectedValues(request, supplied); - const gateway = options.gateway ?? operationGateway; - const reads = options.reads ?? (yield* loadDefaultReads()); - const clientOptions = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }; - const loadedObservation = yield* gateway.invoke( - (authorization) => - reads.observation( - { ico: supplied.queryIco }, - authorization, - request.correlationId, - clientOptions, - ), - options.gatewayContext, - ); - const observation = yield* decodeReadObservation(loadedObservation).pipe( - Effect.mapError(invalidObservation), - ); - const [suppliedInput, observationInput] = yield* Effect.all( - [ - Schema.encodeEffect(AresSubjectEvidenceSchema)(supplied), - Schema.encodeEffect(AresSubjectEvidenceSchema)(observation), - ], - { concurrency: 2 }, - ).pipe( - Effect.mapError((error) => - invalidSelection('ARES observation cannot be encoded for policy evaluation', error), - ), - ); - const decisionTime = yield* DateTime.now; - const decisionEpochMillis = DateTime.toEpochMillis(decisionTime); - const decidedAt = DateTime.formatIso(decisionTime); - const { canonical, revision } = yield* loadCanonicalSnapshot( - request, - observation, - reads, - gateway, - options, - decidedAt, - decisionEpochMillis, - clientOptions, - ); - const application = yield* Effect.try({ - catch: (error) => - invalidSelection( - 'The trusted ARES evidence cannot be evaluated under the owner policy', - error, - ), - try: () => - AresApplication.deriveAresEvidenceApplication({ - canonical, - decidedAt, - evidence: observationInput, - selectedFacts: request.selections.map(({ fact }) => fact), - userConfirmed: request.userConfirmed, - }), - }); - const correctionCandidates = - canonical === null - ? [] - : AresApplication.deriveAresCorrectionReviewHandoffs(application, canonical); - if (observationIsStale(supplied, observation, decisionTime)) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'observation_not_fresh'), - completed: [] as const, - correctionCandidates: [], - skipped: [] as const, - }; - } - if (refreshedObservationChanged(request, suppliedInput, observationInput, observation)) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'refreshed_observation_changed'), - completed: [] as const, - correctionCandidates: [], - skipped: [], - }; - } - const { executable, skipped, deferred } = planSelectedActions( - request, - application, - observation, - correctionCandidates, - revision, - ); - if (deferred !== null) { - return deferred; - } - type PartialCompletion = Extract< - AresApplyOutcome, - { readonly _tag: 'AresApplyPartiallyCompleted' } - >; - interface ExecutionState { - readonly completed: readonly AresAppliedAction[]; - readonly partial: PartialCompletion | null; - } - const initial: ExecutionState = { completed: [], partial: null }; - const execution = yield* Effect.reduce( - executable, - () => initial, - (state, selection) => { - if (state.partial !== null) { - return Effect.succeed(state); - } - const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); - if (decision === undefined) { - return Effect.fail(invalidSelection('Selected fact has no owner decision')); - } - // Logical as-of time of the confirmed observation, stable across delivery retries. - // The standard Action records its trusted actual acceptance time independently. - const evidence = AresApplication.makeAresAppliedEvidence( - { ...application, decidedAt: supplied.servedAt, evidence: supplied }, - decision, - ); - return invokeSelection(selection, evidence, invoker, gateway, options.gatewayContext ?? {}, { - ...clientOptions, - correlationId: request.correlationId, - idempotencyKey: selection.idempotencyKey, - }).pipe( - Effect.result, - Effect.map((attempt): ExecutionState => { - if ('failure' in attempt) { - return { - completed: state.completed, - partial: { - _tag: 'AresApplyPartiallyCompleted' as const, - application, - completed: state.completed, - failed: { - error: attempt.failure, - fact: selection.fact, - idempotencyKey: selection.idempotencyKey, - recovery: 'RESOLVE_STANDARD_ACTION_BEFORE_RETRY' as const, - route: selection.route, - }, - skipped, - }, - }; - } - return { completed: [...state.completed, attempt.success], partial: null }; +export const applyAresObservationWithActions = Effect.fn('ActionGateway.applyAresObservationWithActions')( + function* applyAresObservationWithActionsEffect( + request: AresApplyRequest, + invoker: PartyRegistryStandardActionInvoker, + options: AresApplyOptions = {}, + ) { + if (request.selections.length === 0) { + return { + _tag: 'AresApplyNotRequested' as const, + completed: [] as const, + skipped: [] as const, + }; + } + const supplied = yield* validateRequest(request); + yield* validateSelectedValues(request, supplied); + const gateway = options.gateway ?? operationGateway; + const reads = options.reads ?? (yield* loadDefaultReads()); + const clientOptions = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }; + const loadedObservation = yield* gateway.invoke( + (authorization) => + reads.observation({ ico: supplied.queryIco }, authorization, request.correlationId, clientOptions), + options.gatewayContext, + ); + const observation = yield* decodeReadObservation(loadedObservation).pipe(Effect.mapError(invalidObservation)); + const [suppliedInput, observationInput] = yield* Effect.all( + [ + Schema.encodeEffect(AresSubjectEvidenceSchema)(supplied), + Schema.encodeEffect(AresSubjectEvidenceSchema)(observation), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError((error) => invalidSelection('ARES observation cannot be encoded for policy evaluation', error)), + ); + const decisionTime = yield* DateTime.now; + const decisionEpochMillis = DateTime.toEpochMillis(decisionTime); + const decidedAt = DateTime.formatIso(decisionTime); + const { canonical, revision } = yield* loadCanonicalSnapshot( + request, + observation, + reads, + gateway, + options, + decidedAt, + decisionEpochMillis, + clientOptions, + ); + const application = yield* Effect.try({ + catch: (error) => invalidSelection('The trusted ARES evidence cannot be evaluated under the owner policy', error), + try: () => + AresApplication.deriveAresEvidenceApplication({ + canonical, + decidedAt, + evidence: observationInput, + selectedFacts: request.selections.map(({ fact }) => fact), + userConfirmed: request.userConfirmed, }), - ); - }, - ); - return ( - execution.partial ?? { - _tag: 'AresApplyCompleted' as const, + }); + const correctionCandidates = + canonical === null ? [] : AresApplication.deriveAresCorrectionReviewHandoffs(application, canonical); + if (observationIsStale(supplied, observation, decisionTime)) { + return { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'observation_not_fresh'), + completed: [] as const, + correctionCandidates: [], + skipped: [] as const, + }; + } + if (refreshedObservationChanged(request, suppliedInput, observationInput, observation)) { + return { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'refreshed_observation_changed'), + completed: [] as const, + correctionCandidates: [], + skipped: [], + }; + } + const { executable, skipped, deferred } = planSelectedActions( + request, application, - completed: execution.completed, - skipped, + observation, + correctionCandidates, + revision, + ); + if (deferred !== null) { + return deferred; } - ); -}); + type PartialCompletion = Extract, { readonly _tag: 'AresApplyPartiallyCompleted' }>; + interface ExecutionState { + readonly completed: readonly AresAppliedAction[]; + readonly partial: PartialCompletion | null; + } + const initial: ExecutionState = { completed: [], partial: null }; + const execution = yield* Effect.reduce( + executable, + () => initial, + (state, selection) => { + if (state.partial !== null) { + return Effect.succeed(state); + } + const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); + if (decision === undefined) { + return Effect.fail(invalidSelection('Selected fact has no owner decision')); + } + // Logical as-of time of the confirmed observation, stable across delivery retries. + // The standard Action records its trusted actual acceptance time independently. + const evidence = AresApplication.makeAresAppliedEvidence( + { ...application, decidedAt: supplied.servedAt, evidence: supplied }, + decision, + ); + return invokeSelection(selection, evidence, invoker, gateway, options.gatewayContext ?? {}, { + ...clientOptions, + correlationId: request.correlationId, + idempotencyKey: selection.idempotencyKey, + }).pipe( + Effect.result, + Effect.map((attempt): ExecutionState => { + if ('failure' in attempt) { + return { + completed: state.completed, + partial: { + _tag: 'AresApplyPartiallyCompleted' as const, + application, + completed: state.completed, + failed: { + error: attempt.failure, + fact: selection.fact, + idempotencyKey: selection.idempotencyKey, + recovery: 'RESOLVE_STANDARD_ACTION_BEFORE_RETRY' as const, + route: selection.route, + }, + skipped, + }, + }; + } + return { + completed: [...state.completed, attempt.success], + partial: null, + }; + }), + ); + }, + ); + return ( + execution.partial ?? { + _tag: 'AresApplyCompleted' as const, + application, + completed: execution.completed, + skipped, + } + ); + }, +); /** Production coordinator: mutations use only the explicit, authenticated standard command API. */ export const applyAresObservation = ( diff --git a/app/verticals/party-registry/src/api/ares-lookup-client.ts b/app/verticals/party-registry/src/api/ares-lookup-client.ts index 347124ad3..e5b586526 100644 --- a/app/verticals/party-registry/src/api/ares-lookup-client.ts +++ b/app/verticals/party-registry/src/api/ares-lookup-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { AresLookupApi } from '../../shared/apis/ares-lookup.ts'; import type { AresLookupRequest } from '../../shared/apis/ares-lookup.ts'; import { operationGateway } from './action-gateway.ts'; @@ -15,10 +16,7 @@ type AresLookupAuthorizedInvocation = readonly [ options?: AresLookupClientOptions, ]; -type AresLookupOperationInvocation = readonly [ - requestCorrelation: string, - options?: AresLookupClientOptions, -]; +type AresLookupOperationInvocation = readonly [requestCorrelation: string, options?: AresLookupClientOptions]; const aresLookupClient = ( credential: Redacted.Redacted, @@ -40,9 +38,7 @@ export const executeAresLookupWithAuthorization = ( ...[credential, requestCorrelation, options = {}]: AresLookupAuthorizedInvocation ) => aresLookupClient(Redacted.make(credential), requestCorrelation, options).pipe( - Effect.flatMap((client) => - client.aresLookup.execute({ headers: {}, params: {}, payload, query: {} }), - ), + Effect.flatMap((client) => client.aresLookup.execute({ headers: {}, params: {}, payload, query: {} })), ); export const executeAresLookup = ( diff --git a/app/verticals/party-registry/src/api/ares-lookup.read.ts b/app/verticals/party-registry/src/api/ares-lookup.read.ts index 69ee0b06c..035ccafc1 100644 --- a/app/verticals/party-registry/src/api/ares-lookup.read.ts +++ b/app/verticals/party-registry/src/api/ares-lookup.read.ts @@ -8,10 +8,8 @@ import { } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Match } from 'effect'; -import { - AresLookupRequestSchema, - AresLookupResponseSchema, -} from '../../shared/apis/ares-lookup.ts'; + +import { AresLookupRequestSchema, AresLookupResponseSchema } from '../../shared/apis/ares-lookup.ts'; import { AresSubjectService } from '../integrations/ares/ares-subject.service.ts'; import type { AresSubjectError, @@ -31,15 +29,10 @@ interface Services { readonly lookup: AresSubjectServiceContract['subject']; } -type AresLookupHandlerError = - | ReadHandlerExecutionError - | ReadHandlerNotFound - | ReadHandlerUnavailable; +type AresLookupHandlerError = ReadHandlerExecutionError | ReadHandlerNotFound | ReadHandlerUnavailable; -const withCause = ( - mappedError: MappedError, - cause: AresSubjectError, -) => Object.defineProperty(mappedError, 'cause', { value: cause }); +const withCause = (mappedError: MappedError, cause: AresSubjectError) => + Object.defineProperty(mappedError, 'cause', { value: cause }); const mapAresFailure = (error: AresSubjectError): Effect.Effect => Match.value(error).pipe( diff --git a/app/verticals/party-registry/src/api/counterparties-search-client.ts b/app/verticals/party-registry/src/api/counterparties-search-client.ts index f37292fdf..cc63dc101 100644 --- a/app/verticals/party-registry/src/api/counterparties-search-client.ts +++ b/app/verticals/party-registry/src/api/counterparties-search-client.ts @@ -2,6 +2,7 @@ // @ontos-contribution-kind search-provider import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { CounterpartiesSearchApi } from '../../shared/apis/counterparties-search.ts'; import type { CounterpartiesProviderRequest } from '../../shared/apis/counterparties-search.ts'; import { operationGateway } from './action-gateway.ts'; diff --git a/app/verticals/party-registry/src/api/counterparty-read-client.ts b/app/verticals/party-registry/src/api/counterparty-read-client.ts index f1b37d7d3..bd876080a 100644 --- a/app/verticals/party-registry/src/api/counterparty-read-client.ts +++ b/app/verticals/party-registry/src/api/counterparty-read-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { CounterpartyReadApi } from '../../shared/apis/counterparty-read.ts'; import type { CounterpartyReadRequest } from '../../shared/apis/counterparty-read.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executeCounterpartyReadWithAuthorization = ( ) => counterpartyReadClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.counterpartyRead.execute({ headers: {}, params: {}, payload, query: {} }), + client.counterpartyRead.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/counterparty-read-support.ts b/app/verticals/party-registry/src/api/counterparty-read-support.ts index 0c29ccecf..fa636eda1 100644 --- a/app/verticals/party-registry/src/api/counterparty-read-support.ts +++ b/app/verticals/party-registry/src/api/counterparty-read-support.ts @@ -1,12 +1,11 @@ import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; import { Effect, Match } from 'effect'; + import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import type { CounterpartyRef } from '../../shared/party-registry-references.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; -export const counterpartyPermissionTarget = (input: { - readonly counterpartyRef: CounterpartyRef; -}) => ({ +export const counterpartyPermissionTarget = (input: { readonly counterpartyRef: CounterpartyRef }) => ({ kind: 'any_of' as const, targets: [ { @@ -30,9 +29,7 @@ const notFound = (context: string) => export const resolveCounterpartyRead = ( ref: CounterpartyRef, tenantId: string, - load: ( - counterpartyId: string, - ) => Effect.Effect, CounterpartyPersistenceUnavailable>, + load: (counterpartyId: string) => Effect.Effect, CounterpartyPersistenceUnavailable>, unavailableReason: string, ) => ref.tenantId === tenantId diff --git a/app/verticals/party-registry/src/api/counterparty-read.read.ts b/app/verticals/party-registry/src/api/counterparty-read.read.ts index a359d7dd2..d9e8a4785 100644 --- a/app/verticals/party-registry/src/api/counterparty-read.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-read.read.ts @@ -1,19 +1,14 @@ -// @generated by OntOS Codesmith module-api v1 -import { - counterpartyPermissionTarget, - resolveCounterpartyRead, -} from './counterparty-read-support.ts'; import type { ReadHandlerContext } from '@app/core-runtime'; import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { Effect } from 'effect'; -import { - CounterpartyReadRequestSchema, - CounterpartyReadResponseSchema, -} from '../../shared/apis/counterparty-read.ts'; + +import { CounterpartyReadRequestSchema, CounterpartyReadResponseSchema } from '../../shared/apis/counterparty-read.ts'; import type { CounterpartyReadResponse } from '../../shared/apis/counterparty-read.ts'; import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { findCounterpartyRecord } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; +// @generated by OntOS Codesmith module-api v1 +import { counterpartyPermissionTarget, resolveCounterpartyRead } from './counterparty-read-support.ts'; const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ access: 'read', @@ -23,9 +18,8 @@ const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyReadPermissionTarget = ( - input: Parameters[0], -) => counterpartyPermissionTarget(input); +export const counterpartyReadPermissionTarget = (input: Parameters[0]) => + counterpartyPermissionTarget(input); export const counterpartyReadRead = defineRead( { @@ -49,10 +43,7 @@ export const counterpartyReadRead = defineRead( context: ReadHandlerContext<{ readonly find: ( counterpartyId: string, - ) => Effect.Effect< - LookupResult, - CounterpartyPersistenceUnavailable - >; + ) => Effect.Effect, CounterpartyPersistenceUnavailable>; }>, ) => resolveCounterpartyRead( diff --git a/app/verticals/party-registry/src/api/counterparty-role-history-client.ts b/app/verticals/party-registry/src/api/counterparty-role-history-client.ts index e4b844f28..d1f559c61 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history-client.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { CounterpartyRoleHistoryApi } from '../../shared/apis/counterparty-role-history.ts'; import type { CounterpartyRoleHistoryRequest } from '../../shared/apis/counterparty-role-history.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executeCounterpartyRoleHistoryWithAuthorization = ( ) => counterpartyRoleHistoryClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.counterpartyRoleHistory.execute({ headers: {}, params: {}, payload, query: {} }), + client.counterpartyRoleHistory.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -50,10 +56,5 @@ export const executeCounterpartyRoleHistory = ( ...[requestCorrelation, options = {}]: CounterpartyRoleHistoryOperationInvocation ) => operationGateway.invoke((credential) => - executeCounterpartyRoleHistoryWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executeCounterpartyRoleHistoryWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts index 5f820a08a..af43d90c6 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts @@ -1,11 +1,7 @@ -// @generated by OntOS Codesmith module-api v1 -import { - counterpartyPermissionTarget, - resolveCounterpartyRead, -} from './counterparty-read-support.ts'; import type { ReadHandlerContext } from '@app/core-runtime'; import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { Effect } from 'effect'; + import { CounterpartyRoleHistoryRequestSchema, CounterpartyRoleHistoryResponseSchema, @@ -14,6 +10,8 @@ import type { CounterpartyRoleHistoryResponse } from '../../shared/apis/counterp import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { listCounterpartyRoleHistory } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; +// @generated by OntOS Codesmith module-api v1 +import { counterpartyPermissionTarget, resolveCounterpartyRead } from './counterparty-read-support.ts'; const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ access: 'read', @@ -23,9 +21,8 @@ const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyRoleHistoryPermissionTarget = ( - input: Parameters[0], -) => counterpartyPermissionTarget(input); +export const counterpartyRoleHistoryPermissionTarget = (input: Parameters[0]) => + counterpartyPermissionTarget(input); export const counterpartyRoleHistoryRead = defineRead( { @@ -49,10 +46,7 @@ export const counterpartyRoleHistoryRead = defineRead( context: ReadHandlerContext<{ readonly list: ( counterpartyId: string, - ) => Effect.Effect< - LookupResult, - CounterpartyPersistenceUnavailable - >; + ) => Effect.Effect, CounterpartyPersistenceUnavailable>; }>, ) => resolveCounterpartyRead( @@ -69,12 +63,7 @@ export const counterpartyRoleHistoryRead = defineRead( (transaction, scope) => Effect.succeed({ list: (counterpartyId: string) => - listCounterpartyRoleHistory( - transaction, - scope.tenantId, - scope.legalEntityId, - counterpartyId, - ), + listCounterpartyRoleHistory(transaction, scope.tenantId, scope.legalEntityId, counterpartyId), }), counterpartyRoleHistoryPermissionTarget, ); diff --git a/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts b/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts index 67f70fbad..957bc7a34 100644 --- a/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts +++ b/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { DuplicateCandidateDetailApi } from '../../shared/apis/duplicate-candidate-detail.ts'; import type { DuplicateCandidateDetailRequest } from '../../shared/apis/duplicate-candidate-detail.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executeDuplicateCandidateDetailWithAuthorization = ( ) => duplicateCandidateDetailClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.duplicateCandidateDetail.execute({ headers: {}, params: {}, payload, query: {} }), + client.duplicateCandidateDetail.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -50,10 +56,5 @@ export const executeDuplicateCandidateDetail = ( ...[requestCorrelation, options = {}]: DuplicateCandidateDetailOperationInvocation ) => operationGateway.invoke((credential) => - executeDuplicateCandidateDetailWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executeDuplicateCandidateDetailWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts index b01dc391b..e21885e5e 100644 --- a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts +++ b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts @@ -2,6 +2,7 @@ import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; + import { DuplicateCandidateDetailRequestSchema, DuplicateCandidateDetailResponseSchema, @@ -19,9 +20,7 @@ const duplicateCandidateDetailEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly find: (caseId: string) => ReturnType; } -const duplicateCandidateUnavailable = readUnavailable( - 'Duplicate Candidate persistence is unavailable', -); +const duplicateCandidateUnavailable = readUnavailable('Duplicate Candidate persistence is unavailable'); export const duplicateCandidateDetailRead = defineRead( { accessKind: 'detail', diff --git a/app/verticals/party-registry/src/api/engagement-profile-client.ts b/app/verticals/party-registry/src/api/engagement-profile-client.ts index b65c1f7f4..ef9ef41bc 100644 --- a/app/verticals/party-registry/src/api/engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/engagement-profile-client.ts @@ -3,10 +3,8 @@ import type { GatewayContextClientOptions } from '@app/shared-contracts'; import { Effect } from '@modern-js/plugin-bff/effect-client'; import type { HttpClientError, Schema } from '@modern-js/plugin-bff/effect-client'; import { Redacted } from 'effect'; -import { - engagementProfileOperationContexts, - partyRegistryOperationContexts, -} from '../../shared/api.ts'; + +import { engagementProfileOperationContexts, partyRegistryOperationContexts } from '../../shared/api.ts'; import type { OperationContext, PartyRegistryReadiness } from '../../shared/api.ts'; import { operationGateway } from './action-gateway.ts'; import { @@ -46,9 +44,8 @@ export interface ContactsMutationOptions extends ContactsOperationOptions { readonly idempotencyKey: string; } -export const createContactsClient = ( - options: ContactsClientOptions = {}, -): ContactsClientEffect => createPartyRegistryHttpClient(options); +export const createContactsClient = (options: ContactsClientOptions = {}): ContactsClientEffect => + createPartyRegistryHttpClient(options); const invoke = ( options: ContactsOperationOptions, @@ -72,14 +69,14 @@ const engagementMutation = context: OperationContext, endpoint: ( client: ContactsClient, - ) => (request: { - headers: { 'idempotency-key': string }; - payload: Payload; - }) => Effect.Effect, + ) => (request: { headers: { 'idempotency-key': string }; payload: Payload }) => Effect.Effect, ) => (payload: Payload, options: ContactsMutationOptions) => invoke(options, context, (client) => - endpoint(client)({ headers: { 'idempotency-key': options.idempotencyKey }, payload }), + endpoint(client)({ + headers: { 'idempotency-key': options.idempotencyKey }, + payload, + }), ); export const getContactsReadiness = ( diff --git a/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts b/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts index 1b3b26967..ffc60453e 100644 --- a/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { OrganizationEngagementProfileApi } from '../../shared/apis/organization-engagement-profile.ts'; import type { OrganizationEngagementProfileRequest } from '../../shared/apis/organization-engagement-profile.ts'; import { operationGateway } from './action-gateway.ts'; @@ -37,15 +38,16 @@ const organizationEngagementProfileClient = ( export const executeOrganizationEngagementProfileWithAuthorization = ( payload: OrganizationEngagementProfileRequest, - ...[ - credential, - requestCorrelation, - options = {}, - ]: OrganizationEngagementProfileAuthorizedInvocation + ...[credential, requestCorrelation, options = {}]: OrganizationEngagementProfileAuthorizedInvocation ) => organizationEngagementProfileClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.organizationEngagementProfile.execute({ headers: {}, params: {}, payload, query: {} }), + client.organizationEngagementProfile.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -54,10 +56,5 @@ export const executeOrganizationEngagementProfile = ( ...[requestCorrelation, options = {}]: OrganizationEngagementProfileOperationInvocation ) => operationGateway.invoke((credential) => - executeOrganizationEngagementProfileWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executeOrganizationEngagementProfileWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts index 2beefe12f..59d29367a 100644 --- a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts @@ -1,11 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - OperationContextUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { OperationContextUnavailable, defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import { OrganizationEngagementProfileRequestSchema, OrganizationEngagementProfileResponseSchema, @@ -25,9 +22,7 @@ interface Services { readonly find: (profileId: string) => ReturnType; } -const organizationProfileUnavailable = readUnavailable( - 'Contacts engagement persistence is temporarily unavailable', -); +const organizationProfileUnavailable = readUnavailable('Contacts engagement persistence is temporarily unavailable'); export const organizationEngagementProfileRead = defineRead( { @@ -64,8 +59,7 @@ export const organizationEngagementProfileRead = defineRead( ); } return Effect.succeed({ - find: (profileId) => - findOrganizationEngagementProfile(transaction, scope.tenantId, profileId), + find: (profileId) => findOrganizationEngagementProfile(transaction, scope.tenantId, profileId), }); }, (input) => ({ diff --git a/app/verticals/party-registry/src/api/parties-search-client.ts b/app/verticals/party-registry/src/api/parties-search-client.ts index 3ce4868c7..a05b7be62 100644 --- a/app/verticals/party-registry/src/api/parties-search-client.ts +++ b/app/verticals/party-registry/src/api/parties-search-client.ts @@ -2,6 +2,7 @@ // @ontos-contribution-kind search-provider import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartiesSearchApi } from '../../shared/apis/parties-search.ts'; import type { PartiesProviderRequest } from '../../shared/apis/parties-search.ts'; import { operationGateway } from './action-gateway.ts'; @@ -16,10 +17,7 @@ type PartiesSearchAuthorizedInvocation = readonly [ options?: PartiesSearchClientOptions, ]; -type PartiesSearchOperationInvocation = readonly [ - requestCorrelation: string, - options?: PartiesSearchClientOptions, -]; +type PartiesSearchOperationInvocation = readonly [requestCorrelation: string, options?: PartiesSearchClientOptions]; const partiesClient = ( credential: Redacted.Redacted, diff --git a/app/verticals/party-registry/src/api/party-command-client.ts b/app/verticals/party-registry/src/api/party-command-client.ts index 2c9d9c063..f91d3f330 100644 --- a/app/verticals/party-registry/src/api/party-command-client.ts +++ b/app/verticals/party-registry/src/api/party-command-client.ts @@ -1,15 +1,9 @@ -// @generated by OntOS Codesmith MicroVertical Command Client v1 -// @ontos-command-client-owner party-registry -import { - committedCreateResult, - PartyCreateRecoveryUnavailable, -} from '../../shared/domain/matching-contracts.ts'; -import { executePartyMatchDecisionWithAuthorization } from './party-match-decision-client.ts'; import type { GatewayContextClientOptions } from '@app/shared-contracts'; import { Effect, makeEffectHttpApiClient } from '@modern-js/plugin-bff/effect-client'; import type { HttpApi, HttpApiClient, HttpApiGroup } from '@modern-js/plugin-bff/effect-client'; import { Context, Redacted, Schema } from 'effect'; import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; + import { AddContactPointPayloadSchema, AddPartyOfficialIdentifierPayloadSchema, @@ -46,14 +40,13 @@ import type { UpdatePartyPayload, } from '../../shared/command-api.ts'; import { ActionInvocationIdSchema } from '../../shared/domain/correction-contracts.ts'; +// @generated by OntOS Codesmith MicroVertical Command Client v1 +// @ontos-command-client-owner party-registry +import { committedCreateResult, PartyCreateRecoveryUnavailable } from '../../shared/domain/matching-contracts.ts'; +import { executePartyMatchDecisionWithAuthorization } from './party-match-decision-client.ts'; -type CommandGroups = - typeof partyRegistryCommandsApi extends HttpApi.HttpApi ? Groups : never; -export type PartyCommandClient = HttpApiClient.Client< - Extract, - never, - never ->; +type CommandGroups = typeof partyRegistryCommandsApi extends HttpApi.HttpApi ? Groups : never; +export type PartyCommandClient = HttpApiClient.Client, never, never>; const correlationIdOption = 'correlationId' as const; const traceIdOption = 'traceId' as const; @@ -70,10 +63,7 @@ export interface PartyCommandOptions extends PartyCommandRecoveryOptions { } type PartyCommandInvocation = readonly [credential: string, options: PartyCommandOptions]; -type PartyCommandRecoveryInvocation = readonly [ - credential: string, - options: PartyCommandRecoveryOptions, -]; +type PartyCommandRecoveryInvocation = readonly [credential: string, options: PartyCommandRecoveryOptions]; interface PartyCommandRequestContextValue { readonly baseUrl: string | URL; @@ -88,10 +78,9 @@ const defaultPartyCommandRequestContext: PartyCommandRequestContextValue = { requestCorrelation: '', }; -const PartyCommandRequestContext = Context.Reference( - 'PartyCommandRequestContext', - { defaultValue: () => defaultPartyCommandRequestContext }, -); +const PartyCommandRequestContext = Context.Reference('PartyCommandRequestContext', { + defaultValue: () => defaultPartyCommandRequestContext, +}); const applyPartyCommandRequestContext = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* applyRequestContext() { @@ -152,12 +141,7 @@ const invokeAuthorized = ( gatewayAssertion: string, options: PartyCommandOptions, operation: (client: PartyCommandClient) => Effect.Effect, -) => - providePartyCommandRequestContext( - partyCommandClient.pipe(Effect.flatMap(operation)), - gatewayAssertion, - options, - ); +) => providePartyCommandRequestContext(partyCommandClient.pipe(Effect.flatMap(operation)), gatewayAssertion, options); // Defer gateway loading to the attempt: the gateway's ARES coordinator uses these exact commands. // Assertions are acquired afresh, never cached in a client, route loader, or module initializer. @@ -174,9 +158,7 @@ export const resolvePartyCommandCommitWithAuthorization = ( ...[credential, options]: PartyCommandRecoveryInvocation ) => providePartyCommandRequestContext( - partyCommandRecoveryClient.pipe( - Effect.flatMap((client) => client.partyCommandRecovery.resolve({ payload })), - ), + partyCommandRecoveryClient.pipe(Effect.flatMap((client) => client.partyCommandRecovery.resolve({ payload }))), credential, options, ); @@ -184,10 +166,7 @@ export const resolvePartyCommandCommitWithAuthorization = ( export const resolvePartyCommandCommit = ( payload: ResolvePartyCommandCommitPayload, options: PartyCommandRecoveryOptions, -) => - invoke(options, (authorization) => - resolvePartyCommandCommitWithAuthorization(payload, authorization, options), - ); +) => invoke(options, (authorization) => resolvePartyCommandCommitWithAuthorization(payload, authorization, options)); const defineCommand = ( operation: ( @@ -205,8 +184,8 @@ const defineCommand = ( return { authorized, execute }; }; -export const { authorized: addContactPointWithAuthorization, execute: addContactPoint } = - defineCommand((client, payload: AddContactPointPayload, headers) => +export const { authorized: addContactPointWithAuthorization, execute: addContactPoint } = defineCommand( + (client, payload: AddContactPointPayload, headers) => Schema.encodeUnknownEffect(AddContactPointPayloadSchema)(payload).pipe( Effect.flatMap((endpointPayload) => client.partyCommands.addContactPoint({ @@ -215,21 +194,19 @@ export const { authorized: addContactPointWithAuthorization, execute: addContact }), ), ), - ); +); -export const { - authorized: addPartyOfficialIdentifierWithAuthorization, - execute: addPartyOfficialIdentifier, -} = defineCommand((client, payload: AddPartyOfficialIdentifierPayload, headers) => - Schema.encodeUnknownEffect(AddPartyOfficialIdentifierPayloadSchema)(payload).pipe( - Effect.flatMap((endpointPayload) => - client.partyCommands.addPartyOfficialIdentifier({ - headers, - payload: endpointPayload, - }), +export const { authorized: addPartyOfficialIdentifierWithAuthorization, execute: addPartyOfficialIdentifier } = + defineCommand((client, payload: AddPartyOfficialIdentifierPayload, headers) => + Schema.encodeUnknownEffect(AddPartyOfficialIdentifierPayloadSchema)(payload).pipe( + Effect.flatMap((endpointPayload) => + client.partyCommands.addPartyOfficialIdentifier({ + headers, + payload: endpointPayload, + }), + ), ), - ), -); + ); export const { authorized: archivePartyWithAuthorization, execute: archiveParty } = defineCommand( (client, payload: ArchivePartyPayload, headers) => @@ -239,18 +216,16 @@ export const { authorized: archivePartyWithAuthorization, execute: archiveParty }), ); -export const { - authorized: confirmDuplicatePartiesWithAuthorization, - execute: confirmDuplicateParties, -} = defineCommand((client, payload: ConfirmDuplicatePartiesPayload, headers) => - client.partyCommands.confirmDuplicateParties({ - headers, - payload, - }), +export const { authorized: confirmDuplicatePartiesWithAuthorization, execute: confirmDuplicateParties } = defineCommand( + (client, payload: ConfirmDuplicatePartiesPayload, headers) => + client.partyCommands.confirmDuplicateParties({ + headers, + payload, + }), ); -export const { authorized: correctPartyFactWithAuthorization, execute: correctPartyFact } = - defineCommand((client, payload: CorrectPartyFactPayload, headers) => { +export const { authorized: correctPartyFactWithAuthorization, execute: correctPartyFact } = defineCommand( + (client, payload: CorrectPartyFactPayload, headers) => { // HttpApi retains an overload for each union member; narrow without weakening its schema. if (payload.factKind !== 'RELATIONSHIP') { return client.partyCommands.correctPartyFact({ headers, payload }); @@ -259,40 +234,39 @@ export const { authorized: correctPartyFactWithAuthorization, execute: correctPa return client.partyCommands.correctPartyFact({ headers, payload }); } return client.partyCommands.correctPartyFact({ headers, payload }); - }); + }, +); -export const { authorized: counterpartyCreateWithAuthorization, execute: counterpartyCreate } = - defineCommand((client, payload: CounterpartyCreatePayload, headers) => +export const { authorized: counterpartyCreateWithAuthorization, execute: counterpartyCreate } = defineCommand( + (client, payload: CounterpartyCreatePayload, headers) => client.partyCommands.counterpartyCreate({ headers, payload, }), - ); +); -export const { authorized: counterpartyRoleAddWithAuthorization, execute: counterpartyRoleAdd } = - defineCommand((client, payload: CounterpartyRoleAddPayload, headers) => +export const { authorized: counterpartyRoleAddWithAuthorization, execute: counterpartyRoleAdd } = defineCommand( + (client, payload: CounterpartyRoleAddPayload, headers) => client.partyCommands.counterpartyRoleAdd({ headers, payload, }), - ); +); -export const { authorized: counterpartyRoleEndWithAuthorization, execute: counterpartyRoleEnd } = - defineCommand((client, payload: CounterpartyRoleEndPayload, headers) => +export const { authorized: counterpartyRoleEndWithAuthorization, execute: counterpartyRoleEnd } = defineCommand( + (client, payload: CounterpartyRoleEndPayload, headers) => client.partyCommands.counterpartyRoleEnd({ headers, payload, }), - ); +); -export const { - authorized: createPartyRelationshipWithAuthorization, - execute: createPartyRelationship, -} = defineCommand((client, payload: CreatePartyRelationshipPayload, headers) => - client.partyCommands.createPartyRelationship({ - headers, - payload, - }), +export const { authorized: createPartyRelationshipWithAuthorization, execute: createPartyRelationship } = defineCommand( + (client, payload: CreatePartyRelationshipPayload, headers) => + client.partyCommands.createPartyRelationship({ + headers, + payload, + }), ); export const { authorized: createPartyWithAuthorization, execute: createParty } = defineCommand( @@ -307,42 +281,38 @@ export const { authorized: createPartyWithAuthorization, execute: createParty } ), ); -export const { - authorized: dismissDuplicateCandidateWithAuthorization, - execute: dismissDuplicateCandidate, -} = defineCommand((client, payload: DismissDuplicateCandidatePayload, headers) => - client.partyCommands.dismissDuplicateCandidate({ - headers, - payload, - }), -); - -export const { authorized: endContactPointWithAuthorization, execute: endContactPoint } = - defineCommand((client, payload: EndContactPointPayload, headers) => - client.partyCommands.endContactPoint({ +export const { authorized: dismissDuplicateCandidateWithAuthorization, execute: dismissDuplicateCandidate } = + defineCommand((client, payload: DismissDuplicateCandidatePayload, headers) => + client.partyCommands.dismissDuplicateCandidate({ headers, payload, }), ); -export const { - authorized: endPartyOfficialIdentifierWithAuthorization, - execute: endPartyOfficialIdentifier, -} = defineCommand((client, payload: EndPartyOfficialIdentifierPayload, headers) => - client.partyCommands.endPartyOfficialIdentifier({ - headers, - payload, - }), +export const { authorized: endContactPointWithAuthorization, execute: endContactPoint } = defineCommand( + (client, payload: EndContactPointPayload, headers) => + client.partyCommands.endContactPoint({ + headers, + payload, + }), ); -export const { authorized: endPartyRelationshipWithAuthorization, execute: endPartyRelationship } = - defineCommand((client, payload: EndPartyRelationshipPayload, headers) => - client.partyCommands.endPartyRelationship({ +export const { authorized: endPartyOfficialIdentifierWithAuthorization, execute: endPartyOfficialIdentifier } = + defineCommand((client, payload: EndPartyOfficialIdentifierPayload, headers) => + client.partyCommands.endPartyOfficialIdentifier({ headers, payload, }), ); +export const { authorized: endPartyRelationshipWithAuthorization, execute: endPartyRelationship } = defineCommand( + (client, payload: EndPartyRelationshipPayload, headers) => + client.partyCommands.endPartyRelationship({ + headers, + payload, + }), +); + export const { authorized: markDuplicateCandidateNeedsEvidenceWithAuthorization, execute: markDuplicateCandidateNeedsEvidence, @@ -361,13 +331,13 @@ export const { authorized: matchPartyWithAuthorization, execute: matchParty } = }), ); -export const { authorized: requestSearchRebuildWithAuthorization, execute: requestSearchRebuild } = - defineCommand((client, payload: RequestSearchRebuildPayload, headers) => +export const { authorized: requestSearchRebuildWithAuthorization, execute: requestSearchRebuild } = defineCommand( + (client, payload: RequestSearchRebuildPayload, headers) => client.partyCommands.requestSearchRebuild({ headers, payload, }), - ); +); export const { authorized: resolveDuplicateCandidateCreateWithAuthorization, @@ -379,50 +349,44 @@ export const { }), ); -export const { - authorized: resolveDuplicateCandidateMatchWithAuthorization, - execute: resolveDuplicateCandidateMatch, -} = defineCommand((client, payload: ResolveDuplicateCandidateMatchPayload, headers) => - client.partyCommands.resolveDuplicateCandidateMatch({ - headers, - payload, - }), -); +export const { authorized: resolveDuplicateCandidateMatchWithAuthorization, execute: resolveDuplicateCandidateMatch } = + defineCommand((client, payload: ResolveDuplicateCandidateMatchPayload, headers) => + client.partyCommands.resolveDuplicateCandidateMatch({ + headers, + payload, + }), + ); -export const { authorized: unarchivePartyWithAuthorization, execute: unarchiveParty } = - defineCommand((client, payload: UnarchivePartyPayload, headers) => +export const { authorized: unarchivePartyWithAuthorization, execute: unarchiveParty } = defineCommand( + (client, payload: UnarchivePartyPayload, headers) => client.partyCommands.unarchiveParty({ headers, payload, }), - ); +); -export const { authorized: updateContactPointWithAuthorization, execute: updateContactPoint } = - defineCommand((client, payload: UpdateContactPointPayload, headers) => +export const { authorized: updateContactPointWithAuthorization, execute: updateContactPoint } = defineCommand( + (client, payload: UpdateContactPointPayload, headers) => client.partyCommands.updateContactPoint({ headers, payload, }), - ); - -export const { - authorized: updatePartyOfficialIdentifierWithAuthorization, - execute: updatePartyOfficialIdentifier, -} = defineCommand((client, payload: UpdatePartyOfficialIdentifierPayload, headers) => - client.partyCommands.updatePartyOfficialIdentifier({ - headers, - payload, - }), ); -export const { - authorized: updatePartyRelationshipWithAuthorization, - execute: updatePartyRelationship, -} = defineCommand((client, payload: UpdatePartyRelationshipPayload, headers) => - client.partyCommands.updatePartyRelationship({ - headers, - payload, - }), +export const { authorized: updatePartyOfficialIdentifierWithAuthorization, execute: updatePartyOfficialIdentifier } = + defineCommand((client, payload: UpdatePartyOfficialIdentifierPayload, headers) => + client.partyCommands.updatePartyOfficialIdentifier({ + headers, + payload, + }), + ); + +export const { authorized: updatePartyRelationshipWithAuthorization, execute: updatePartyRelationship } = defineCommand( + (client, payload: UpdatePartyRelationshipPayload, headers) => + client.partyCommands.updatePartyRelationship({ + headers, + payload, + }), ); export const { authorized: updatePartyWithAuthorization, execute: updateParty } = defineCommand( @@ -438,18 +402,13 @@ export const { authorized: updatePartyWithAuthorization, execute: updateParty } ); /** Resolve commit before reading the durable result; never resubmit Create during recovery. */ -export const recoverPartyCreate = ( - payload: ResolvePartyCommandCommitPayload, - options: PartyCommandRecoveryOptions, -) => +export const recoverPartyCreate = (payload: ResolvePartyCommandCommitPayload, options: PartyCommandRecoveryOptions) => Effect.gen(function* recoverCreate() { const resolution = yield* resolvePartyCommandCommit(payload, options); if (resolution.state !== 'COMMITTED') { return { _tag: 'PartyCreateRecoveryPending' as const, resolution }; } - const actionInvocationId = yield* Schema.decodeUnknownEffect(ActionInvocationIdSchema)( - payload.invocationId, - ); + const actionInvocationId = yield* Schema.decodeEffect(ActionInvocationIdSchema)(payload.invocationId); const decision = yield* invoke(options, (authorization) => executePartyMatchDecisionWithAuthorization( { actionInvocationId }, diff --git a/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts b/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts index 36417d099..98f151f0f 100644 --- a/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyContactPointDetailApi } from '../../shared/apis/party-contact-point-detail.ts'; import type { PartyContactPointDetailRequest } from '../../shared/apis/party-contact-point-detail.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePartyContactPointDetailWithAuthorization = ( ) => partyContactPointDetailClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyContactPointDetail.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyContactPointDetail.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -50,10 +56,5 @@ export const executePartyContactPointDetail = ( ...[requestCorrelation, options = {}]: PartyContactPointDetailOperationInvocation ) => operationGateway.invoke((credential) => - executePartyContactPointDetailWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executePartyContactPointDetailWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts b/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts index 89f3f42e2..06594e830 100644 --- a/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts @@ -7,12 +7,13 @@ import { } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Option } from 'effect'; + import { PartyContactPointDetailRequestSchema, PartyContactPointDetailResponseSchema, } from '../../shared/apis/party-contact-point-detail.ts'; -import type { PartyContactPoint } from '../../shared/domain/contact-point.ts'; import type { PartyContactPointPersistenceUnavailable } from '../../shared/domain/contact-point-errors.ts'; +import type { PartyContactPoint } from '../../shared/domain/contact-point.ts'; import { findPartyContactPointRecord } from '../services/party-contact-point-persistence.service.ts'; const partyContactPointDetailEntrypoint = defineTenantModuleEntrypoint({ @@ -65,13 +66,15 @@ export const partyContactPointDetailRead = defineRead( reason: 'The requested Party Contact Point does not exist', }), ) - : Effect.succeed({ evidence: { resultCount: 1 }, result: contactPoint.value }), + : Effect.succeed({ + evidence: { resultCount: 1 }, + result: contactPoint.value, + }), ), ), (transaction, scope) => Effect.succeed({ - find: (contactPointId: string) => - findPartyContactPointRecord(transaction, scope, contactPointId), + find: (contactPointId: string) => findPartyContactPointRecord(transaction, scope, contactPointId), }), () => ({ kind: 'tenant', permission: 'read_party_identity' }), ); diff --git a/app/verticals/party-registry/src/api/party-contact-points-client.ts b/app/verticals/party-registry/src/api/party-contact-points-client.ts index 10c9458f7..91d580b47 100644 --- a/app/verticals/party-registry/src/api/party-contact-points-client.ts +++ b/app/verticals/party-registry/src/api/party-contact-points-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyContactPointsApi } from '../../shared/apis/party-contact-points.ts'; import type { PartyContactPointsRequest } from '../../shared/apis/party-contact-points.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePartyContactPointsWithAuthorization = ( ) => partyContactPointsClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyContactPoints.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyContactPoints.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/party-contact-points.read.ts b/app/verticals/party-registry/src/api/party-contact-points.read.ts index 34369ffd5..c3846db8e 100644 --- a/app/verticals/party-registry/src/api/party-contact-points.read.ts +++ b/app/verticals/party-registry/src/api/party-contact-points.read.ts @@ -1,19 +1,13 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { ReadHandlerUnavailable, defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PartyContactPointsRequestSchema, PartyContactPointsResponseSchema, } from '../../shared/apis/party-contact-points.ts'; -import type { - PartyContactPointsRequest, - PartyContactPointsResponse, -} from '../../shared/apis/party-contact-points.ts'; +import type { PartyContactPointsRequest, PartyContactPointsResponse } from '../../shared/apis/party-contact-points.ts'; import type { PartyContactPointPersistenceUnavailable } from '../../shared/domain/contact-point-errors.ts'; import { listPartyContactPointRecords } from '../services/party-contact-point-persistence.service.ts'; diff --git a/app/verticals/party-registry/src/api/party-correction-client.ts b/app/verticals/party-registry/src/api/party-correction-client.ts index 5cb1c3b2c..575c331be 100644 --- a/app/verticals/party-registry/src/api/party-correction-client.ts +++ b/app/verticals/party-registry/src/api/party-correction-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyCorrectionApi } from '../../shared/apis/party-correction.ts'; import type { PartyCorrectionRequest } from '../../shared/apis/party-correction.ts'; import { operationGateway } from './action-gateway.ts'; @@ -15,10 +16,7 @@ type PartyCorrectionAuthorizedInvocation = readonly [ options?: PartyCorrectionClientOptions, ]; -type PartyCorrectionOperationInvocation = readonly [ - requestCorrelation: string, - options?: PartyCorrectionClientOptions, -]; +type PartyCorrectionOperationInvocation = readonly [requestCorrelation: string, options?: PartyCorrectionClientOptions]; const partyCorrectionClient = ( credential: Redacted.Redacted, @@ -41,7 +39,12 @@ export const executePartyCorrectionWithAuthorization = ( ) => partyCorrectionClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyCorrection.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyCorrection.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/party-correction.read.ts b/app/verticals/party-registry/src/api/party-correction.read.ts index bd4e79d31..a660287ae 100644 --- a/app/verticals/party-registry/src/api/party-correction.read.ts +++ b/app/verticals/party-registry/src/api/party-correction.read.ts @@ -2,10 +2,8 @@ import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; -import { - PartyCorrectionRequestSchema, - PartyCorrectionResponseSchema, -} from '../../shared/apis/party-correction.ts'; + +import { PartyCorrectionRequestSchema, PartyCorrectionResponseSchema } from '../../shared/apis/party-correction.ts'; import { findPartyCorrection } from '../services/party-correction.service.ts'; import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; @@ -19,8 +17,7 @@ const partyCorrectionEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly find: (correctionId: string) => ReturnType; } -export const partyCorrectionPermissionTarget = () => - ({ kind: 'tenant', permission: 'review_party_identity' }) as const; +export const partyCorrectionPermissionTarget = () => ({ kind: 'tenant', permission: 'review_party_identity' }) as const; const unavailable = readUnavailable('Party Correction persistence is unavailable'); export const partyCorrectionRead = defineRead( { @@ -49,8 +46,7 @@ export const partyCorrectionRead = defineRead( ), (transaction, scope) => Effect.succeed({ - find: (correctionId: string) => - findPartyCorrection(transaction, scope.tenantId, correctionId), + find: (correctionId: string) => findPartyCorrection(transaction, scope.tenantId, correctionId), }), partyCorrectionPermissionTarget, ); diff --git a/app/verticals/party-registry/src/api/party-detail-client.ts b/app/verticals/party-registry/src/api/party-detail-client.ts index 535cd8866..3e73b6098 100644 --- a/app/verticals/party-registry/src/api/party-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-detail-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyDetailApi } from '../../shared/apis/party-detail.ts'; import type { PartyDetailRequest } from '../../shared/apis/party-detail.ts'; import { operationGateway } from './action-gateway.ts'; @@ -15,10 +16,7 @@ type PartyDetailAuthorizedInvocation = readonly [ options?: PartyDetailClientOptions, ]; -type PartyDetailOperationInvocation = readonly [ - requestCorrelation: string, - options?: PartyDetailClientOptions, -]; +type PartyDetailOperationInvocation = readonly [requestCorrelation: string, options?: PartyDetailClientOptions]; const partyDetailClient = ( credential: Redacted.Redacted, @@ -41,7 +39,12 @@ export const executePartyDetailWithAuthorization = ( ) => partyDetailClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyDetail.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyDetail.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/party-detail.read.ts b/app/verticals/party-registry/src/api/party-detail.read.ts index 5a47dc1f2..33ffee59b 100644 --- a/app/verticals/party-registry/src/api/party-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-detail.read.ts @@ -7,10 +7,8 @@ import { } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Match, Option } from 'effect'; -import { - PartyDetailRequestSchema, - PartyDetailResponseSchema, -} from '../../shared/apis/party-detail.ts'; + +import { PartyDetailRequestSchema, PartyDetailResponseSchema } from '../../shared/apis/party-detail.ts'; import type { PartyDetailRequest, PartyDetailResponse } from '../../shared/apis/party-detail.ts'; // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- This pure helper constructs a ResourceRef, not an Effect service. import { makePartyRef } from '../../shared/domain/identity-contracts.ts'; @@ -19,10 +17,10 @@ import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias- import type { PartyRef } from '../../shared/resources/party.ts'; import { resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; import type { ResolvedPartyAlias } from '../merge/party-alias-resolution.service.ts'; -import { findPartyRecord } from '../services/party-identity-persistence.service.ts'; -import type { PartyLookup } from '../services/party-identity-persistence.service.ts'; import { findPartyDetailAssertions } from '../services/party-detail-persistence.service.ts'; import type { PartyDetailAssertions } from '../services/party-detail-persistence.service.ts'; +import { findPartyRecord } from '../services/party-identity-persistence.service.ts'; +import type { PartyLookup } from '../services/party-identity-persistence.service.ts'; interface Services { readonly facts: ( @@ -30,9 +28,7 @@ interface Services { includeFactHistory: boolean, ) => Effect.Effect; readonly find: (partyId: string) => Effect.Effect; - readonly resolve: ( - partyId: string, - ) => Effect.Effect; + readonly resolve: (partyId: string) => Effect.Effect; } const notFound = () => @@ -46,7 +42,10 @@ const unavailable = (cause?: unknown) => { reason: 'Party identity or Alias resolution is temporarily unavailable', }); if (cause !== undefined) { - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); + Object.defineProperty(failure, 'cause', { + configurable: true, + value: cause, + }); } return failure; }; @@ -67,9 +66,7 @@ export const readPartyDetailFromServices = Effect.fn('PartyDetailRead.readPartyD Match.value(error).pipe( Match.tags({ PartyAliasResolutionBrokenChain: (failure) => - failure.missingPartyId === requestedPartyRef.resourceId - ? notFound() - : unavailable(failure), + failure.missingPartyId === requestedPartyRef.resourceId ? notFound() : unavailable(failure), PartyAliasResolutionCrossTenant: unavailable, PartyAliasResolutionCycle: unavailable, PartyAliasResolutionUnavailable: unavailable, @@ -78,9 +75,7 @@ export const readPartyDetailFromServices = Effect.fn('PartyDetailRead.readPartyD ), ), ); - const found = yield* services - .find(resolved.canonicalPartyId) - .pipe(Effect.mapError(unavailable)); + const found = yield* services.find(resolved.canonicalPartyId).pipe(Effect.mapError(unavailable)); const party = Match.value(found).pipe( Match.tag('found', ({ value }) => Option.some(value)), Match.tag('not_found', () => Option.none()), @@ -116,10 +111,7 @@ const partyDetailEntrypoint = defineTenantModuleEntrypoint({ export const partyDetailPermissionTarget = (input: PartyDetailRequest) => ({ kind: 'tenant' as const, - permission: - input.includeFactHistory === true - ? ('review_party_identity' as const) - : ('read_party_identity' as const), + permission: input.includeFactHistory === true ? ('review_party_identity' as const) : ('read_party_identity' as const), }); export const partyDetailRead = defineRead( diff --git a/app/verticals/party-registry/src/api/party-match-client.ts b/app/verticals/party-registry/src/api/party-match-client.ts index 26fb2264c..8c1549b10 100644 --- a/app/verticals/party-registry/src/api/party-match-client.ts +++ b/app/verticals/party-registry/src/api/party-match-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyMatchApi } from '../../shared/apis/party-match.ts'; import type { PartyMatchRequest } from '../../shared/apis/party-match.ts'; import { operationGateway } from './action-gateway.ts'; @@ -15,10 +16,7 @@ type PartyMatchAuthorizedInvocation = readonly [ options?: PartyMatchClientOptions, ]; -type PartyMatchOperationInvocation = readonly [ - requestCorrelation: string, - options?: PartyMatchClientOptions, -]; +type PartyMatchOperationInvocation = readonly [requestCorrelation: string, options?: PartyMatchClientOptions]; const partyMatchClient = ( credential: Redacted.Redacted, @@ -40,9 +38,7 @@ export const executePartyMatchWithAuthorization = ( ...[credential, requestCorrelation, options = {}]: PartyMatchAuthorizedInvocation ) => partyMatchClient(Redacted.make(credential), requestCorrelation, options).pipe( - Effect.flatMap((client) => - client.partyMatch.execute({ headers: {}, params: {}, payload, query: {} }), - ), + Effect.flatMap((client) => client.partyMatch.execute({ headers: {}, params: {}, payload, query: {} })), ); export const executePartyMatch = ( diff --git a/app/verticals/party-registry/src/api/party-match-decision-client.ts b/app/verticals/party-registry/src/api/party-match-decision-client.ts index ec632d968..26420c63f 100644 --- a/app/verticals/party-registry/src/api/party-match-decision-client.ts +++ b/app/verticals/party-registry/src/api/party-match-decision-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyMatchDecisionApi } from '../../shared/apis/party-match-decision.ts'; import type { PartyMatchDecisionRequest } from '../../shared/apis/party-match-decision.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePartyMatchDecisionWithAuthorization = ( ) => partyMatchDecisionClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyMatchDecision.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyMatchDecision.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/party-match-decision.read.ts b/app/verticals/party-registry/src/api/party-match-decision.read.ts index 30fa4b7b1..21a88fef5 100644 --- a/app/verticals/party-registry/src/api/party-match-decision.read.ts +++ b/app/verticals/party-registry/src/api/party-match-decision.read.ts @@ -7,6 +7,7 @@ import { } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; + import { PartyMatchDecisionRequestSchema, PartyMatchDecisionResponseSchema, @@ -71,8 +72,11 @@ export const partyMatchDecisionRead = defineRead( Effect.flatMap((found) => Match.value(found).pipe( Match.tag('found', ({ value }) => - Schema.decodeUnknownEffect(PartyMatchDecisionResponseSchema)(value).pipe( - Effect.map((result) => ({ evidence: { resultCount: 1 }, result })), + Schema.decodeEffect(PartyMatchDecisionResponseSchema)(value).pipe( + Effect.map((result) => ({ + evidence: { resultCount: 1 }, + result, + })), Effect.mapError(matchDecisionUnavailable), ), ), diff --git a/app/verticals/party-registry/src/api/party-match.read.ts b/app/verticals/party-registry/src/api/party-match.read.ts index b6d3077e8..284fc8a2c 100644 --- a/app/verticals/party-registry/src/api/party-match.read.ts +++ b/app/verticals/party-registry/src/api/party-match.read.ts @@ -2,10 +2,8 @@ import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; -import { - PartyMatchRequestSchema, - PartyMatchResponseSchema, -} from '../../shared/apis/party-match.ts'; + +import { PartyMatchRequestSchema, PartyMatchResponseSchema } from '../../shared/apis/party-match.ts'; import type { PartyCandidate } from '../../shared/domain/identity-contracts.ts'; import { previewPartyMatch } from '../services/party-matching-persistence.service.ts'; import { readUnavailable } from './read-outcome.ts'; @@ -49,8 +47,7 @@ export const partyMatchRead = defineRead( ), (transaction, scope) => Effect.succeed({ - preview: (candidate: PartyCandidate) => - previewPartyMatch(transaction, scope.tenantId, candidate), + preview: (candidate: PartyCandidate) => previewPartyMatch(transaction, scope.tenantId, candidate), }), () => ({ kind: 'tenant', permission: 'manage_party_identity' }), ); diff --git a/app/verticals/party-registry/src/api/party-merge-readiness-client.ts b/app/verticals/party-registry/src/api/party-merge-readiness-client.ts index cfe6a0171..725f7104c 100644 --- a/app/verticals/party-registry/src/api/party-merge-readiness-client.ts +++ b/app/verticals/party-registry/src/api/party-merge-readiness-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyMergeReadinessApi } from '../../shared/apis/party-merge-readiness.ts'; import type { PartyMergeReadinessRequest } from '../../shared/apis/party-merge-readiness.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePartyMergeReadinessWithAuthorization = ( ) => partyMergeReadinessClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyMergeReadiness.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyMergeReadiness.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); diff --git a/app/verticals/party-registry/src/api/party-merge-readiness.read.ts b/app/verticals/party-registry/src/api/party-merge-readiness.read.ts index b974127f9..276640f9f 100644 --- a/app/verticals/party-registry/src/api/party-merge-readiness.read.ts +++ b/app/verticals/party-registry/src/api/party-merge-readiness.read.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PartyMergeReadinessRequestSchema, PartyMergeReadinessResponseSchema, diff --git a/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts b/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts index 9bf236cd1..d708ebd08 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyOfficialIdentifierDetailApi } from '../../shared/apis/party-official-identifier-detail.ts'; import type { PartyOfficialIdentifierDetailRequest } from '../../shared/apis/party-official-identifier-detail.ts'; import { operationGateway } from './action-gateway.ts'; @@ -37,15 +38,16 @@ const partyOfficialIdentifierDetailClient = ( export const executePartyOfficialIdentifierDetailWithAuthorization = ( payload: PartyOfficialIdentifierDetailRequest, - ...[ - credential, - requestCorrelation, - options = {}, - ]: PartyOfficialIdentifierDetailAuthorizedInvocation + ...[credential, requestCorrelation, options = {}]: PartyOfficialIdentifierDetailAuthorizedInvocation ) => partyOfficialIdentifierDetailClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyOfficialIdentifierDetail.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyOfficialIdentifierDetail.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -54,10 +56,5 @@ export const executePartyOfficialIdentifierDetail = ( ...[requestCorrelation, options = {}]: PartyOfficialIdentifierDetailOperationInvocation ) => operationGateway.invoke((credential) => - executePartyOfficialIdentifierDetailWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executePartyOfficialIdentifierDetailWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts index 7cde3a4b9..0f0dcc7bf 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts @@ -2,6 +2,7 @@ import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PartyOfficialIdentifierDetailRequestSchema, PartyOfficialIdentifierDetailResponseSchema, @@ -47,8 +48,7 @@ export const partyOfficialIdentifierDetailRead = defineRead( ), (transaction, scope) => Effect.succeed({ - find: (identifierId: string) => - findOfficialIdentifierRecord(transaction, scope.tenantId, identifierId), + find: (identifierId: string) => findOfficialIdentifierRecord(transaction, scope.tenantId, identifierId), }), () => ({ kind: 'tenant', permission: 'read_party_identity' }), ); diff --git a/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts b/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts index 58803ffda..c72004180 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyOfficialIdentifierHistoryApi } from '../../shared/apis/party-official-identifier-history.ts'; import type { PartyOfficialIdentifierHistoryRequest } from '../../shared/apis/party-official-identifier-history.ts'; import { operationGateway } from './action-gateway.ts'; @@ -37,11 +38,7 @@ const partyOfficialIdentifierHistoryClient = ( export const executePartyOfficialIdentifierHistoryWithAuthorization = ( payload: PartyOfficialIdentifierHistoryRequest, - ...[ - credential, - requestCorrelation, - options = {}, - ]: PartyOfficialIdentifierHistoryAuthorizedInvocation + ...[credential, requestCorrelation, options = {}]: PartyOfficialIdentifierHistoryAuthorizedInvocation ) => partyOfficialIdentifierHistoryClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => @@ -59,10 +56,5 @@ export const executePartyOfficialIdentifierHistory = ( ...[requestCorrelation, options = {}]: PartyOfficialIdentifierHistoryOperationInvocation ) => operationGateway.invoke((credential) => - executePartyOfficialIdentifierHistoryWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executePartyOfficialIdentifierHistoryWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts index d6d486994..cbb50082b 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts @@ -2,6 +2,7 @@ import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PartyOfficialIdentifierHistoryRequestSchema, PartyOfficialIdentifierHistoryResponseSchema, @@ -40,12 +41,14 @@ export const partyOfficialIdentifierHistoryRead = defineRead( (input, context: ReadHandlerContext) => context.services.list(input.partyRef.resourceId).pipe( Effect.mapError(unavailable), - Effect.map((items) => ({ evidence: { resultCount: items.length }, result: { items } })), + Effect.map((items) => ({ + evidence: { resultCount: items.length }, + result: { items }, + })), ), (transaction, scope) => Effect.succeed({ - list: (partyId: string) => - listOfficialIdentifierHistory(transaction, scope.tenantId, partyId), + list: (partyId: string) => listOfficialIdentifierHistory(transaction, scope.tenantId, partyId), }), () => ({ kind: 'tenant', permission: 'read_party_identity' }), ); diff --git a/app/verticals/party-registry/src/api/party-registry-client.ts b/app/verticals/party-registry/src/api/party-registry-client.ts index 0f9f7d825..c75205248 100644 --- a/app/verticals/party-registry/src/api/party-registry-client.ts +++ b/app/verticals/party-registry/src/api/party-registry-client.ts @@ -14,14 +14,14 @@ import { executePartyContactPointDetail } from './party-contact-point-detail-cli import { executePartyContactPoints } from './party-contact-points-client.ts'; import { executePartyCorrection } from './party-correction-client.ts'; import { executePartyDetail } from './party-detail-client.ts'; -import { executePartyMatchDecision } from './party-match-decision-client.ts'; import { executePartyMatch } from './party-match-client.ts'; +import { executePartyMatchDecision } from './party-match-decision-client.ts'; import { executePartyMergeReadiness } from './party-merge-readiness-client.ts'; import { executePartyOfficialIdentifierDetail } from './party-official-identifier-detail-client.ts'; import { executePartyOfficialIdentifierHistory } from './party-official-identifier-history-client.ts'; -import { executePartyRelationshipDetail } from './party-relationship-detail-client.ts'; import { createPartyRegistryHttpClient } from './party-registry-http-client.ts'; import type { PartyRegistryHttpClientOptions } from './party-registry-http-client.ts'; +import { executePartyRelationshipDetail } from './party-relationship-detail-client.ts'; export * from './ares-lookup-client.ts'; export * from './counterparties-search-client.ts'; @@ -83,11 +83,7 @@ export interface PartyRegistryClient { export type PartyRegistryClientError = HttpClientError.HttpClientError | Schema.SchemaError; -export type PartyRegistryClientEffect = Effect.Effect< - Success, - PartyRegistryClientError, - never ->; +export type PartyRegistryClientEffect = Effect.Effect; export type PartyRegistryClientOptions = PartyRegistryHttpClientOptions; diff --git a/app/verticals/party-registry/src/api/party-registry-http-client.ts b/app/verticals/party-registry/src/api/party-registry-http-client.ts index f9ae00076..d5b8b2492 100644 --- a/app/verticals/party-registry/src/api/party-registry-http-client.ts +++ b/app/verticals/party-registry/src/api/party-registry-http-client.ts @@ -1,25 +1,16 @@ import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; -import type { - EffectBffClientOptions, - EffectBffRequestContext, -} from '@app/shared-contracts/client-runtime'; +import type { EffectBffClientOptions, EffectBffRequestContext } from '@app/shared-contracts/client-runtime'; import { Effect } from '@modern-js/plugin-bff/effect-client'; -import type { - HttpApi, - HttpApiClient, - HttpApiGroup, - Schema, -} from '@modern-js/plugin-bff/effect-client'; +import type { HttpApi, HttpApiClient, HttpApiGroup, Schema } from '@modern-js/plugin-bff/effect-client'; import { Redacted } from 'effect'; + import { partyRegistryApi, partyRegistryApiContract } from '../../shared/api.ts'; import type { OperationContext } from '../../shared/api.ts'; type PartyRegistryApiGroups = typeof partyRegistryApi extends HttpApi.HttpApi ? Groups : never; -export type PartyRegistryHttpClient = HttpApiClient.Client< - Extract ->; +export type PartyRegistryHttpClient = HttpApiClient.Client>; const traceparentOption = 'traceparent' as const; const requestCorrelationHeaderName = 'x-correlation-id' as const; @@ -74,9 +65,7 @@ export const authenticatePartyRegistryHttpRequest = ( requestTrace, }; -const effectBffClientOptions = ( - context: PartyRegistryHttpRequestContextValue, -): EffectBffClientOptions => { +const effectBffClientOptions = (context: PartyRegistryHttpRequestContextValue): EffectBffClientOptions => { const requestCorrelationHeader = context.requestCorrelationHeader ?? requestCorrelationHeaderName; const transportHeaders = context.credential === undefined || context.requestCorrelation === undefined @@ -91,7 +80,9 @@ const effectBffClientOptions = ( Object.assign(requestContext, { locale: context.requestLocale }); } if (context.operationContext !== undefined) { - Object.assign(requestContext, { operationContext: context.operationContext }); + Object.assign(requestContext, { + operationContext: context.operationContext, + }); } if (context.requestTraceparent !== undefined) { Object.assign(requestContext, { traceparent: context.requestTraceparent }); diff --git a/app/verticals/party-registry/src/api/party-relationship-detail-client.ts b/app/verticals/party-registry/src/api/party-relationship-detail-client.ts index 1552534c1..9f2b8ea3c 100644 --- a/app/verticals/party-registry/src/api/party-relationship-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-relationship-detail-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PartyRelationshipDetailApi } from '../../shared/apis/party-relationship-detail.ts'; import type { PartyRelationshipDetailRequest } from '../../shared/apis/party-relationship-detail.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePartyRelationshipDetailWithAuthorization = ( ) => partyRelationshipDetailClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.partyRelationshipDetail.execute({ headers: {}, params: {}, payload, query: {} }), + client.partyRelationshipDetail.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -50,10 +56,5 @@ export const executePartyRelationshipDetail = ( ...[requestCorrelation, options = {}]: PartyRelationshipDetailOperationInvocation ) => operationGateway.invoke((credential) => - executePartyRelationshipDetailWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executePartyRelationshipDetailWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/party-relationship-detail.read.ts b/app/verticals/party-registry/src/api/party-relationship-detail.read.ts index d90f9789d..9fbc38293 100644 --- a/app/verticals/party-registry/src/api/party-relationship-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-relationship-detail.read.ts @@ -7,6 +7,7 @@ import { } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect, Option } from 'effect'; + import { PartyRelationshipDetailRequestSchema, PartyRelationshipDetailResponseSchema, @@ -39,10 +40,7 @@ const relationshipUnavailable = (cause: unknown) => interface Services { readonly find: ( relationshipId: string, - ) => Effect.Effect< - Option.Option, - PartyRelationshipPersistenceUnavailable - >; + ) => Effect.Effect, PartyRelationshipPersistenceUnavailable>; } export const partyRelationshipDetailRead = defineRead( @@ -86,9 +84,7 @@ export const partyRelationshipDetailRead = defineRead( (transaction, scope) => Effect.succeed({ find: (relationshipId: string) => - findPartyRelationshipRecord(transaction, scope.tenantId, relationshipId).pipe( - Effect.map(Option.fromNullishOr), - ), + findPartyRelationshipRecord(transaction, scope.tenantId, relationshipId).pipe(Effect.map(Option.fromNullishOr)), }), () => ({ kind: 'tenant', permission: 'read_party_identity' }), ); diff --git a/app/verticals/party-registry/src/api/person-engagement-profile-client.ts b/app/verticals/party-registry/src/api/person-engagement-profile-client.ts index ef05dcc9d..98c1f213c 100644 --- a/app/verticals/party-registry/src/api/person-engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/person-engagement-profile-client.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith module-api v1 import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; + import { PersonEngagementProfileApi } from '../../shared/apis/person-engagement-profile.ts'; import type { PersonEngagementProfileRequest } from '../../shared/apis/person-engagement-profile.ts'; import { operationGateway } from './action-gateway.ts'; @@ -41,7 +42,12 @@ export const executePersonEngagementProfileWithAuthorization = ( ) => personEngagementProfileClient(Redacted.make(credential), requestCorrelation, options).pipe( Effect.flatMap((client) => - client.personEngagementProfile.execute({ headers: {}, params: {}, payload, query: {} }), + client.personEngagementProfile.execute({ + headers: {}, + params: {}, + payload, + query: {}, + }), ), ); @@ -50,10 +56,5 @@ export const executePersonEngagementProfile = ( ...[requestCorrelation, options = {}]: PersonEngagementProfileOperationInvocation ) => operationGateway.invoke((credential) => - executePersonEngagementProfileWithAuthorization( - payload, - credential, - requestCorrelation, - options, - ), + executePersonEngagementProfileWithAuthorization(payload, credential, requestCorrelation, options), ); diff --git a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts index 55b6d22d9..85c109c53 100644 --- a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts @@ -1,11 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - OperationContextUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { OperationContextUnavailable, defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; + import { PersonEngagementProfileRequestSchema, PersonEngagementProfileResponseSchema, @@ -25,9 +22,7 @@ interface Services { readonly find: (profileId: string) => ReturnType; } -const personProfileUnavailable = readUnavailable( - 'Contacts engagement persistence is temporarily unavailable', -); +const personProfileUnavailable = readUnavailable('Contacts engagement persistence is temporarily unavailable'); export const personEngagementProfileRead = defineRead( { diff --git a/app/verticals/party-registry/src/api/read-outcome.ts b/app/verticals/party-registry/src/api/read-outcome.ts index 3a2c430f1..1dcbc02b9 100644 --- a/app/verticals/party-registry/src/api/read-outcome.ts +++ b/app/verticals/party-registry/src/api/read-outcome.ts @@ -1,24 +1,22 @@ import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; import { Effect, Match } from 'effect'; + import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; export const readUnavailable = (reason: string, configurable = false) => (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), - 'cause', - { configurable, value: cause }, - ); + Object.defineProperty(new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), 'cause', { + configurable, + value: cause, + }); export const requireReadValue = (reason: string) => (found: LookupResult) => Match.value(found).pipe( Match.tag('found', ({ value }) => Effect.succeed(value)), - Match.tag('not_found', () => - Effect.fail(new ReadHandlerNotFound({ code: 'read_handler_not_found', reason })), - ), + Match.tag('not_found', () => Effect.fail(new ReadHandlerNotFound({ code: 'read_handler_not_found', reason }))), Match.exhaustive, ); diff --git a/app/verticals/party-registry/src/auth/gateway-assertion-redemption-runtime.ts b/app/verticals/party-registry/src/auth/gateway-assertion-redemption-runtime.ts index 83eae31a2..4fcf6a143 100644 --- a/app/verticals/party-registry/src/auth/gateway-assertion-redemption-runtime.ts +++ b/app/verticals/party-registry/src/auth/gateway-assertion-redemption-runtime.ts @@ -3,14 +3,12 @@ import { GatewayAssertionRedemptionUnavailableError, GatewayAssertionReplayError, } from '@app/core-runtime'; -import type { - GatewayAssertionRedemption, - GatewayAssertionRedemptionInput, -} from '@app/core-runtime'; +import type { GatewayAssertionRedemption, GatewayAssertionRedemptionInput } from '@app/core-runtime'; import { GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS } from '@app/shared-contracts'; import { lt } from 'drizzle-orm'; import { Clock, DateTime, Duration, Effect, Layer } from 'effect'; import { isSqlError } from 'effect/unstable/sql/SqlError'; + import { PartyDatabase } from '../db/client.ts'; import { gatewayAssertionRedemptions } from '../db/engagement-schema.ts'; import type { PartyDatabaseExecutor } from '../db/types.ts'; @@ -18,7 +16,9 @@ import type { PartyDatabaseExecutor } from '../db/types.ts'; export { PartyDatabaseLive as GatewayAssertionRedemptionDatabaseLive } from '../db/client.ts'; const replayError = () => - new GatewayAssertionReplayError({ reason: 'The Bearer assertion is no longer usable' }); + new GatewayAssertionReplayError({ + reason: 'The Bearer assertion is no longer usable', + }); const unavailableError = (cause?: unknown) => { const error = new GatewayAssertionRedemptionUnavailableError({ reason: 'Bearer assertion redemption is unavailable', @@ -42,59 +42,50 @@ const redeemAssertion = ( expiresAt: Date, ) => executor.transaction( - Effect.fn('GatewayAssertionRedemptionRuntime.redeemAssertion')( - function* redeemAssertionEffect(transaction) { - yield* transaction - .delete(gatewayAssertionRedemptions) - .where(lt(gatewayAssertionRedemptions.expiresAt, expiredBefore)); - return yield* transaction - .insert(gatewayAssertionRedemptions) - .values({ - audience: input.audience, - expiresAt, - issuer: input.issuer, - jti: input.jti, - }) - .onConflictDoNothing() - .returning({ jti: gatewayAssertionRedemptions.jti }); - }, - ), + Effect.fn('GatewayAssertionRedemptionRuntime.redeemAssertion')(function* redeemAssertionEffect(transaction) { + yield* transaction + .delete(gatewayAssertionRedemptions) + .where(lt(gatewayAssertionRedemptions.expiresAt, expiredBefore)); + return yield* transaction + .insert(gatewayAssertionRedemptions) + .values({ + audience: input.audience, + expiresAt, + issuer: input.issuer, + jti: input.jti, + }) + .onConflictDoNothing() + .returning({ jti: gatewayAssertionRedemptions.jti }); + }), ); -export const makeGatewayAssertionRedemption = ( - executor: PartyDatabaseExecutor, -): GatewayAssertionRedemption => ({ - consume: Effect.fn('PartyRegistryGatewayAssertionRedemption.consume')( - function* consumeGatewayAssertionEffect(input: GatewayAssertionRedemptionInput) { - const nowEpochMs = yield* Clock.currentTimeMillis; - // Verification can finish after its captured clock passes the assertion's skew window. - // Reject before cleanup can delete this assertion's existing replay evidence. - if ( - input.expiresAtEpochSeconds + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS <= - Math.floor(nowEpochMs / 1000) - ) { - return yield* replayError(); - } - const expiredBefore = DateTime.toDateUtc( - DateTime.makeUnsafe(nowEpochMs - GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS * 1000), - ); - const expiresAt = DateTime.toDateUtc(DateTime.makeUnsafe(input.expiresAtEpochSeconds * 1000)); - const inserted = yield* redeemAssertion(executor, input, expiredBefore, expiresAt).pipe( - Effect.mapError(unavailableError), - // oxlint-disable-next-line effect-native/no-local-defect-seam -- Native SQL settlement dies with SqlError; this owner narrows only that expected persistence failure and preserves other defects. remove-when: the rule recognizes native SQL settlement narrowing. - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(unavailableError(defect)) : Effect.die(defect), - ), - Effect.timeoutOrElse({ - duration: REDEMPTION_TIMEOUT, - orElse: () => Effect.fail(unavailableError()), - }), - ); - if (inserted.length !== 1) { - return yield* replayError(); - } - }, - ), +export const makeGatewayAssertionRedemption = (executor: PartyDatabaseExecutor): GatewayAssertionRedemption => ({ + consume: Effect.fn('PartyRegistryGatewayAssertionRedemption.consume')(function* consumeGatewayAssertionEffect( + input: GatewayAssertionRedemptionInput, + ) { + const nowEpochMs = yield* Clock.currentTimeMillis; + // Verification can finish after its captured clock passes the assertion's skew window. + // Reject before cleanup can delete this assertion's existing replay evidence. + if (input.expiresAtEpochSeconds + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS <= Math.floor(nowEpochMs / 1000)) { + return yield* replayError(); + } + const expiredBefore = DateTime.toDateUtc( + DateTime.makeUnsafe(nowEpochMs - GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS * 1000), + ); + const expiresAt = DateTime.toDateUtc(DateTime.makeUnsafe(input.expiresAtEpochSeconds * 1000)); + const inserted = yield* redeemAssertion(executor, input, expiredBefore, expiresAt).pipe( + Effect.mapError(unavailableError), + // oxlint-disable-next-line effect-native/no-local-defect-seam -- Native SQL settlement dies with SqlError; this owner narrows only that expected persistence failure and preserves other defects. remove-when: the rule recognizes native SQL settlement narrowing. + Effect.catchDefect((defect) => (isSqlError(defect) ? Effect.fail(unavailableError(defect)) : Effect.die(defect))), + Effect.timeoutOrElse({ + duration: REDEMPTION_TIMEOUT, + orElse: () => Effect.fail(unavailableError()), + }), + ); + if (inserted.length !== 1) { + return yield* replayError(); + } + }), }); export const GatewayAssertionRedemptionLive = Layer.effect( diff --git a/app/verticals/party-registry/src/db/catalog.ts b/app/verticals/party-registry/src/db/catalog.ts index 49c34cd7b..9578c128a 100644 --- a/app/verticals/party-registry/src/db/catalog.ts +++ b/app/verticals/party-registry/src/db/catalog.ts @@ -1,9 +1,7 @@ import { compareTableCatalog } from './compare-table-catalog.ts'; import { PARTY_SCHEMA_NAME, PARTY_TABLE_INVENTORY } from './schema.ts'; -export const expectedPartyTableCatalog = PARTY_TABLE_INVENTORY.map( - (tableName) => `${PARTY_SCHEMA_NAME}.${tableName}`, -); +export const expectedPartyTableCatalog = PARTY_TABLE_INVENTORY.map((tableName) => `${PARTY_SCHEMA_NAME}.${tableName}`); export const comparePartyCatalog = (qualifiedTableNames: readonly string[]) => compareTableCatalog(expectedPartyTableCatalog, qualifiedTableNames); diff --git a/app/verticals/party-registry/src/db/client.ts b/app/verticals/party-registry/src/db/client.ts index b0c5784c8..2f72de20b 100644 --- a/app/verticals/party-registry/src/db/client.ts +++ b/app/verticals/party-registry/src/db/client.ts @@ -7,6 +7,7 @@ import { Context, Effect, Layer, Redacted } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; import type { PoolConfig } from 'pg'; import { Pool } from 'pg'; + import { PartyDatabaseConnectionError } from './connection-error.ts'; import { partyRelations } from './schema.ts'; import type { PartyDatabaseExecutor } from './types.ts'; @@ -55,21 +56,16 @@ export const makePartyDatabase = Effect.fn('Client.makePartyDatabase')(function* readonly poolDeadlines?: Partial; }, poolFactory: PoolFactory = defaultPoolFactory, -): Effect.fn.Return< - ContextServiceContract, - PartyDatabaseConnectionError, - Scope.Scope -> { +): Effect.fn.Return, PartyDatabaseConnectionError, Scope.Scope> { const poolConfiguration = yield* configureDatabasePool( Redacted.make(configuration.connectionString), configuration.poolDeadlines, ).pipe(Effect.mapError((error) => new PartyDatabaseConnectionError({ reason: error.reason }))); const pool = yield* acquirePoolResource(() => poolFactory(poolConfiguration)); const reactivity = yield* Reactivity.make; - const client = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( - Effect.provideService(Reactivity.Reactivity, reactivity), - Effect.mapError(connectionFailure), - ); + const client = yield* PgClient.fromPool({ + acquire: Effect.succeed(pool), + }).pipe(Effect.provideService(Reactivity.Reactivity, reactivity), Effect.mapError(connectionFailure)); return { executor: yield* makeWithDefaults({ relations: partyRelations }).pipe( Effect.provideService(PgClient.PgClient, client), diff --git a/app/verticals/party-registry/src/db/compare-table-catalog.ts b/app/verticals/party-registry/src/db/compare-table-catalog.ts index 02f5658ae..096c58e92 100644 --- a/app/verticals/party-registry/src/db/compare-table-catalog.ts +++ b/app/verticals/party-registry/src/db/compare-table-catalog.ts @@ -1,7 +1,4 @@ -export const compareTableCatalog = ( - expectedTableNames: readonly string[], - actualTableNames: readonly string[], -) => { +export const compareTableCatalog = (expectedTableNames: readonly string[], actualTableNames: readonly string[]) => { const expected = new Set(expectedTableNames); const actual = new Set(actualTableNames); return { diff --git a/app/verticals/party-registry/src/db/engagement-schema.ts b/app/verticals/party-registry/src/db/engagement-schema.ts index 64b6d7392..f93d78aab 100644 --- a/app/verticals/party-registry/src/db/engagement-schema.ts +++ b/app/verticals/party-registry/src/db/engagement-schema.ts @@ -1,15 +1,6 @@ import { tenantRlsPolicies } from '@app/core-runtime'; import { defineRelations, sql } from 'drizzle-orm'; -import { - check, - index, - pgSchema, - text, - timestamp, - unique, - uniqueIndex, - uuid, -} from 'drizzle-orm/pg-core'; +import { check, index, pgSchema, text, timestamp, unique, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; export const CONTACTS_SCHEMA_NAME = 'contacts'; @@ -37,18 +28,12 @@ export const organizationEngagementProfiles = contactsSchema.table.withRLS( archivedAt: archivedAt(), }, (table) => [ - unique('contacts_organization_engagement_profiles_tenant_id_uk').on( - table.tenantId, - table.engagementProfileId, - ), + unique('contacts_organization_engagement_profiles_tenant_id_uk').on(table.tenantId, table.engagementProfileId), uniqueIndex('contacts_organization_engagement_profiles_counterparty_uk').on( table.tenantId, table.counterpartyResourceId, ), - uniqueIndex('contacts_organization_engagement_profiles_party_uk').on( - table.tenantId, - table.partyResourceId, - ), + uniqueIndex('contacts_organization_engagement_profiles_party_uk').on(table.tenantId, table.partyResourceId), index('contacts_organization_engagement_profiles_active_idx') .on(table.tenantId, table.counterpartyResourceId) .where(sql`${table.archivedAt} is null`), @@ -76,10 +61,7 @@ export const personEngagementProfiles = contactsSchema.table.withRLS( archivedAt: archivedAt(), }, (table) => [ - unique('contacts_person_engagement_profiles_tenant_id_uk').on( - table.tenantId, - table.engagementProfileId, - ), + unique('contacts_person_engagement_profiles_tenant_id_uk').on(table.tenantId, table.engagementProfileId), uniqueIndex('contacts_person_engagement_profiles_party_counterparty_uk').on( table.tenantId, table.partyResourceId, @@ -113,11 +95,7 @@ export const gatewayAssertionRedemptions = contactsSchema.table( redeemedAt: timestamp('redeemed_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ - unique('contacts_gateway_assertion_redemptions_identity_uk').on( - table.issuer, - table.audience, - table.jti, - ), + unique('contacts_gateway_assertion_redemptions_identity_uk').on(table.issuer, table.audience, table.jti), index('contacts_gateway_assertion_redemptions_expiry_idx').on(table.expiresAt), ], ); @@ -134,8 +112,7 @@ export const CONTACTS_TABLES = [ personEngagementProfiles, ] as const; -export type OrganizationEngagementProfileRecord = - typeof organizationEngagementProfiles.$inferSelect; +export type OrganizationEngagementProfileRecord = typeof organizationEngagementProfiles.$inferSelect; export type PersonEngagementProfileRecord = typeof personEngagementProfiles.$inferSelect; /** Relational Queries v2 entry point for the Contacts owner. */ diff --git a/app/verticals/party-registry/src/db/engagement-types.ts b/app/verticals/party-registry/src/db/engagement-types.ts index 24708f274..2caf93ea0 100644 --- a/app/verticals/party-registry/src/db/engagement-types.ts +++ b/app/verticals/party-registry/src/db/engagement-types.ts @@ -1,4 +1,5 @@ import type { EffectPgDatabase } from 'drizzle-orm/effect-postgres'; + import type { contactsRelations } from './engagement-schema.ts'; type ContactsDatabaseExecutor = EffectPgDatabase; diff --git a/app/verticals/party-registry/src/db/schema.ts b/app/verticals/party-registry/src/db/schema.ts index 89d9113de..e62da6ba9 100644 --- a/app/verticals/party-registry/src/db/schema.ts +++ b/app/verticals/party-registry/src/db/schema.ts @@ -1,7 +1,3 @@ -import type { - PartySubjectEvidence, - PartyEvidenceEvaluation, -} from '../../shared/domain/identity-contracts.ts'; import { tenantLegalEntityRlsPolicies, tenantRlsPolicies } from '@app/core-runtime'; import { defineRelations, sql } from 'drizzle-orm'; import { @@ -19,14 +15,10 @@ import { uuid, } from 'drizzle-orm/pg-core'; import type { AnyPgColumn } from 'drizzle-orm/pg-core'; -import type { - AresAppliedEvidence, - AresAppliedEvidenceSchema, -} from '../../shared/domain/ares-application.ts'; -import type { - MergeSelectionEvidenceStep, - MergeSurvivorSelectionReason, -} from '../../shared/domain/merge-selection.ts'; + +import type { AresAppliedEvidence, AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; +import type { PartySubjectEvidence, PartyEvidenceEvaluation } from '../../shared/domain/identity-contracts.ts'; +import type { MergeSelectionEvidenceStep, MergeSurvivorSelectionReason } from '../../shared/domain/merge-selection.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; export const PARTY_SCHEMA_NAME = 'party'; @@ -101,10 +93,7 @@ const activePeriodConstraints = ( prefix: string, table: Readonly>, ) => [ - check( - `${prefix}_interval_ck`, - sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, - ), + check(`${prefix}_interval_ck`, sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`), check( `${prefix}_state_ck`, sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, @@ -114,10 +103,7 @@ const activePeriodConstraints = ( const contactEvidenceConstraints = ( prefix: string, table: Readonly< - Record< - keyof ReturnType | keyof ReturnType, - AnyPgColumn - > + Record | keyof ReturnType, AnyPgColumn> >, ) => [ check( @@ -130,8 +116,7 @@ const contactEvidenceConstraints = ( ), ]; -const enableGovernedRls =
(table: { readonly enableRLS: () => Table }): Table => - table.enableRLS(); +const enableGovernedRls =
(table: { readonly enableRLS: () => Table }): Table => table.enableRLS(); const externalEvidenceConstraint = (name: string, column: AnyPgColumn) => check( @@ -217,10 +202,7 @@ export const parties = enableGovernedRls( index('party_parties_current_name_idx') .on(table.tenantId, table.currentDisplayName) .where(sql`${table.archivedAt} is null`), - check( - 'party_parties_type_ck', - sql`${table.currentType} in ('PERSON', 'ORGANIZATION', 'UNRESOLVED')`, - ), + check('party_parties_type_ck', sql`${table.currentType} in ('PERSON', 'ORGANIZATION', 'UNRESOLVED')`), check( 'party_parties_display_name_ck', sql`${table.currentDisplayName} is null or (${table.currentDisplayName} = btrim(${table.currentDisplayName}) and length(${table.currentDisplayName}) > 0)`, @@ -267,10 +249,7 @@ export const partyFactAssertions = enableGovernedRls( index('party_fact_assertions_current_idx') .on(table.tenantId, table.partyId, table.factKind) .where(sql`${table.state} = 'ACTIVE' and ${table.isCurrent}`), - check( - 'party_fact_assertions_kind_ck', - sql`${table.factKind} in ('PARTY_TYPE', 'DISPLAY_NAME')`, - ), + check('party_fact_assertions_kind_ck', sql`${table.factKind} in ('PARTY_TYPE', 'DISPLAY_NAME')`), check( 'party_fact_assertions_value_ck', sql`${table.normalizedValue} = btrim(${table.normalizedValue}) and length(${table.normalizedValue}) > 0`, @@ -280,10 +259,7 @@ export const partyFactAssertions = enableGovernedRls( 'party_fact_assertions_verification_ck', sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or ${table.verifiedAt} is not null)`, ), - externalEvidenceConstraint( - 'party_fact_assertions_external_evidence_ck', - table.externalEvidence, - ), + externalEvidenceConstraint('party_fact_assertions_external_evidence_ck', table.externalEvidence), ...tenantRlsPolicies('party_fact_assertions_tenant', table.tenantId), ], ), @@ -314,10 +290,7 @@ export const partyOfficialIdentifiers = enableGovernedRls( retractsOfficialIdentifierId: uuid('retracts_official_identifier_id'), }, (table) => [ - unique('party_official_identifiers_tenant_id_uk').on( - table.tenantId, - table.officialIdentifierId, - ), + unique('party_official_identifiers_tenant_id_uk').on(table.tenantId, table.officialIdentifierId), foreignKey({ columns: [table.tenantId, table.partyId], foreignColumns: [parties.tenantId, parties.partyId], @@ -333,15 +306,8 @@ export const partyOfficialIdentifiers = enableGovernedRls( foreignColumns: [table.tenantId, table.officialIdentifierId], name: 'party_official_identifiers_tenant_retracts_fk', }).onDelete('restrict'), - index('party_official_identifiers_party_idx').on( - table.tenantId, - table.partyId, - table.identifierTypeKey, - ), - check( - 'party_official_identifiers_type_ck', - sql`${table.identifierTypeKey} in ('ICO', 'CZ_DIC')`, - ), + index('party_official_identifiers_party_idx').on(table.tenantId, table.partyId, table.identifierTypeKey), + check('party_official_identifiers_type_ck', sql`${table.identifierTypeKey} in ('ICO', 'CZ_DIC')`), check( 'party_official_identifiers_normalized_value_ck', sql`(${table.identifierTypeKey} = 'ICO' and ${table.normalizedValue} ~ '^[0-9]{8}$') or (${table.identifierTypeKey} = 'CZ_DIC' and ${table.normalizedValue} ~ '^CZ[0-9]{8,10}$')`, @@ -351,10 +317,7 @@ export const partyOfficialIdentifiers = enableGovernedRls( 'party_official_identifiers_verification_ck', sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or ${table.verifiedAt} is not null)`, ), - externalEvidenceConstraint( - 'party_official_identifiers_external_evidence_ck', - table.externalEvidence, - ), + externalEvidenceConstraint('party_official_identifiers_external_evidence_ck', table.externalEvidence), ...tenantRlsPolicies('party_official_identifiers_tenant', table.tenantId), ], ), @@ -388,17 +351,11 @@ export const partyIdentifierClaims = enableGovernedRls( }).onDelete('restrict'), foreignKey({ columns: [table.tenantId, table.officialIdentifierId], - foreignColumns: [ - partyOfficialIdentifiers.tenantId, - partyOfficialIdentifiers.officialIdentifierId, - ], + foreignColumns: [partyOfficialIdentifiers.tenantId, partyOfficialIdentifiers.officialIdentifierId], name: 'party_identifier_claims_tenant_identifier_fk', }).onDelete('restrict'), index('party_identifier_claims_party_lookup_idx').on(table.tenantId, table.partyId), - check( - 'party_identifier_claims_type_ck', - sql`${table.identifierTypeKey} in ('ICO', 'CZ_DIC')`, - ), + check('party_identifier_claims_type_ck', sql`${table.identifierTypeKey} in ('ICO', 'CZ_DIC')`), ...tenantRlsPolicies('party_identifier_claims_tenant', table.tenantId), ], ), @@ -428,21 +385,14 @@ export const partyContactPoints = enableGovernedRls( revision: integer('revision').default(1).notNull(), ...endedPeriodColumns(), ...provenanceColumns(), - additionalEvidenceRefs: jsonb('additional_evidence_refs') - .$type() - .default([]) - .notNull(), + additionalEvidenceRefs: jsonb('additional_evidence_refs').$type().default([]).notNull(), ...verificationColumns(), supersedesContactPointId: uuid('supersedes_contact_point_id'), retractsContactPointId: uuid('retracts_contact_point_id'), }, (table) => [ unique('party_contact_points_tenant_id_uk').on(table.tenantId, table.contactPointId), - unique('party_contact_points_tenant_party_id_uk').on( - table.tenantId, - table.partyId, - table.contactPointId, - ), + unique('party_contact_points_tenant_party_id_uk').on(table.tenantId, table.partyId, table.contactPointId), foreignKey({ columns: [table.tenantId, table.partyId], foreignColumns: [parties.tenantId, parties.partyId], @@ -466,10 +416,7 @@ export const partyContactPoints = enableGovernedRls( .where( sql`${table.preferred} and ${table.contactPointType} in ('EMAIL', 'PHONE') and ${table.state} = 'ACTIVE' and ${table.isCurrent}`, ), - check( - 'party_contact_points_type_ck', - sql`${table.contactPointType} in ('EMAIL', 'PHONE', 'ADDRESS')`, - ), + check('party_contact_points_type_ck', sql`${table.contactPointType} in ('EMAIL', 'PHONE', 'ADDRESS')`), check( 'party_contact_points_shape_ck', sql`(${table.contactPointType} = 'EMAIL' and length(btrim(${table.displayValue})) > 0 and length(btrim(${table.normalizedValue})) > 0 and length(btrim(${table.normalizationVersion})) > 0 and ${table.phoneCountryCode} is null and ${table.phoneExtension} is null and ${table.addressLine1} is null and ${table.city} is null and ${table.postalCode} is null and ${table.countryCode} is null) or (${table.contactPointType} = 'PHONE' and length(btrim(${table.displayValue})) > 0 and ${table.normalizedValue} ~ '^\\+[1-9][0-9]{6,14}$' and length(btrim(${table.normalizationVersion})) > 0 and (${table.phoneCountryCode} is null or ${table.phoneCountryCode} ~ '^[A-Z]{2}$') and (${table.phoneExtension} is null or ${table.phoneExtension} ~ '^[0-9]{1,12}$') and ${table.addressLine1} is null and ${table.city} is null and ${table.postalCode} is null and ${table.countryCode} is null) or (${table.contactPointType} = 'ADDRESS' and ${table.normalizedValue} is null and ${table.normalizationVersion} is null and ${table.phoneCountryCode} is null and ${table.phoneExtension} is null and ${table.countryCode} ~ '^[A-Z]{2}$' and num_nonnulls(nullif(btrim(${table.addressLine1}), ''), nullif(btrim(${table.addressLine2}), ''), nullif(btrim(${table.city}), ''), nullif(btrim(${table.postalCode}), ''), nullif(btrim(${table.region}), '')) >= 2 and not ${table.preferred})`, @@ -485,10 +432,7 @@ export const partyContactPoints = enableGovernedRls( ...activePeriodConstraints('party_contact_points', table), ...contactEvidenceConstraints('party_contact_points', table), check('party_contact_points_revision_ck', sql`${table.revision} > 0`), - externalEvidenceConstraint( - 'party_contact_points_external_evidence_ck', - table.externalEvidence, - ), + externalEvidenceConstraint('party_contact_points_external_evidence_ck', table.externalEvidence), ...tenantRlsPolicies('party_contact_points_tenant', table.tenantId), ], ), @@ -512,36 +456,21 @@ export const partyContactPointPurposes = enableGovernedRls( revision: integer('revision').default(1).notNull(), }, (table) => [ - unique('party_contact_point_purposes_tenant_id_uk').on( - table.tenantId, - table.contactPointPurposeId, - ), + unique('party_contact_point_purposes_tenant_id_uk').on(table.tenantId, table.contactPointPurposeId), foreignKey({ columns: [table.tenantId, table.partyId, table.contactPointId], - foreignColumns: [ - partyContactPoints.tenantId, - partyContactPoints.partyId, - partyContactPoints.contactPointId, - ], + foreignColumns: [partyContactPoints.tenantId, partyContactPoints.partyId, partyContactPoints.contactPointId], name: 'party_contact_point_purposes_contact_fk', }).onDelete('restrict'), index('party_contact_point_purposes_current_idx') .on(table.tenantId, table.partyId, table.purposeKey) .where(sql`${table.state} = 'ACTIVE' and ${table.isCurrent}`), uniqueIndex('party_contact_point_purposes_current_preferred_uk') - .on( - table.tenantId, - table.partyId, - table.purposeKey, - table.registryContext, - table.jurisdiction, - ) + .on(table.tenantId, table.partyId, table.purposeKey, table.registryContext, table.jurisdiction) .where(sql`${table.preferred} and ${table.state} = 'ACTIVE' and ${table.isCurrent}`), uniqueIndex('party_contact_point_purposes_current_registered_uk') .on(table.tenantId, table.partyId, table.registryContext, table.jurisdiction) - .where( - sql`${table.purposeKey} = 'REGISTERED' and ${table.state} = 'ACTIVE' and ${table.isCurrent}`, - ), + .where(sql`${table.purposeKey} = 'REGISTERED' and ${table.state} = 'ACTIVE' and ${table.isCurrent}`), check( 'party_contact_point_purposes_key_ck', sql`${table.purposeKey} in ('REGISTERED', 'BILLING', 'DELIVERY', 'CORRESPONDENCE')`, @@ -553,10 +482,7 @@ export const partyContactPointPurposes = enableGovernedRls( ...activePeriodConstraints('party_contact_point_purposes', table), ...contactEvidenceConstraints('party_contact_point_purposes', table), check('party_contact_point_purposes_revision_ck', sql`${table.revision} > 0`), - externalEvidenceConstraint( - 'party_contact_point_purposes_external_evidence_ck', - table.externalEvidence, - ), + externalEvidenceConstraint('party_contact_point_purposes_external_evidence_ck', table.externalEvidence), ...tenantRlsPolicies('party_contact_point_purposes_tenant', table.tenantId), ], ), @@ -668,22 +594,14 @@ export const counterparties = enableGovernedRls( }, (table) => [ unique('party_counterparties_tenant_id_uk').on(table.tenantId, table.counterpartyId), - unique('party_counterparties_scope_id_uk').on( - table.tenantId, - table.legalEntityId, - table.counterpartyId, - ), + unique('party_counterparties_scope_id_uk').on(table.tenantId, table.legalEntityId, table.counterpartyId), unique('party_counterparties_projection_source_uk').on( table.tenantId, table.counterpartyId, table.legalEntityId, table.partyId, ), - unique('party_counterparties_context_uk').on( - table.tenantId, - table.partyId, - table.legalEntityId, - ), + unique('party_counterparties_context_uk').on(table.tenantId, table.partyId, table.legalEntityId), foreignKey({ columns: [table.tenantId, table.partyId], foreignColumns: [parties.tenantId, parties.partyId], @@ -700,11 +618,7 @@ export const counterparties = enableGovernedRls( 'party_counterparties_evidence_ck', sql`jsonb_typeof(${table.evidenceRefs}) = 'array' and jsonb_array_length(${table.evidenceRefs}) between 1 and 32 and jsonb_typeof(${table.sourceRecordRefs}) = 'array' and jsonb_array_length(${table.sourceRecordRefs}) <= 32`, ), - ...tenantLegalEntityRlsPolicies( - 'party_counterparties_scope', - table.tenantId, - table.legalEntityId, - ), + ...tenantLegalEntityRlsPolicies('party_counterparties_scope', table.tenantId, table.legalEntityId), ], ), ); @@ -747,17 +661,10 @@ export const counterpartyRolePeriods = enableGovernedRls( ), foreignKey({ columns: [table.tenantId, table.legalEntityId, table.counterpartyId], - foreignColumns: [ - counterparties.tenantId, - counterparties.legalEntityId, - counterparties.counterpartyId, - ], + foreignColumns: [counterparties.tenantId, counterparties.legalEntityId, counterparties.counterpartyId], name: 'party_role_periods_scope_counterparty_fk', }).onDelete('restrict'), - check( - 'party_counterparty_role_periods_type_ck', - sql`${table.roleType} in ('CUSTOMER', 'SUPPLIER')`, - ), + check('party_counterparty_role_periods_type_ck', sql`${table.roleType} in ('CUSTOMER', 'SUPPLIER')`), check( 'party_counterparty_role_periods_interval_ck', sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, @@ -774,11 +681,7 @@ export const counterpartyRolePeriods = enableGovernedRls( 'party_counterparty_role_periods_end_evidence_ck', sql`((${table.state} = 'ACTIVE' and ((${table.validTo} is null and ${table.endReason} is null and ${table.endEvidenceRefs} is null and ${table.endedByActionInvocationId} is null and ${table.endedByPrincipalId} is null and ${table.endedRecordedAt} is null) or (${table.validTo} is not null and length(btrim(${table.endReason})) > 0 and jsonb_typeof(${table.endEvidenceRefs}) = 'array' and jsonb_array_length(${table.endEvidenceRefs}) between 1 and 32 and ${table.endedByActionInvocationId} is not null and ${table.endedByPrincipalId} is not null and ${table.endedRecordedAt} is not null))) or (${table.state} = 'ENDED' and ${table.validTo} is not null and length(btrim(${table.endReason})) > 0 and jsonb_typeof(${table.endEvidenceRefs}) = 'array' and jsonb_array_length(${table.endEvidenceRefs}) between 1 and 32 and ${table.endedByActionInvocationId} is not null and ${table.endedByPrincipalId} is not null and ${table.endedRecordedAt} is not null) or (${table.state} in ('SUPERSEDED', 'RETRACTED', 'DISPUTED'))) and ((${table.validTo} is null and ${table.endProvenanceSource} is null and ${table.endProvenanceMethod} is null) or (${table.validTo} is not null and ${table.endProvenanceSource} = btrim(${table.endProvenanceSource}) and length(${table.endProvenanceSource}) > 0 and ${table.endProvenanceMethod} = btrim(${table.endProvenanceMethod}) and length(${table.endProvenanceMethod}) > 0))`, ), - ...tenantLegalEntityRlsPolicies( - 'party_role_periods_scope', - table.tenantId, - table.legalEntityId, - ), + ...tenantLegalEntityRlsPolicies('party_role_periods_scope', table.tenantId, table.legalEntityId), ], ), ); @@ -795,10 +698,7 @@ export const counterpartyAdminReadModels = enableGovernedRls( archivedAt: timestamp('archived_at', { withTimezone: true }), }, (table) => [ - unique('party_counterparty_admin_models_tenant_id_uk').on( - table.tenantId, - table.counterpartyId, - ), + unique('party_counterparty_admin_models_tenant_id_uk').on(table.tenantId, table.counterpartyId), foreignKey({ columns: [table.tenantId, table.counterpartyId, table.legalEntityId, table.storedPartyId], foreignColumns: [ @@ -841,10 +741,7 @@ export const counterpartyRoleAdminReadModels = enableGovernedRls( provenanceMethod: text('provenance_method').notNull(), }, (table) => [ - unique('party_counterparty_role_admin_models_tenant_id_uk').on( - table.tenantId, - table.rolePeriodId, - ), + unique('party_counterparty_role_admin_models_tenant_id_uk').on(table.tenantId, table.rolePeriodId), foreignKey({ columns: [table.tenantId, table.counterpartyId, table.rolePeriodId], foreignColumns: [ @@ -856,10 +753,7 @@ export const counterpartyRoleAdminReadModels = enableGovernedRls( }).onDelete('restrict'), foreignKey({ columns: [table.tenantId, table.counterpartyId], - foreignColumns: [ - counterpartyAdminReadModels.tenantId, - counterpartyAdminReadModels.counterpartyId, - ], + foreignColumns: [counterpartyAdminReadModels.tenantId, counterpartyAdminReadModels.counterpartyId], name: 'party_counterparty_role_admin_model_counterparty_fk', }).onDelete('restrict'), index('party_counterparty_role_admin_models_history_idx').on( @@ -868,10 +762,7 @@ export const counterpartyRoleAdminReadModels = enableGovernedRls( table.validFrom, table.roleType, ), - check( - 'party_counterparty_role_admin_models_type_ck', - sql`${table.roleType} in ('CUSTOMER', 'SUPPLIER')`, - ), + check('party_counterparty_role_admin_models_type_ck', sql`${table.roleType} in ('CUSTOMER', 'SUPPLIER')`), check( 'party_counterparty_role_admin_models_state_ck', sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED')`, @@ -903,9 +794,7 @@ export const duplicateCandidateCases = enableGovernedRls( evaluationFingerprint: text('evaluation_fingerprint').notNull(), priorCandidateCaseId: uuid('prior_candidate_case_id'), candidateSnapshot: jsonb('candidate_snapshot').$type().notNull(), - evaluatedEvidence: jsonb('evaluated_evidence') - .$type() - .notNull(), + evaluatedEvidence: jsonb('evaluated_evidence').$type().notNull(), matchRuleVersion: text('match_rule_version').notNull(), lifecycleState: text('lifecycle_state').default('OPEN').notNull(), revision: integer('revision').default(1).notNull(), @@ -923,11 +812,7 @@ export const duplicateCandidateCases = enableGovernedRls( uniqueIndex('party_duplicate_cases_fingerprint_uk') .on(table.tenantId, table.evaluationFingerprint, table.matchRuleVersion) .where(sql`${table.lifecycleState} in ('OPEN', 'NEEDS_EVIDENCE')`), - index('party_duplicate_cases_input_history_idx').on( - table.tenantId, - table.candidateFingerprint, - table.createdAt, - ), + index('party_duplicate_cases_input_history_idx').on(table.tenantId, table.candidateFingerprint, table.createdAt), foreignKey({ columns: [table.tenantId, table.priorCandidateCaseId], foreignColumns: [table.tenantId, table.candidateCaseId], @@ -937,19 +822,13 @@ export const duplicateCandidateCases = enableGovernedRls( 'party_duplicate_cases_prior_case_ck', sql`${table.priorCandidateCaseId} is null or ${table.priorCandidateCaseId} <> ${table.candidateCaseId}`, ), - check( - 'party_duplicate_cases_evaluation_fingerprint_ck', - sql`${table.evaluationFingerprint} ~ '^[0-9a-f]{64}$'`, - ), + check('party_duplicate_cases_evaluation_fingerprint_ck', sql`${table.evaluationFingerprint} ~ '^[0-9a-f]{64}$'`), foreignKey({ columns: [table.tenantId, table.selectedPartyId], foreignColumns: [parties.tenantId, parties.partyId], name: 'party_duplicate_cases_selected_party_fk', }).onDelete('restrict'), - check( - 'party_duplicate_cases_fingerprint_ck', - sql`${table.candidateFingerprint} ~ '^[0-9a-f]{64}$'`, - ), + check('party_duplicate_cases_fingerprint_ck', sql`${table.candidateFingerprint} ~ '^[0-9a-f]{64}$'`), check( 'party_duplicate_cases_snapshot_ck', sql`coalesce(jsonb_typeof(${table.candidateSnapshot}), '') = 'object' and coalesce(jsonb_typeof(${table.candidateSnapshot}->'names'), '') = 'array' and jsonb_array_length(${table.candidateSnapshot}->'names') <= 32 and coalesce(jsonb_typeof(${table.candidateSnapshot}->'provenance'), '') = 'object' and coalesce(length(btrim(${table.candidateSnapshot}->'provenance'->>'source')), 0) between 1 and 500 and coalesce(length(btrim(${table.candidateSnapshot}->'provenance'->>'method')), 0) between 1 and 500 and coalesce(${table.candidateSnapshot}->>'validFrom', '') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{3})?Z$'`, @@ -977,17 +856,11 @@ export const duplicateCandidateCaseParties = enableGovernedRls( candidateCaseId: uuid('candidate_case_id').notNull(), partyId: uuid('party_id').notNull(), rank: integer('rank').notNull(), - evidenceExplanation: jsonb('evidence_explanation') - .$type() - .notNull(), + evidenceExplanation: jsonb('evidence_explanation').$type().notNull(), }, (table) => [ unique('party_case_parties_tenant_id_uk').on(table.tenantId, table.candidateCasePartyId), - unique('party_case_parties_candidate_party_uk').on( - table.tenantId, - table.candidateCaseId, - table.partyId, - ), + unique('party_case_parties_candidate_party_uk').on(table.tenantId, table.candidateCaseId, table.partyId), foreignKey({ columns: [table.tenantId, table.candidateCaseId], foreignColumns: [duplicateCandidateCases.tenantId, duplicateCandidateCases.candidateCaseId], @@ -1019,17 +892,12 @@ export const partyMatchDecisions = enableGovernedRls( outcome: text('outcome').notNull(), partyId: uuid('party_id'), candidateCaseId: uuid('candidate_case_id'), - evidenceExplanation: jsonb('evidence_explanation') - .$type() - .notNull(), + evidenceExplanation: jsonb('evidence_explanation').$type().notNull(), decidedAt: timestamp('decided_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ unique('party_match_decisions_tenant_id_uk').on(table.tenantId, table.matchDecisionId), - unique('party_match_decisions_action_invocation_uk').on( - table.tenantId, - table.actionInvocationId, - ), + unique('party_match_decisions_action_invocation_uk').on(table.tenantId, table.actionInvocationId), foreignKey({ columns: [table.tenantId, table.partyId], foreignColumns: [parties.tenantId, parties.partyId], @@ -1040,10 +908,7 @@ export const partyMatchDecisions = enableGovernedRls( foreignColumns: [duplicateCandidateCases.tenantId, duplicateCandidateCases.candidateCaseId], name: 'party_match_decisions_tenant_case_fk', }).onDelete('restrict'), - check( - 'party_match_decisions_fingerprint_ck', - sql`${table.candidateFingerprint} ~ '^[0-9a-f]{64}$'`, - ), + check('party_match_decisions_fingerprint_ck', sql`${table.candidateFingerprint} ~ '^[0-9a-f]{64}$'`), check( 'party_match_decisions_outcome_ck', sql`${table.outcome} in ('CREATED', 'MATCHED', 'NO_MATCH', 'AMBIGUOUS')`, @@ -1187,10 +1052,7 @@ export const partyCorrections = enableGovernedRls( }).onDelete('restrict'), foreignKey({ columns: [table.tenantId, table.officialIdentifierId], - foreignColumns: [ - partyOfficialIdentifiers.tenantId, - partyOfficialIdentifiers.officialIdentifierId, - ], + foreignColumns: [partyOfficialIdentifiers.tenantId, partyOfficialIdentifiers.officialIdentifierId], name: 'party_corrections_tenant_identifier_fk', }).onDelete('restrict'), foreignKey({ @@ -1210,10 +1072,7 @@ export const partyCorrections = enableGovernedRls( }).onDelete('restrict'), foreignKey({ columns: [table.tenantId, table.replacementOfficialIdentifierId], - foreignColumns: [ - partyOfficialIdentifiers.tenantId, - partyOfficialIdentifiers.officialIdentifierId, - ], + foreignColumns: [partyOfficialIdentifiers.tenantId, partyOfficialIdentifiers.officialIdentifierId], name: 'party_corrections_tenant_replacement_id_fk', }).onDelete('restrict'), foreignKey({ diff --git a/app/verticals/party-registry/src/db/types.ts b/app/verticals/party-registry/src/db/types.ts index aa838a6fa..081922fd4 100644 --- a/app/verticals/party-registry/src/db/types.ts +++ b/app/verticals/party-registry/src/db/types.ts @@ -1,4 +1,5 @@ import type { EffectPgDatabase } from 'drizzle-orm/effect-postgres'; + import type { partyRelations } from './schema.ts'; export type PartyDatabaseExecutor = EffectPgDatabase; diff --git a/app/verticals/party-registry/src/federation/page-contacts.tsx b/app/verticals/party-registry/src/federation/page-contacts.tsx index a2cc06e91..cb527ebd1 100644 --- a/app/verticals/party-registry/src/federation/page-contacts.tsx +++ b/app/verticals/party-registry/src/federation/page-contacts.tsx @@ -1,4 +1,5 @@ import { FederatedI18nBoundary } from '@modern-js/plugin-i18n/runtime'; + import { partyRegistryI18nResources } from '../i18n/resources'; import ContactsPage from '../routes/[lang]/contacts/page'; diff --git a/app/verticals/party-registry/src/i18n/resources.ts b/app/verticals/party-registry/src/i18n/resources.ts index 321edc543..fcad443aa 100644 --- a/app/verticals/party-registry/src/i18n/resources.ts +++ b/app/verticals/party-registry/src/i18n/resources.ts @@ -1,6 +1,7 @@ +import { isString } from 'effect/Predicate'; + import csResource from '../../locales/cs/party-registry.json' with { type: 'json' }; import enResource from '../../locales/en/party-registry.json' with { type: 'json' }; -import { isString } from 'effect/Predicate'; import { ultramodernRouteNamespace } from '../routes/ultramodern-route-metadata.ts'; type LocaleResource = string | { readonly [key: string]: LocaleResource }; @@ -15,9 +16,7 @@ const flattenLocaleResource = (resource: LocaleResource, prefix = ''): Record { const nextKey = prefix.length > 0 ? `${prefix}.${key}` : key; - return isLocaleText(value) - ? [[nextKey, value]] - : Object.entries(flattenLocaleResource(value, nextKey)); + return isLocaleText(value) ? [[nextKey, value]] : Object.entries(flattenLocaleResource(value, nextKey)); }), ); }; diff --git a/app/verticals/party-registry/src/integrations/ares/ares-subject.service.ts b/app/verticals/party-registry/src/integrations/ares/ares-subject.service.ts index 47e2fd325..adabf58ea 100644 --- a/app/verticals/party-registry/src/integrations/ares/ares-subject.service.ts +++ b/app/verticals/party-registry/src/integrations/ares/ares-subject.service.ts @@ -1,18 +1,7 @@ // @generated by OntOS Codesmith External HTTP Adapter v1 -import { - Cache, - Cause, - Context, - DateTime, - Effect, - Exit, - Layer, - Option, - Schedule, - Schema, - Semaphore, -} from 'effect'; +import { Cache, Cause, Context, DateTime, Effect, Exit, Layer, Option, Schedule, Schema, Semaphore } from 'effect'; import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http'; + import { AresDateOnlySchema, AresDicSchema, @@ -22,8 +11,7 @@ import { } from '../../../shared/domain/ares-evidence.ts'; import type { AresSubjectEvidence } from '../../../shared/domain/ares-evidence.ts'; -const ARES_SUBJECT_BASE_URL = - 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/'; +const ARES_SUBJECT_BASE_URL = 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/'; const ARES_REQUEST_TIMEOUT = '3 seconds'; const ARES_SUCCESS_CACHE_TTL = '5 minutes'; const ARES_CACHE_CAPACITY = 256; @@ -111,14 +99,11 @@ export interface AresSubjectLookup extends Readonly Effect.Effect; + readonly subject: (input: AresSubjectLookup) => Effect.Effect; } -export class AresSubjectService extends Context.Service< - AresSubjectService, - AresSubjectServiceContract ->()('@app/party-registry/integrations/ares/ares-subject.service/AresSubjectService') {} +export class AresSubjectService extends Context.Service()( + '@app/party-registry/integrations/ares/ares-subject.service/AresSubjectService', +) {} const invalidIco = (cause?: unknown) => new AresSubjectInvalidIco({ @@ -177,14 +162,9 @@ const statusFailures = new Map AresSubjectError>([ [504, unavailable], ]); -const classifyStatus = (status: number): AresSubjectError => - (statusFailures.get(status) ?? responseInvalid)(); +const classifyStatus = (status: number): AresSubjectError => (statusFailures.get(status) ?? responseInvalid)(); -const AresRetryableErrorSchema = Schema.Union([ - AresSubjectThrottled, - AresSubjectTimeout, - AresSubjectUnavailable, -]); +const AresRetryableErrorSchema = Schema.Union([AresSubjectThrottled, AresSubjectTimeout, AresSubjectUnavailable]); const isRetryable = Schema.is(AresRetryableErrorSchema); const retrySchedule = Schedule.exponential('100 millis').pipe(Schedule.upTo({ times: 2 })); const failTimedOut = (error: Cause.TimeoutError) => Effect.fail(timedOut(error)); @@ -229,9 +209,9 @@ const decodeSubject = ( response: HttpClientResponse.HttpClientResponse, requestedIco: string, ): Effect.Effect> => - HttpClientResponse.schemaBodyJson(AresRawSubjectSchema, { onExcessProperty: 'ignore' })( - response, - ).pipe( + HttpClientResponse.schemaBodyJson(AresRawSubjectSchema, { + onExcessProperty: 'ignore', + })(response).pipe( Effect.mapError(responseInvalid), Effect.flatMap((subject) => { if (subject.ico !== requestedIco) { @@ -258,7 +238,7 @@ const decodeSubject = ( mappedAddress !== null && Object.values(mappedAddress).every((value) => value === null) ? null : mappedAddress; - const evidence = yield* Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)({ + const evidence = yield* Schema.decodeEffect(AresSubjectEvidenceSchema)({ cacheAgeSeconds: 0, observedAt: observedAtIso, provider: 'ares', @@ -295,10 +275,9 @@ const requestSubject = ( response.status === 200 ? decodeSubject(response, ico) : Effect.andThen( - Effect.annotateLogs( - Effect.logError('ARES subject request returned an upstream failure'), - { upstreamStatus: response.status }, - ), + Effect.annotateLogs(Effect.logError('ARES subject request returned an upstream failure'), { + upstreamStatus: response.status, + }), Effect.fail(classifyStatus(response.status)), ), ), @@ -312,9 +291,7 @@ const makeAresSubjectService = Effect.gen(function* makeAresSubjectServiceEffect const concurrency = yield* Semaphore.make(ARES_MAX_CONCURRENCY); const loadSubject = (ico: string) => concurrency.withPermit( - requestSubject(httpClient, ico).pipe( - Effect.retry({ schedule: retrySchedule, while: isRetryable }), - ), + requestSubject(httpClient, ico).pipe(Effect.retry({ schedule: retrySchedule, while: isRetryable })), ); const cache = yield* Cache.makeWith(loadSubject, { capacity: ARES_CACHE_CAPACITY, @@ -322,7 +299,7 @@ const makeAresSubjectService = Effect.gen(function* makeAresSubjectServiceEffect }); return { subject: (input) => - Schema.decodeUnknownEffect(AresSubjectLookupIcoSchema)(input.ico).pipe( + Schema.decodeEffect(AresSubjectLookupIcoSchema)(input.ico).pipe( Effect.mapError(invalidIco), Effect.flatMap((ico) => Cache.get(cache, ico).pipe( diff --git a/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts b/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts index 6ffe261c5..a8c1112d4 100644 --- a/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts +++ b/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts @@ -1,3 +1,5 @@ +import { Schema } from 'effect'; + import type { ConfirmedDuplicateSet, MergeSelectionEvidenceCriterion, @@ -14,7 +16,6 @@ import { } from '../../shared/domain/merge-selection.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; -import { Schema } from 'effect'; const CanonicalSurvivorSelectionSchema = Schema.Union([ Schema.TaggedStruct('CanonicalSurvivorSelected', { @@ -204,12 +205,9 @@ const findDecidingCriterion = ( survivor: MergeSurvivorCandidate, runnerUp: MergeSurvivorCandidate, ): MergeSurvivorSelectionReason => - criteria.find(({ compare }) => compare(survivor, runnerUp) !== 0)?.reason ?? - 'STABLE_RESOURCE_IDENTITY'; + criteria.find(({ compare }) => compare(survivor, runnerUp) !== 0)?.reason ?? 'STABLE_RESOURCE_IDENTITY'; -export const selectCanonicalSurvivor = ( - input: MergeSurvivorSelectionInput, -): CanonicalSurvivorSelection => { +export const selectCanonicalSurvivor = (input: MergeSurvivorSelectionInput): CanonicalSurvivorSelection => { const { confirmation } = input; const candidates = input.candidates .map((candidate) => @@ -226,9 +224,7 @@ export const selectCanonicalSurvivor = ( conflictingPartyRefs: candidates.map(({ partyRef }) => partyRef), }; } - const candidateKeys = candidates.map( - ({ partyRef }) => `${partyRef.tenantId}:${partyRef.resourceId}`, - ); + const candidateKeys = candidates.map(({ partyRef }) => `${partyRef.tenantId}:${partyRef.resourceId}`); if (new Set(candidateKeys).size !== candidates.length) { return { _tag: 'SurvivorSelectionBlocked', @@ -251,9 +247,7 @@ export const selectCanonicalSurvivor = ( conflictingPartyRefs: candidates.map(({ partyRef }) => partyRef), }; } - const conflicts = candidates.filter( - ({ blockingAuthoritativeConflict }) => blockingAuthoritativeConflict, - ); + const conflicts = candidates.filter(({ blockingAuthoritativeConflict }) => blockingAuthoritativeConflict); if (conflicts.length > 0) { return { _tag: 'SurvivorSelectionBlocked', diff --git a/app/verticals/party-registry/src/merge/merge-collision-analysis.ts b/app/verticals/party-registry/src/merge/merge-collision-analysis.ts index 7e726e738..ef8d75275 100644 --- a/app/verticals/party-registry/src/merge/merge-collision-analysis.ts +++ b/app/verticals/party-registry/src/merge/merge-collision-analysis.ts @@ -67,9 +67,7 @@ const groupsWithCollisions = ( const key = groupKey(value); groups.set(key, [...(groups.get(key) ?? []), value]); } - return [...groups] - .filter(([, group]) => group.length > 1) - .toSorted(([left], [right]) => left.localeCompare(right)); + return [...groups].filter(([, group]) => group.length > 1).toSorted(([left], [right]) => left.localeCompare(right)); }; const periodsOverlap = ( @@ -81,10 +79,7 @@ const periodsOverlap = ( const partyKey = ({ resourceId, tenantId }: PartyRef) => `${tenantId}:${resourceId}`; export const analyzeMergeCollisions = (input: MergeCollisionInput): readonly MergeCollision[] => { - const mergePartyKeys = new Set([ - partyKey(input.survivorPartyRef), - ...input.absorbedPartyRefs.map(partyKey), - ]); + const mergePartyKeys = new Set([partyKey(input.survivorPartyRef), ...input.absorbedPartyRefs.map(partyKey)]); const inMergeSet = (values: readonly Value[]) => values.filter(({ partyRef }) => mergePartyKeys.has(partyKey(partyRef))); const canonicalPartyId = (partyRef: PartyRef) => @@ -116,10 +111,7 @@ export const analyzeMergeCollisions = (input: MergeCollisionInput): readonly Mer canonicalToPartyId: canonicalPartyId(relationship.toPartyRef), })); for (const relationship of relationships) { - if ( - relationship.forbidsOverlap && - relationship.canonicalFromPartyId === relationship.canonicalToPartyId - ) { + if (relationship.forbidsOverlap && relationship.canonicalFromPartyId === relationship.canonicalToPartyId) { relationshipCollisions.push({ code: 'RELATIONSHIP_SELF_REFERENCE', ownerKey: 'party.registry', @@ -133,9 +125,7 @@ export const analyzeMergeCollisions = (input: MergeCollisionInput): readonly Mer ({ canonicalFromPartyId, canonicalToPartyId, relationshipTypeKey }) => `${relationshipTypeKey}:${canonicalFromPartyId}:${canonicalToPartyId}`, )) { - if ( - rows.some((left, index) => rows.slice(index + 1).some((right) => periodsOverlap(left, right))) - ) { + if (rows.some((left, index) => rows.slice(index + 1).some((right) => periodsOverlap(left, right)))) { relationshipCollisions.push({ code: 'RELATIONSHIP_PERIOD_COLLISION', ownerKey: 'party.registry', @@ -150,9 +140,7 @@ export const analyzeMergeCollisions = (input: MergeCollisionInput): readonly Mer ({ legalEntityId, roleType }) => `${legalEntityId}:${roleType}`, ) .filter(([, rows]) => - rows.some((left, index) => - rows.slice(index + 1).some((right) => periodsOverlap(left, right)), - ), + rows.some((left, index) => rows.slice(index + 1).some((right) => periodsOverlap(left, right))), ) .map(([, rows]) => ({ code: 'COUNTERPARTY_ROLE_PERIOD_COLLISION' as const, diff --git a/app/verticals/party-registry/src/merge/merge-readiness.ts b/app/verticals/party-registry/src/merge/merge-readiness.ts index ff5ba66f7..d4405c5bd 100644 --- a/app/verticals/party-registry/src/merge/merge-readiness.ts +++ b/app/verticals/party-registry/src/merge/merge-readiness.ts @@ -1,19 +1,14 @@ import { Match } from 'effect'; -import type { - MergeReadinessBlocker, - MergeReadinessResult, -} from '../../shared/domain/merge-readiness.ts'; -import type { PartyRef } from '../../shared/resources/party.ts'; -import type { PartyAlias } from '../../shared/resources/party-alias.ts'; + +import type { MergeReadinessBlocker, MergeReadinessResult } from '../../shared/domain/merge-readiness.ts'; import type { MergeSurvivorSelectionInput } from '../../shared/domain/merge-selection.ts'; +import type { PartyAlias } from '../../shared/resources/party-alias.ts'; +import type { PartyRef } from '../../shared/resources/party.ts'; import { selectCanonicalSurvivor } from './canonical-survivor-selection.ts'; import { analyzeMergeCollisions } from './merge-collision-analysis.ts'; import type { MergeCollisionInput } from './merge-collision-analysis.ts'; import { planReferencePreservation } from './reference-preservation-plan.ts'; -import type { - ConsumerReconciliationContract, - PartyReferenceInventoryItem, -} from './reference-preservation-plan.ts'; +import type { ConsumerReconciliationContract, PartyReferenceInventoryItem } from './reference-preservation-plan.ts'; export const rejectProductionMergeExecution = () => ({ @@ -58,15 +53,12 @@ const baseBlockers = () => }, { code: 'WRONG_MERGE_RECOVERY_UNPROVEN' as const, - detail: - 'A behaviorally tested wrong-merge recovery path is required before merge execution can be enabled.', + detail: 'A behaviorally tested wrong-merge recovery path is required before merge execution can be enabled.', ownerKey: 'party.registry', }, ] as const; -export const analyzePreparedMergeReadiness = ( - input: PreparedMergeReadinessInput, -): MergeReadinessResult => { +export const analyzePreparedMergeReadiness = (input: PreparedMergeReadinessInput): MergeReadinessResult => { const selection = selectCanonicalSurvivor(input.selectionInput); const partyRefs = input.selectionInput.candidates.map(({ partyRef }) => partyRef); const selectionAnalysis = Match.value(selection).pipe( @@ -89,16 +81,13 @@ export const analyzePreparedMergeReadiness = ( Match.exhaustive, ); const survivorPartyRef = - selectionAnalysis.selectedSurvivorPartyRef ?? - partyRefs[0] ?? - input.collisionInput.survivorPartyRef; + selectionAnalysis.selectedSurvivorPartyRef ?? partyRefs[0] ?? input.collisionInput.survivorPartyRef; // Analyze the actual selection set, never a separately supplied collision target set. const collisions = analyzeMergeCollisions({ ...input.collisionInput, absorbedPartyRefs: partyRefs.filter( (partyRef) => - partyRef.resourceId !== survivorPartyRef.resourceId || - partyRef.tenantId !== survivorPartyRef.tenantId, + partyRef.resourceId !== survivorPartyRef.resourceId || partyRef.tenantId !== survivorPartyRef.tenantId, ), survivorPartyRef, }); @@ -139,21 +128,14 @@ export const analyzePreparedMergeReadiness = ( selectedSurvivorPartyRef: selectionAnalysis.selectedSurvivorPartyRef, selectionStatus: selectionAnalysis.status, }, - blockers: [ - ...baseBlockers(), - ...selectionAnalysis.blockers, - ...collisionBlockers, - ...referenceAnalysis.blockers, - ], + blockers: [...baseBlockers(), ...selectionAnalysis.blockers, ...collisionBlockers, ...referenceAnalysis.blockers], mergeExecutionEnabled: false, partyRefs, status: 'DISABLED', }; }; -export const evaluateDisabledMergeReadiness = ( - partyRefs: readonly PartyRef[], -): MergeReadinessResult => { +export const evaluateDisabledMergeReadiness = (partyRefs: readonly PartyRef[]): MergeReadinessResult => { const unavailable = analyzePreparedMergeReadiness({ aliases: [], collisionInput: { @@ -192,8 +174,7 @@ export const evaluateDisabledMergeReadiness = ( ...unavailable.blockers, { code: 'PREPARED_STATE_UNAVAILABLE', - detail: - 'This read-only boundary has no canonical prepared merge state for the requested Parties.', + detail: 'This read-only boundary has no canonical prepared merge state for the requested Parties.', ownerKey: 'party.registry', }, ], diff --git a/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts b/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts index 7938004d9..be7e8fb3f 100644 --- a/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts +++ b/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { Context, Effect, Option } from 'effect'; + import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; import { PartyAliasResolutionBrokenChain, @@ -23,10 +24,7 @@ export interface PartyAliasLookup { tenantId: string, aliasPartyId: string, ) => Effect.Effect, PartyAliasResolutionUnavailable>; - readonly partyExists: ( - tenantId: string, - partyId: string, - ) => Effect.Effect; + readonly partyExists: (tenantId: string, partyId: string) => Effect.Effect; } export interface ResolvedPartyAlias { @@ -47,10 +45,9 @@ export interface PartyAliasResolutionService { ) => Effect.Effect; } -class PartyAliasResolution extends Context.Service< - PartyAliasResolution, - PartyAliasResolutionService ->()('@app/party-registry/merge/party-alias-resolution.service/PartyAliasResolution') {} +class PartyAliasResolution extends Context.Service()( + '@app/party-registry/merge/party-alias-resolution.service/PartyAliasResolution', +) {} const partyRef = (tenantId: string, resourceId: string): PartyRef => ({ moduleId: 'party.registry', @@ -59,9 +56,7 @@ const partyRef = (tenantId: string, resourceId: string): PartyRef => ({ tenantId, }); -export const makePartyAliasResolutionService = ( - lookup: PartyAliasLookup, -): PartyAliasResolutionService => { +export const makePartyAliasResolutionService = (lookup: PartyAliasLookup): PartyAliasResolutionService => { type ResolveFrom = ( tenantId: string, requestedPartyId: string, @@ -69,9 +64,13 @@ export const makePartyAliasResolutionService = ( seen: ReadonlySet, traversedAliasIds: readonly string[], ) => Effect.Effect; - const resolveFrom: ResolveFrom = Effect.fn( - 'makePartyAliasResolutionService.resolvePartyAlias.step', - )((tenantId, requestedPartyId, currentPartyId, seen, traversedAliasIds) => { + const resolveFrom: ResolveFrom = Effect.fn('makePartyAliasResolutionService.resolvePartyAlias.step')(( + tenantId, + requestedPartyId, + currentPartyId, + seen, + traversedAliasIds, + ) => { if (seen.has(currentPartyId)) { return new PartyAliasResolutionCycle({ code: 'party_alias_resolution_cycle', @@ -110,13 +109,10 @@ export const makePartyAliasResolutionService = ( tenantId, }) : Effect.suspend(() => - resolveFrom( - tenantId, - requestedPartyId, - alias.canonicalPartyId, - new Set([...seen, currentPartyId]), - [...traversedAliasIds, currentPartyId], - ), + resolveFrom(tenantId, requestedPartyId, alias.canonicalPartyId, new Set([...seen, currentPartyId]), [ + ...traversedAliasIds, + currentPartyId, + ]), ), }), ), @@ -129,15 +125,15 @@ export const makePartyAliasResolutionService = ( return PartyAliasResolution.of({ requireCanonicalWriteTarget: (tenantId, requestedPartyId) => resolvePartyAlias(tenantId, requestedPartyId).pipe( - Effect.flatMap((resolution) => - resolution.wasAlias - ? new PartyAliasWriteRejected({ - aliasPartyRef: partyRef(tenantId, requestedPartyId), - canonicalPartyRef: partyRef(tenantId, resolution.canonicalPartyId), - code: 'party_alias_write_rejected', - reason: 'New writes must explicitly target the canonical survivor Party', - }) - : Effect.succeed(resolution), + Effect.filterOrFail( + (resolution) => !resolution.wasAlias, + (resolution) => + new PartyAliasWriteRejected({ + aliasPartyRef: partyRef(tenantId, requestedPartyId), + canonicalPartyRef: partyRef(tenantId, resolution.canonicalPartyId), + code: 'party_alias_write_rejected', + reason: 'New writes must explicitly target the canonical survivor Party', + }), ), ), resolvePartyAlias, @@ -157,9 +153,7 @@ const unavailable = (cause?: unknown) => cause, ); -const makeTransactionPartyAliasResolutionService = ( - transaction: AliasTransaction, -): PartyAliasResolutionService => +const makeTransactionPartyAliasResolutionService = (transaction: AliasTransaction): PartyAliasResolutionService => makePartyAliasResolutionService({ findAlias: (tenantId, aliasPartyId) => transaction @@ -169,9 +163,7 @@ const makeTransactionPartyAliasResolutionService = ( tenantId: partyAliases.tenantId, }) .from(partyAliases) - .where( - and(eq(partyAliases.tenantId, tenantId), eq(partyAliases.aliasPartyId, aliasPartyId)), - ) + .where(and(eq(partyAliases.tenantId, tenantId), eq(partyAliases.aliasPartyId, aliasPartyId))) .limit(1) .pipe( Effect.mapError(unavailable), @@ -189,18 +181,8 @@ const makeTransactionPartyAliasResolutionService = ( ), }); -export const resolvePartyAlias = ( - transaction: AliasTransaction, - tenantId: string, - partyId: string, -) => makeTransactionPartyAliasResolutionService(transaction).resolvePartyAlias(tenantId, partyId); +export const resolvePartyAlias = (transaction: AliasTransaction, tenantId: string, partyId: string) => + makeTransactionPartyAliasResolutionService(transaction).resolvePartyAlias(tenantId, partyId); -export const requireCanonicalPartyWriteTarget = ( - transaction: AliasTransaction, - tenantId: string, - partyId: string, -) => - makeTransactionPartyAliasResolutionService(transaction).requireCanonicalWriteTarget( - tenantId, - partyId, - ); +export const requireCanonicalPartyWriteTarget = (transaction: AliasTransaction, tenantId: string, partyId: string) => + makeTransactionPartyAliasResolutionService(transaction).requireCanonicalWriteTarget(tenantId, partyId); diff --git a/app/verticals/party-registry/src/merge/party-alias-resolution.ts b/app/verticals/party-registry/src/merge/party-alias-resolution.ts index 78a79c631..5f0b37e3f 100644 --- a/app/verticals/party-registry/src/merge/party-alias-resolution.ts +++ b/app/verticals/party-registry/src/merge/party-alias-resolution.ts @@ -1,12 +1,19 @@ +import { Match, Schema } from 'effect'; + import type { PartyAlias } from '../../shared/resources/party-alias.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; -import { Match, Schema } from 'effect'; const AliasResolutionRejectionSchema = Schema.Union([ - Schema.TaggedStruct('PartyAliasCycleRejected', { aliasPartyRef: PartyRefSchema }), - Schema.TaggedStruct('PartyAliasSelfReferenceRejected', { aliasPartyRef: PartyRefSchema }), - Schema.TaggedStruct('PartyAliasCrossTenantRejected', { aliasPartyRef: PartyRefSchema }), + Schema.TaggedStruct('PartyAliasCycleRejected', { + aliasPartyRef: PartyRefSchema, + }), + Schema.TaggedStruct('PartyAliasSelfReferenceRejected', { + aliasPartyRef: PartyRefSchema, + }), + Schema.TaggedStruct('PartyAliasCrossTenantRejected', { + aliasPartyRef: PartyRefSchema, + }), ]); const CanonicalPartyResolutionSchema = Schema.Union([ AliasResolutionRejectionSchema, @@ -40,14 +47,23 @@ export const resolveCanonicalPartyRef = ( return traversed.length > 0 ? { ...resolved, requestedAlias: requested } : resolved; } if (alias.aliasPartyRef.tenantId !== alias.survivorPartyRef.tenantId) { - return { _tag: 'PartyAliasCrossTenantRejected', aliasPartyRef: alias.aliasPartyRef }; + return { + _tag: 'PartyAliasCrossTenantRejected', + aliasPartyRef: alias.aliasPartyRef, + }; } if (alias.aliasPartyRef.resourceId === alias.survivorPartyRef.resourceId) { - return { _tag: 'PartyAliasSelfReferenceRejected', aliasPartyRef: alias.aliasPartyRef }; + return { + _tag: 'PartyAliasSelfReferenceRejected', + aliasPartyRef: alias.aliasPartyRef, + }; } const currentKey = keyOf(alias.aliasPartyRef); if (seen.has(currentKey)) { - return { _tag: 'PartyAliasCycleRejected', aliasPartyRef: alias.aliasPartyRef }; + return { + _tag: 'PartyAliasCycleRejected', + aliasPartyRef: alias.aliasPartyRef, + }; } seen.add(currentKey); traversed.push(alias.aliasPartyRef); @@ -60,7 +76,10 @@ export const assertCanonicalWriteTarget = (requested: PartyRef, aliases: readonl return Match.value(resolved).pipe( Match.tag('CanonicalPartyResolved', (resolution) => resolution.requestedAlias === undefined - ? ({ _tag: 'CanonicalWriteTargetAccepted', partyRef: requested } as const) + ? ({ + _tag: 'CanonicalWriteTargetAccepted', + partyRef: requested, + } as const) : ({ _tag: 'AliasWriteRejected', aliasPartyRef: requested, diff --git a/app/verticals/party-registry/src/merge/reference-preservation-plan.ts b/app/verticals/party-registry/src/merge/reference-preservation-plan.ts index 06b1186bd..c97dbc83b 100644 --- a/app/verticals/party-registry/src/merge/reference-preservation-plan.ts +++ b/app/verticals/party-registry/src/merge/reference-preservation-plan.ts @@ -1,6 +1,7 @@ +import { Match, Option, Schema } from 'effect'; + import type { PartyAlias } from '../../shared/resources/party-alias.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; -import { Match, Option, Schema } from 'effect'; import { resolveCanonicalPartyRef } from './party-alias-resolution.ts'; const SupportedReferenceClassSchema = Schema.Literals([ @@ -13,10 +14,7 @@ const SupportedReferenceClassSchema = Schema.Literals([ 'HISTORICAL_DOCUMENT', ]); type SupportedReferenceClass = typeof SupportedReferenceClassSchema.Type; -const ReferenceClassSchema = Schema.Union([ - SupportedReferenceClassSchema, - Schema.Literal('UNSUPPORTED'), -]); +const ReferenceClassSchema = Schema.Union([SupportedReferenceClassSchema, Schema.Literal('UNSUPPORTED')]); type ReferenceClass = typeof ReferenceClassSchema.Type; interface HistoricalPartySnapshot { readonly address?: string; @@ -73,12 +71,13 @@ const collectReferenceBlockers = ( consumerReconciliation: readonly ConsumerReconciliationContract[] | undefined, ): ReferenceBlocker[] => { const blockers: ReferenceBlocker[] = []; - const contracts = new Map( - (consumerReconciliation ?? []).map((contract) => [contract.consumerKey, contract]), - ); + const contracts = new Map((consumerReconciliation ?? []).map((contract) => [contract.consumerKey, contract])); for (const reference of references) { if (reference.class === 'UNSUPPORTED') { - blockers.push({ code: 'UNSUPPORTED_REFERENCE_CLASS', ownerKey: reference.ownerKey }); + blockers.push({ + code: 'UNSUPPORTED_REFERENCE_CLASS', + ownerKey: reference.ownerKey, + }); continue; } const blocker = consumerContractBlocker(reference.ownerKey, contracts.get(reference.ownerKey)); @@ -100,11 +99,7 @@ export const planReferencePreservation = ( if (blockers.length > 0) { return { _tag: 'ReferencePreservationBlocked', - blockers: [ - ...new Map( - blockers.map((blocker) => [`${blocker.code}:${blocker.ownerKey}`, blocker]), - ).values(), - ], + blockers: [...new Map(blockers.map((blocker) => [`${blocker.code}:${blocker.ownerKey}`, blocker])).values()], } as const; } @@ -143,7 +138,10 @@ export const planReferencePreservation = ( references.push( reference.historicalSnapshot === undefined ? planned.value - : { ...planned.value, historicalSnapshot: reference.historicalSnapshot }, + : { + ...planned.value, + historicalSnapshot: reference.historicalSnapshot, + }, ); } } diff --git a/app/verticals/party-registry/src/modern.runtime.ts b/app/verticals/party-registry/src/modern.runtime.ts index 494c255e2..fa2b9caa0 100644 --- a/app/verticals/party-registry/src/modern.runtime.ts +++ b/app/verticals/party-registry/src/modern.runtime.ts @@ -1,6 +1,7 @@ import { assertI18nInstance } from '@modern-js/plugin-i18n/i18n'; import { defineRuntimeConfig } from '@modern-js/runtime'; import { createInstance } from 'i18next'; + import csResource from '../locales/cs/translation.json' with { type: 'json' }; import enResource from '../locales/en/translation.json' with { type: 'json' }; import { partyRegistryI18nResources } from './i18n/resources.ts'; diff --git a/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts b/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts index a0a42f909..745f57cff 100644 --- a/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts +++ b/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from 'effect'; + import { PartySubjectEligibilityVersion, PartyEvidenceInsufficient, @@ -71,10 +72,12 @@ const CreateWithoutStrongIdentifierDecisionSchema = Schema.Union([ decision: Schema.Literal('REVIEW_REQUIRED'), reasonCode: Schema.Literal('identity_review_required'), }), - Schema.Struct({ decision: Schema.Literal('DENY'), reasonCode: Schema.String }), + Schema.Struct({ + decision: Schema.Literal('DENY'), + reasonCode: Schema.String, + }), ]); -export type CreateWithoutStrongIdentifierDecision = - typeof CreateWithoutStrongIdentifierDecisionSchema.Type; +export type CreateWithoutStrongIdentifierDecision = typeof CreateWithoutStrongIdentifierDecisionSchema.Type; export const decideCreateWithoutStrongIdentifier = ( candidate: PartyCandidate, configuration: CreateWithoutStrongIdentifierPolicyConfiguration, diff --git a/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx b/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx index 4ff9ef222..3c33d38ce 100644 --- a/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx +++ b/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx @@ -1,4 +1,5 @@ import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; + import { UltramodernRouteHead } from '../../ultramodern-route-head'; const ContactsPage = () => { diff --git a/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts b/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts index a1c6481ab..f4e533578 100644 --- a/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts +++ b/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts @@ -1,4 +1,4 @@ -// @generated by @modern-js/create. +// @generated by @modern-js/ultramodern-create. // Author route metadata in colocated src/routes/**/route.meta.ts files. // This compatibility manifest is regenerated from route-owned metadata. diff --git a/app/verticals/party-registry/src/search-normalization.ts b/app/verticals/party-registry/src/search-normalization.ts index 51fc3e035..ff8537487 100644 --- a/app/verticals/party-registry/src/search-normalization.ts +++ b/app/verticals/party-registry/src/search-normalization.ts @@ -1,4 +1,5 @@ import { Effect, Match } from 'effect'; + import { PartySearchProjectionUnavailable } from '../shared/domain/search-projection-error.ts'; import type { SearchNormalizationResult } from '../shared/domain/search-semantics.ts'; diff --git a/app/verticals/party-registry/src/search/counterparties.provider.ts b/app/verticals/party-registry/src/search/counterparties.provider.ts index 2ec67b09d..b452385ac 100644 --- a/app/verticals/party-registry/src/search/counterparties.provider.ts +++ b/app/verticals/party-registry/src/search/counterparties.provider.ts @@ -1,6 +1,3 @@ -// @generated by OntOS Codesmith Governed Contribution v1 -// @ontos-contribution-kind search-provider -import { DateTime, Effect } from 'effect'; import { OperationContextUnavailable, ReadHandlerUnavailable, @@ -8,6 +5,10 @@ import { defineTenantModuleEntrypoint, } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; +// @generated by OntOS Codesmith Governed Contribution v1 +// @ontos-contribution-kind search-provider +import { DateTime, Effect } from 'effect'; + import { CounterpartiesProviderRequestSchema, CounterpartiesProviderResponseSchema, @@ -16,9 +17,9 @@ import type { CounterpartiesProviderRequest, CounterpartiesProviderResponse, } from '../../shared/apis/counterparties-search.ts'; +import type { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { PartySearchProjectionGateway } from '../../shared/domain/search-projection-gateway.ts'; import type { PartySearchProjectionGatewayService as PartySearchProjectionGatewayPort } from '../../shared/domain/search-projection-gateway.ts'; -import type { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; import { resolveSearchNormalization } from '../search-normalization.ts'; @@ -47,7 +48,10 @@ const readHandlerUnavailable = (cause: PartySearchProjectionUnavailable) => { export const loadCounterpartySearch = ( gateway: PartySearchProjectionGatewayPort, - scope: Readonly<{ readonly legalEntityId: string; readonly tenantId: string }>, + scope: Readonly<{ + readonly legalEntityId: string; + readonly tenantId: string; + }>, input: CounterpartiesProviderRequest, effectiveAt: string, ) => { @@ -61,11 +65,7 @@ export const loadCounterpartySearch = ( const query = input.role === undefined ? baseQuery : { ...baseQuery, role: input.role }; return gateway .searchCounterparties(query) - .pipe( - Effect.flatMap((hits) => - resolveSearchNormalization(normalizeCounterpartySearchHits(query, hits)), - ), - ); + .pipe(Effect.flatMap((hits) => resolveSearchNormalization(normalizeCounterpartySearchHits(query, hits)))); }; export const counterpartiesRead = defineRead( @@ -93,32 +93,25 @@ export const counterpartiesRead = defineRead( result, })), ), - Effect.fn('CounterpartiesProvider.counterpartiesRead')( - function* makeCounterpartySearchServices(_transaction, scope) { - const gateway = yield* PartySearchProjectionGateway; - const { legalEntityId } = scope; - if (legalEntityId === undefined) { - return yield* new OperationContextUnavailable({ - code: 'operation_context_unavailable', - reason: 'Counterparty Search requires trusted Legal Entity context', - }); - } - return { - load: (input: CounterpartiesProviderRequest) => - DateTime.now.pipe( - Effect.map(DateTime.formatIso), - Effect.flatMap((effectiveAt) => - loadCounterpartySearch( - gateway, - { legalEntityId, tenantId: scope.tenantId }, - input, - effectiveAt, - ), - ), + Effect.fn('CounterpartiesProvider.counterpartiesRead')(function* makeCounterpartySearchServices(_transaction, scope) { + const gateway = yield* PartySearchProjectionGateway; + const { legalEntityId } = scope; + if (legalEntityId === undefined) { + return yield* new OperationContextUnavailable({ + code: 'operation_context_unavailable', + reason: 'Counterparty Search requires trusted Legal Entity context', + }); + } + return { + load: (input: CounterpartiesProviderRequest) => + DateTime.now.pipe( + Effect.map(DateTime.formatIso), + Effect.flatMap((effectiveAt) => + loadCounterpartySearch(gateway, { legalEntityId, tenantId: scope.tenantId }, input, effectiveAt), ), - }; - }, - ), + ), + }; + }), () => ({ kind: 'legal_entity', permission: 'read_counterparty' }), (result) => result.map(({ ref }) => ref), ); diff --git a/app/verticals/party-registry/src/search/parties.provider.ts b/app/verticals/party-registry/src/search/parties.provider.ts index aa8654251..05f01c836 100644 --- a/app/verticals/party-registry/src/search/parties.provider.ts +++ b/app/verticals/party-registry/src/search/parties.provider.ts @@ -1,6 +1,3 @@ -// @generated by OntOS Codesmith Governed Contribution v1 -// @ontos-contribution-kind search-provider -import { Effect, Layer, Schema } from 'effect'; import { CoreSearchQueryRuntime, ReadHandlerUnavailable, @@ -14,23 +11,21 @@ import type { OperationalScope, ReadHandlerContext, } from '@app/core-runtime'; -import { - PartiesProviderRequestSchema, - PartiesProviderResponseSchema, -} from '../../shared/apis/parties-search.ts'; -import type { - PartiesProviderRequest, - PartiesProviderResponse, -} from '../../shared/apis/parties-search.ts'; +// @generated by OntOS Codesmith Governed Contribution v1 +// @ontos-contribution-kind search-provider +import { Effect, Layer, Schema } from 'effect'; + +import { PartiesProviderRequestSchema, PartiesProviderResponseSchema } from '../../shared/apis/parties-search.ts'; +import type { PartiesProviderRequest, PartiesProviderResponse } from '../../shared/apis/parties-search.ts'; +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { PartySearchProjectionGateway } from '../../shared/domain/search-projection-gateway.ts'; import type { CounterpartySearchProjectionQuery, PartySearchProjectionGatewayService as PartySearchProjectionGatewayPort, PartySearchProjectionQuery, } from '../../shared/domain/search-projection-gateway.ts'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; -import { normalizePartySearchHits } from '../../shared/domain/search-semantics.ts'; import { CurrentCounterpartyRoleSchema } from '../../shared/domain/search-result.ts'; +import { normalizePartySearchHits } from '../../shared/domain/search-semantics.ts'; import { CounterpartyRefSchema } from '../../shared/resources/counterparty.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import { resolveSearchNormalization } from '../search-normalization.ts'; @@ -73,22 +68,19 @@ const decodePartyRef = (input: CoreSearchResourceRef) => Schema.decodeUnknownEffect(PartyRefSchema)(input).pipe(Effect.mapError(projectionUnavailable)); const decodeCounterpartyRef = (input: CoreSearchResourceRef) => - Schema.decodeUnknownEffect(CounterpartyRefSchema)(input).pipe( - Effect.mapError(projectionUnavailable), - ); + Schema.decodeUnknownEffect(CounterpartyRefSchema)(input).pipe(Effect.mapError(projectionUnavailable)); -const mapPartyProjectionHit = Effect.fn('PartiesProvider.mapPartyProjectionHit')( - function* mapPartyHit(hit: CoreSearchProjectionHit) { - if (hit.subjectRef !== undefined || hit.matchedSubjectRef !== undefined) { - return yield* projectionUnavailable(); - } - const canonicalPartyRef = yield* decodePartyRef(hit.ref); - const matchedPartyRef = - hit.matchedRef === undefined ? undefined : yield* decodePartyRef(hit.matchedRef); - const base = { archived: hit.archived, canonicalPartyRef, title: hit.title }; - return matchedPartyRef === undefined ? base : { ...base, matchedPartyRef }; - }, -); +const mapPartyProjectionHit = Effect.fn('PartiesProvider.mapPartyProjectionHit')(function* mapPartyHit( + hit: CoreSearchProjectionHit, +) { + if (hit.subjectRef !== undefined || hit.matchedSubjectRef !== undefined) { + return yield* projectionUnavailable(); + } + const canonicalPartyRef = yield* decodePartyRef(hit.ref); + const matchedPartyRef = hit.matchedRef === undefined ? undefined : yield* decodePartyRef(hit.matchedRef); + const base = { archived: hit.archived, canonicalPartyRef, title: hit.title }; + return matchedPartyRef === undefined ? base : { ...base, matchedPartyRef }; +}); const mapRolePeriod = (facet: NonNullable[number]) => Schema.decodeUnknownEffect(CurrentCounterpartyRoleSchema)(facet.value).pipe( @@ -101,11 +93,7 @@ const mapRolePeriod = (facet: NonNullable key === 'current-role'), mapRolePeriod, @@ -166,7 +152,9 @@ export const makePartySearchProjectionGateway = ( ) .pipe( Effect.flatMap((hits) => - Effect.forEach(hits, mapCounterpartyProjectionHit, { concurrency: 1 }), + Effect.forEach(hits, mapCounterpartyProjectionHit, { + concurrency: 1, + }), ), Effect.mapError(projectionUnavailable), ), @@ -207,7 +195,10 @@ export const loadPartySearch = ( .pipe( Effect.flatMap((hits) => { const normalized = normalizePartySearchHits( - { includeArchived: input.includeArchived ?? false, tenantId: scope.tenantId }, + { + includeArchived: input.includeArchived ?? false, + tenantId: scope.tenantId, + }, hits, ); return resolveSearchNormalization(normalized); @@ -239,12 +230,11 @@ export const partiesRead = defineRead( result, })), ), - Effect.fn('PartiesProvider.partiesRead')(function* makePartySearchServices( - _transaction, - scope: OperationalScope, - ) { + Effect.fn('PartiesProvider.partiesRead')(function* makePartySearchServices(_transaction, scope: OperationalScope) { const gateway = yield* PartySearchProjectionGateway; - return { load: (input: PartiesProviderRequest) => loadPartySearch(gateway, scope, input) }; + return { + load: (input: PartiesProviderRequest) => loadPartySearch(gateway, scope, input), + }; }), () => ({ kind: 'tenant', permission: 'read_party_identity' }), (result) => result.map(({ ref }) => ref), diff --git a/app/verticals/party-registry/src/services/counterparty-persistence.service.ts b/app/verticals/party-registry/src/services/counterparty-persistence.service.ts index 0b4ab2524..9bf69fa11 100644 --- a/app/verticals/party-registry/src/services/counterparty-persistence.service.ts +++ b/app/verticals/party-registry/src/services/counterparty-persistence.service.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith Action Service v1 import { and, asc, eq, gt, inArray, isNull, lt, lte, or } from 'drizzle-orm'; import { DateTime, Effect, Match, Option, Schema } from 'effect'; + import type { CounterpartyProvenance, CounterpartyRecord, @@ -15,10 +16,7 @@ import { legalEntityRef, } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; -import { - roleEndEvidenceIsSufficient, - rolePeriodStorageStateAt, -} from '../../shared/domain/counterparty-role-period.ts'; +import { roleEndEvidenceIsSufficient, rolePeriodStorageStateAt } from '../../shared/domain/counterparty-role-period.ts'; import type { CounterpartyRolePeriodRef } from '../../shared/resources/counterparty-role-period.ts'; import type { CounterpartyRef } from '../../shared/resources/counterparty.ts'; import { CounterpartyRefSchema } from '../../shared/resources/counterparty.ts'; @@ -32,10 +30,7 @@ import { parties, } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; -import { - requireCanonicalPartyWriteTarget, - resolvePartyAlias, -} from '../merge/party-alias-resolution.service.ts'; +import { requireCanonicalPartyWriteTarget, resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; type CounterpartyTransaction = Pick; type CounterpartyRow = typeof counterparties.$inferSelect; @@ -68,7 +63,9 @@ const CreateCounterpartyResultSchema = Schema.Union([ export type CreateCounterpartyResult = typeof CreateCounterpartyResultSchema.Type; const AddCounterpartyRoleResultSchema = Schema.Union([ - Schema.TaggedStruct('counterparty_not_found', { counterpartyId: CounterpartyUuidSchema }), + Schema.TaggedStruct('counterparty_not_found', { + counterpartyId: CounterpartyUuidSchema, + }), Schema.TaggedStruct('overlap', { roleType: CounterpartyRoleTypeSchema }), Schema.TaggedStruct('party_archived', { partyId: CounterpartyUuidSchema }), Schema.TaggedStruct('found', { value: CounterpartyRolePeriodSchema }), @@ -76,13 +73,19 @@ const AddCounterpartyRoleResultSchema = Schema.Union([ export type AddCounterpartyRoleResult = typeof AddCounterpartyRoleResultSchema.Type; const EndCounterpartyRoleResultSchema = Schema.Union([ - Schema.TaggedStruct('already_ended', { rolePeriodId: CounterpartyUuidSchema }), - Schema.TaggedStruct('counterparty_not_found', { counterpartyId: CounterpartyUuidSchema }), + Schema.TaggedStruct('already_ended', { + rolePeriodId: CounterpartyUuidSchema, + }), + Schema.TaggedStruct('counterparty_not_found', { + counterpartyId: CounterpartyUuidSchema, + }), Schema.TaggedStruct('evidence_insufficient', { method: Schema.String, roleType: CounterpartyRoleTypeSchema, }), - Schema.TaggedStruct('role_not_found', { rolePeriodId: CounterpartyUuidSchema }), + Schema.TaggedStruct('role_not_found', { + rolePeriodId: CounterpartyUuidSchema, + }), Schema.TaggedStruct('temporal_conflict', {}), Schema.TaggedStruct('found', { changed: Schema.Boolean, @@ -206,10 +209,7 @@ const roleDto = (row: RolePeriodReadRow, effectiveAt?: Date): CounterpartyRolePe // SAFETY: the owner table's role-type CHECK constraint admits only the V1 closed catalog. roleType: row.roleType as CounterpartyRoleType, // SAFETY: the owner table's lifecycle CHECK constraint admits only assertion lifecycle states. - state: (row.state === 'ACTIVE' && - effectiveAt !== undefined && - row.validTo !== null && - row.validTo <= effectiveAt + state: (row.state === 'ACTIVE' && effectiveAt !== undefined && row.validTo !== null && row.validTo <= effectiveAt ? 'ENDED' : row.state) as CounterpartyRolePeriod['state'], validFrom: row.validFrom.toISOString(), @@ -217,11 +217,7 @@ const roleDto = (row: RolePeriodReadRow, effectiveAt?: Date): CounterpartyRolePe }); const resolveCanonicalParty = Effect.fn('CounterpartyPersistenceService.resolveCanonicalParty')( - function* resolveCanonicalPartyRow( - transaction: CounterpartyTransaction, - tenantId: string, - storedPartyId: string, - ) { + function* resolveCanonicalPartyRow(transaction: CounterpartyTransaction, tenantId: string, storedPartyId: string) { const resolution = yield* resolvePartyAlias(transaction, tenantId, storedPartyId); const [party] = yield* transaction .select() @@ -342,10 +338,7 @@ const syncRoleReadModel = (transaction: CounterpartyTransaction, row: RolePeriod .values(values) .onConflictDoUpdate({ set: values, - target: [ - counterpartyRoleAdminReadModels.tenantId, - counterpartyRoleAdminReadModels.rolePeriodId, - ], + target: [counterpartyRoleAdminReadModels.tenantId, counterpartyRoleAdminReadModels.rolePeriodId], }) .pipe(Effect.mapError(unavailable)); }; @@ -377,10 +370,7 @@ const listRoleReadRows = ( : undefined, ), ) - .orderBy( - asc(counterpartyRoleAdminReadModels.validFrom), - asc(counterpartyRoleAdminReadModels.rolePeriodId), - ) + .orderBy(asc(counterpartyRoleAdminReadModels.validFrom), asc(counterpartyRoleAdminReadModels.rolePeriodId)) .pipe(Effect.mapError(unavailable)) : transaction .select() @@ -394,10 +384,7 @@ const listRoleReadRows = ( ? and( eq(counterpartyRolePeriods.state, 'ACTIVE'), lte(counterpartyRolePeriods.validFrom, effectiveAt), - or( - isNull(counterpartyRolePeriods.validTo), - gt(counterpartyRolePeriods.validTo, effectiveAt), - ), + or(isNull(counterpartyRolePeriods.validTo), gt(counterpartyRolePeriods.validTo, effectiveAt)), ) : undefined, ), @@ -405,97 +392,94 @@ const listRoleReadRows = ( .orderBy(asc(counterpartyRolePeriods.validFrom), asc(counterpartyRolePeriods.rolePeriodId)) .pipe(Effect.mapError(unavailable)); -export const createCounterpartyRecord = Effect.fn( - 'CounterpartyPersistenceService.createCounterpartyRecord', -)(function* createCounterparty( - transaction: CounterpartyTransaction, - input: CreateCounterpartyInput, -): Effect.fn.Return { - const writeTarget = yield* requireCanonicalPartyWriteTarget( - transaction, - input.tenantId, - input.partyId, - ).pipe( - Effect.map(() => ({ _tag: 'canonical' }) as const), - Effect.catchTags({ - PartyAliasResolutionBrokenChain: (error) => - error.missingPartyId === input.partyId - ? Effect.succeed({ _tag: 'party_not_found' as const, partyId: input.partyId }) - : Effect.fail(unavailable()), - PartyAliasWriteRejected: (error) => - Effect.succeed({ - _tag: 'party_alias' as const, - aliasPartyRef: error.aliasPartyRef, - canonicalPartyRef: error.canonicalPartyRef, - }), - }), - Effect.mapError(unavailable), - ); - const nonCanonicalResult: Option.Option = Match.value(writeTarget).pipe( - Match.tag('canonical', () => Option.none()), - Match.tag('party_alias', 'party_not_found', (result) => - Option.some(result), - ), - Match.exhaustive, - ); - if (Option.isSome(nonCanonicalResult)) { - return nonCanonicalResult.value; - } - const resolved = yield* resolveCanonicalParty(transaction, input.tenantId, input.partyId); - if (Option.isNone(resolved)) { - return { _tag: 'party_not_found', partyId: input.partyId } as const; - } - if (resolved.value.archivedAt !== null) { - return { _tag: 'party_archived', partyId: resolved.value.partyId } as const; - } - const inserted = yield* transaction - .insert(counterparties) - .values({ - acceptedByActionInvocationId: input.actionInvocationId, - acceptedByPrincipalId: input.principalId, - creationReason: input.provenance.reason ?? input.provenance.method, - evidenceRefs: [input.provenance.evidenceReference], - legalEntityId: input.legalEntityId, - partyId: resolved.value.partyId, - policyVersion: input.policyVersion, - provenanceMethod: input.provenance.method, - provenanceSource: input.provenance.source, - sourceRecordRefs: [], - tenantId: input.tenantId, - }) - .onConflictDoNothing() - .returning() - .pipe(Effect.mapError(unavailable)); - const [created] = inserted; - const existing = - created ?? - (yield* transaction - .select() - .from(counterparties) - .where( - and( - eq(counterparties.tenantId, input.tenantId), - eq(counterparties.legalEntityId, input.legalEntityId), - eq(counterparties.partyId, resolved.value.partyId), - ), - ) - .limit(1) - .pipe( - Effect.mapError(unavailable), - Effect.map((rows) => rows[0]), - )); - if (existing === undefined) { - return yield* unavailable(); - } - yield* syncCounterpartyReadModel(transaction, existing); - return { - _tag: 'found', - counterpartyRef: counterpartyRef(input.tenantId, existing.counterpartyId), - created: created !== undefined, - legalEntityRef: legalEntityRef(input.tenantId, input.legalEntityId), - partyRef: partyRef(input.tenantId, resolved.value.partyId), - } as const; -}); +export const createCounterpartyRecord = Effect.fn('CounterpartyPersistenceService.createCounterpartyRecord')( + function* createCounterparty( + transaction: CounterpartyTransaction, + input: CreateCounterpartyInput, + ): Effect.fn.Return { + const writeTarget = yield* requireCanonicalPartyWriteTarget(transaction, input.tenantId, input.partyId).pipe( + Effect.map(() => ({ _tag: 'canonical' }) as const), + Effect.catchTags({ + PartyAliasResolutionBrokenChain: (error) => + error.missingPartyId === input.partyId + ? Effect.succeed({ + _tag: 'party_not_found' as const, + partyId: input.partyId, + }) + : Effect.fail(unavailable()), + PartyAliasWriteRejected: (error) => + Effect.succeed({ + _tag: 'party_alias' as const, + aliasPartyRef: error.aliasPartyRef, + canonicalPartyRef: error.canonicalPartyRef, + }), + }), + Effect.mapError(unavailable), + ); + const nonCanonicalResult: Option.Option = Match.value(writeTarget).pipe( + Match.tag('canonical', () => Option.none()), + Match.tag('party_alias', 'party_not_found', (result) => Option.some(result)), + Match.exhaustive, + ); + if (Option.isSome(nonCanonicalResult)) { + return nonCanonicalResult.value; + } + const resolved = yield* resolveCanonicalParty(transaction, input.tenantId, input.partyId); + if (Option.isNone(resolved)) { + return { _tag: 'party_not_found', partyId: input.partyId } as const; + } + if (resolved.value.archivedAt !== null) { + return { _tag: 'party_archived', partyId: resolved.value.partyId } as const; + } + const inserted = yield* transaction + .insert(counterparties) + .values({ + acceptedByActionInvocationId: input.actionInvocationId, + acceptedByPrincipalId: input.principalId, + creationReason: input.provenance.reason ?? input.provenance.method, + evidenceRefs: [input.provenance.evidenceReference], + legalEntityId: input.legalEntityId, + partyId: resolved.value.partyId, + policyVersion: input.policyVersion, + provenanceMethod: input.provenance.method, + provenanceSource: input.provenance.source, + sourceRecordRefs: [], + tenantId: input.tenantId, + }) + .onConflictDoNothing() + .returning() + .pipe(Effect.mapError(unavailable)); + const [created] = inserted; + const existing = + created ?? + (yield* transaction + .select() + .from(counterparties) + .where( + and( + eq(counterparties.tenantId, input.tenantId), + eq(counterparties.legalEntityId, input.legalEntityId), + eq(counterparties.partyId, resolved.value.partyId), + ), + ) + .limit(1) + .pipe( + Effect.mapError(unavailable), + Effect.map((rows) => rows[0]), + )); + if (existing === undefined) { + return yield* unavailable(); + } + yield* syncCounterpartyReadModel(transaction, existing); + return { + _tag: 'found', + counterpartyRef: counterpartyRef(input.tenantId, existing.counterpartyId), + created: created !== undefined, + legalEntityRef: legalEntityRef(input.tenantId, input.legalEntityId), + partyRef: partyRef(input.tenantId, resolved.value.partyId), + } as const; + }, +); const roleEndEvidence = (input: AcceptedActionEvidence, recordedAt: Date, hasEnd: boolean) => { if (!hasEnd) { @@ -520,99 +504,94 @@ const roleEndEvidence = (input: AcceptedActionEvidence, recordedAt: Date, hasEnd }; }; -export const addCounterpartyRoleRecord = Effect.fn( - 'CounterpartyPersistenceService.addCounterpartyRoleRecord', -)(function* addCounterpartyRole( - transaction: CounterpartyTransaction, - input: AddCounterpartyRoleInput, -): Effect.fn.Return { - const counterparty = yield* findCounterpartyRow( - transaction, - input.tenantId, - input.legalEntityId, - input.counterpartyId, - true, - ); - if (Option.isNone(counterparty)) { - return { _tag: 'counterparty_not_found', counterpartyId: input.counterpartyId } as const; - } - const resolvedParty = yield* resolveCanonicalParty( - transaction, - input.tenantId, - counterparty.value.partyId, - ); - if (Option.isNone(resolvedParty)) { - return yield* unavailable(); - } - if (resolvedParty.value.archivedAt !== null) { - return { _tag: 'party_archived', partyId: resolvedParty.value.partyId } as const; - } - const validFrom = instantAsDate(input.validFrom); - const validTo = input.validTo === null ? null : instantAsDate(input.validTo); - const recordedAt = yield* DateTime.nowAsDate; - const lifecycle = rolePeriodStorageStateAt( - { validFrom: input.validFrom, validTo: input.validTo }, - recordedAt.toISOString(), - ); - const [overlap] = yield* transaction - .select() - .from(counterpartyRolePeriods) - .where( - and( - eq(counterpartyRolePeriods.tenantId, input.tenantId), - eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), - eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), - eq(counterpartyRolePeriods.roleType, input.roleType), - inArray(counterpartyRolePeriods.state, ['ACTIVE', 'ENDED']), +export const addCounterpartyRoleRecord = Effect.fn('CounterpartyPersistenceService.addCounterpartyRoleRecord')( + function* addCounterpartyRole( + transaction: CounterpartyTransaction, + input: AddCounterpartyRoleInput, + ): Effect.fn.Return { + const counterparty = yield* findCounterpartyRow( + transaction, + input.tenantId, + input.legalEntityId, + input.counterpartyId, + true, + ); + if (Option.isNone(counterparty)) { + return { + _tag: 'counterparty_not_found', + counterpartyId: input.counterpartyId, + } as const; + } + const resolvedParty = yield* resolveCanonicalParty(transaction, input.tenantId, counterparty.value.partyId); + if (Option.isNone(resolvedParty)) { + return yield* unavailable(); + } + if (resolvedParty.value.archivedAt !== null) { + return { + _tag: 'party_archived', + partyId: resolvedParty.value.partyId, + } as const; + } + const validFrom = instantAsDate(input.validFrom); + const validTo = input.validTo === null ? null : instantAsDate(input.validTo); + const recordedAt = yield* DateTime.nowAsDate; + const lifecycle = rolePeriodStorageStateAt( + { validFrom: input.validFrom, validTo: input.validTo }, + recordedAt.toISOString(), + ); + const [overlap] = yield* transaction + .select() + .from(counterpartyRolePeriods) + .where( and( - or( - isNull(counterpartyRolePeriods.validTo), - gt(counterpartyRolePeriods.validTo, validFrom), + eq(counterpartyRolePeriods.tenantId, input.tenantId), + eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), + eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), + eq(counterpartyRolePeriods.roleType, input.roleType), + inArray(counterpartyRolePeriods.state, ['ACTIVE', 'ENDED']), + and( + or(isNull(counterpartyRolePeriods.validTo), gt(counterpartyRolePeriods.validTo, validFrom)), + validTo === null ? undefined : lt(counterpartyRolePeriods.validFrom, validTo), ), - validTo === null ? undefined : lt(counterpartyRolePeriods.validFrom, validTo), ), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (overlap !== undefined) { - return { _tag: 'overlap', roleType: input.roleType } as const; - } - const [row] = yield* transaction - .insert(counterpartyRolePeriods) - .values({ - acceptedByActionInvocationId: input.actionInvocationId, - acceptedByPrincipalId: input.principalId, - addEvidenceRefs: [input.provenance.evidenceReference], - addReason: input.provenance.reason ?? input.provenance.method, - counterpartyId: input.counterpartyId, - ...roleEndEvidence(input, recordedAt, validTo !== null), - isCurrent: lifecycle.isCurrent, - legalEntityId: input.legalEntityId, - policyVersion: input.policyVersion, - provenanceMethod: input.provenance.method, - provenanceSource: input.provenance.source, - roleType: input.roleType, - state: lifecycle.state, - tenantId: input.tenantId, - validFrom, - validTo, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (row === undefined) { - return yield* unavailable(); - } - yield* syncCounterpartyReadModel(transaction, counterparty.value); - yield* syncRoleReadModel(transaction, row); - return { _tag: 'found', value: roleDto(row) } as const; -}); + ) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (overlap !== undefined) { + return { _tag: 'overlap', roleType: input.roleType } as const; + } + const [row] = yield* transaction + .insert(counterpartyRolePeriods) + .values({ + acceptedByActionInvocationId: input.actionInvocationId, + acceptedByPrincipalId: input.principalId, + addEvidenceRefs: [input.provenance.evidenceReference], + addReason: input.provenance.reason ?? input.provenance.method, + counterpartyId: input.counterpartyId, + ...roleEndEvidence(input, recordedAt, validTo !== null), + isCurrent: lifecycle.isCurrent, + legalEntityId: input.legalEntityId, + policyVersion: input.policyVersion, + provenanceMethod: input.provenance.method, + provenanceSource: input.provenance.source, + roleType: input.roleType, + state: lifecycle.state, + tenantId: input.tenantId, + validFrom, + validTo, + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (row === undefined) { + return yield* unavailable(); + } + yield* syncCounterpartyReadModel(transaction, counterparty.value); + yield* syncRoleReadModel(transaction, row); + return { _tag: 'found', value: roleDto(row) } as const; + }, +); -const repeatsRecordedRoleEnd = ( - current: RolePeriodRow, - input: EndCounterpartyRoleInput, - validTo: Date, -): boolean => +const repeatsRecordedRoleEnd = (current: RolePeriodRow, input: EndCounterpartyRoleInput, validTo: Date): boolean => current.validTo !== null && DateTime.Equivalence(DateTime.makeUnsafe(current.validTo), DateTime.makeUnsafe(validTo)) && current.endProvenanceMethod === input.provenance.method && @@ -620,178 +599,162 @@ const repeatsRecordedRoleEnd = ( current.endEvidenceRefs?.[0] === input.provenance.evidenceReference && current.endReason === (input.provenance.reason ?? input.provenance.method); -export const endCounterpartyRoleRecord = Effect.fn( - 'CounterpartyPersistenceService.endCounterpartyRoleRecord', -)(function* endCounterpartyRole( - transaction: CounterpartyTransaction, - input: EndCounterpartyRoleInput, -): Effect.fn.Return { - const counterparty = yield* findCounterpartyRow( - transaction, - input.tenantId, - input.legalEntityId, - input.counterpartyId, - true, - ); - if (Option.isNone(counterparty)) { - return { _tag: 'counterparty_not_found', counterpartyId: input.counterpartyId } as const; - } - const [current] = yield* transaction - .select() - .from(counterpartyRolePeriods) - .where( - and( - eq(counterpartyRolePeriods.tenantId, input.tenantId), - eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), - eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), - eq(counterpartyRolePeriods.rolePeriodId, input.rolePeriodId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if (current === undefined) { - return { _tag: 'role_not_found', rolePeriodId: input.rolePeriodId } as const; - } - const validTo = instantAsDate(input.validTo); - if (validTo < current.validFrom) { - return { _tag: 'temporal_conflict' } as const; - } - // SAFETY: the owner table's role-type CHECK constraint admits only the V1 closed catalog. - const roleType = current.roleType as CounterpartyRoleType; - if (!roleEndEvidenceIsSufficient(roleType, input.provenance.method)) { +export const endCounterpartyRoleRecord = Effect.fn('CounterpartyPersistenceService.endCounterpartyRoleRecord')( + function* endCounterpartyRole( + transaction: CounterpartyTransaction, + input: EndCounterpartyRoleInput, + ): Effect.fn.Return { + const counterparty = yield* findCounterpartyRow( + transaction, + input.tenantId, + input.legalEntityId, + input.counterpartyId, + true, + ); + if (Option.isNone(counterparty)) { + return { + _tag: 'counterparty_not_found', + counterpartyId: input.counterpartyId, + } as const; + } + const [current] = yield* transaction + .select() + .from(counterpartyRolePeriods) + .where( + and( + eq(counterpartyRolePeriods.tenantId, input.tenantId), + eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), + eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), + eq(counterpartyRolePeriods.rolePeriodId, input.rolePeriodId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if (current === undefined) { + return { + _tag: 'role_not_found', + rolePeriodId: input.rolePeriodId, + } as const; + } + const validTo = instantAsDate(input.validTo); + if (validTo < current.validFrom) { + return { _tag: 'temporal_conflict' } as const; + } + // SAFETY: the owner table's role-type CHECK constraint admits only the V1 closed catalog. + const roleType = current.roleType as CounterpartyRoleType; + if (!roleEndEvidenceIsSufficient(roleType, input.provenance.method)) { + return { + _tag: 'evidence_insufficient', + method: input.provenance.method, + roleType, + } as const; + } + if (repeatsRecordedRoleEnd(current, input, validTo)) { + return { _tag: 'found', changed: false, value: roleDto(current) } as const; + } + if (current.state !== 'ACTIVE' || current.validTo !== null) { + return { _tag: 'already_ended', rolePeriodId: input.rolePeriodId } as const; + } + const endedRecordedAt = yield* DateTime.nowAsDate; + const lifecycle = rolePeriodStorageStateAt( + { validFrom: current.validFrom.toISOString(), validTo: input.validTo }, + endedRecordedAt.toISOString(), + ); + const [updated] = yield* transaction + .update(counterpartyRolePeriods) + .set({ + endEvidenceRefs: [input.provenance.evidenceReference], + endProvenanceMethod: input.provenance.method, + endProvenanceSource: input.provenance.source, + endReason: input.provenance.reason ?? input.provenance.method, + endedByActionInvocationId: input.actionInvocationId, + endedByPrincipalId: input.principalId, + endedRecordedAt, + isCurrent: lifecycle.isCurrent, + state: lifecycle.state, + validTo, + }) + .where( + and( + eq(counterpartyRolePeriods.tenantId, input.tenantId), + eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), + eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), + eq(counterpartyRolePeriods.rolePeriodId, input.rolePeriodId), + eq(counterpartyRolePeriods.state, 'ACTIVE'), + ), + ) + .returning() + .pipe(Effect.mapError(unavailable)); + if (updated === undefined) { + return yield* unavailable(); + } + yield* syncCounterpartyReadModel(transaction, counterparty.value); + yield* syncRoleReadModel(transaction, updated); return { - _tag: 'evidence_insufficient', - method: input.provenance.method, - roleType, + _tag: 'found', + changed: true, + value: roleDto(updated, endedRecordedAt), } as const; - } - if (repeatsRecordedRoleEnd(current, input, validTo)) { - return { _tag: 'found', changed: false, value: roleDto(current) } as const; - } - if (current.state !== 'ACTIVE' || current.validTo !== null) { - return { _tag: 'already_ended', rolePeriodId: input.rolePeriodId } as const; - } - const endedRecordedAt = yield* DateTime.nowAsDate; - const lifecycle = rolePeriodStorageStateAt( - { validFrom: current.validFrom.toISOString(), validTo: input.validTo }, - endedRecordedAt.toISOString(), - ); - const [updated] = yield* transaction - .update(counterpartyRolePeriods) - .set({ - endEvidenceRefs: [input.provenance.evidenceReference], - endProvenanceMethod: input.provenance.method, - endProvenanceSource: input.provenance.source, - endReason: input.provenance.reason ?? input.provenance.method, - endedByActionInvocationId: input.actionInvocationId, - endedByPrincipalId: input.principalId, - endedRecordedAt, - isCurrent: lifecycle.isCurrent, - state: lifecycle.state, - validTo, - }) - .where( - and( - eq(counterpartyRolePeriods.tenantId, input.tenantId), - eq(counterpartyRolePeriods.legalEntityId, input.legalEntityId), - eq(counterpartyRolePeriods.counterpartyId, input.counterpartyId), - eq(counterpartyRolePeriods.rolePeriodId, input.rolePeriodId), - eq(counterpartyRolePeriods.state, 'ACTIVE'), - ), - ) - .returning() - .pipe(Effect.mapError(unavailable)); - if (updated === undefined) { - return yield* unavailable(); - } - yield* syncCounterpartyReadModel(transaction, counterparty.value); - yield* syncRoleReadModel(transaction, updated); - return { _tag: 'found', changed: true, value: roleDto(updated, endedRecordedAt) } as const; -}); + }, +); -export const findCounterpartyRecord = Effect.fn( - 'CounterpartyPersistenceService.findCounterpartyRecord', -)(function* findCounterparty( - transaction: CounterpartyTransaction, - tenantId: string, - legalEntityId: string | undefined, - counterpartyId: string, -): Effect.fn.Return< - LookupResult, - CounterpartyPersistenceUnavailable -> { - const counterparty = yield* findCounterpartyReadRow( - transaction, - tenantId, - legalEntityId, - counterpartyId, - ); - if (Option.isNone(counterparty)) { - return { _tag: 'not_found' } as const; - } - const resolvedParty = yield* resolveCanonicalParty( - transaction, - tenantId, - counterparty.value.partyId, - ); - if (Option.isNone(resolvedParty)) { - return yield* unavailable(); - } - const now = yield* DateTime.nowAsDate; - const roles = yield* listRoleReadRows( - transaction, - counterparty.value, - legalEntityId === undefined, - true, - now, - ); - return { - _tag: 'found', - value: { - counterpartyRef: counterpartyRef(tenantId, counterparty.value.counterpartyId), - createdAt: counterparty.value.createdAt.toISOString(), - currentRoles: roles.map((role) => roleDto(role, now)), - legalEntityRef: legalEntityRef(tenantId, counterparty.value.legalEntityId), - party: { - archived: resolvedParty.value.archivedAt !== null, - canonicalPartyRef: partyRef(tenantId, resolvedParty.value.partyId), - displayName: resolvedParty.value.currentDisplayName, - // SAFETY: the owner table's Party-type CHECK constraint admits only the V1 closed catalog. - partyType: resolvedParty.value.currentType as CounterpartyRecord['party']['partyType'], - storedPartyRef: partyRef(tenantId, counterparty.value.partyId), +export const findCounterpartyRecord = Effect.fn('CounterpartyPersistenceService.findCounterpartyRecord')( + function* findCounterparty( + transaction: CounterpartyTransaction, + tenantId: string, + legalEntityId: string | undefined, + counterpartyId: string, + ): Effect.fn.Return< + LookupResult, + CounterpartyPersistenceUnavailable + > { + const counterparty = yield* findCounterpartyReadRow(transaction, tenantId, legalEntityId, counterpartyId); + if (Option.isNone(counterparty)) { + return { _tag: 'not_found' } as const; + } + const resolvedParty = yield* resolveCanonicalParty(transaction, tenantId, counterparty.value.partyId); + if (Option.isNone(resolvedParty)) { + return yield* unavailable(); + } + const now = yield* DateTime.nowAsDate; + const roles = yield* listRoleReadRows(transaction, counterparty.value, legalEntityId === undefined, true, now); + return { + _tag: 'found', + value: { + counterpartyRef: counterpartyRef(tenantId, counterparty.value.counterpartyId), + createdAt: counterparty.value.createdAt.toISOString(), + currentRoles: roles.map((role) => roleDto(role, now)), + legalEntityRef: legalEntityRef(tenantId, counterparty.value.legalEntityId), + party: { + archived: resolvedParty.value.archivedAt !== null, + canonicalPartyRef: partyRef(tenantId, resolvedParty.value.partyId), + displayName: resolvedParty.value.currentDisplayName, + // SAFETY: the owner table's Party-type CHECK constraint admits only the V1 closed catalog. + partyType: resolvedParty.value.currentType as CounterpartyRecord['party']['partyType'], + storedPartyRef: partyRef(tenantId, counterparty.value.partyId), + }, }, - }, - } as const; -}); + } as const; + }, +); -export const listCounterpartyRoleHistory = Effect.fn( - 'CounterpartyPersistenceService.listCounterpartyRoleHistory', -)(function* findCounterpartyRoleHistory( - transaction: CounterpartyTransaction, - tenantId: string, - legalEntityId: string | undefined, - counterpartyId: string, -): Effect.fn.Return< - LookupResult, - CounterpartyPersistenceUnavailable -> { - const counterparty = yield* findCounterpartyReadRow( - transaction, - tenantId, - legalEntityId, - counterpartyId, - ); - if (Option.isNone(counterparty)) { - return { _tag: 'not_found' } as const; - } - const now = yield* DateTime.nowAsDate; - const roles = yield* listRoleReadRows( - transaction, - counterparty.value, - legalEntityId === undefined, - false, - now, - ); - return { _tag: 'found', value: roles.map((role) => roleDto(role, now)) } as const; -}); +export const listCounterpartyRoleHistory = Effect.fn('CounterpartyPersistenceService.listCounterpartyRoleHistory')( + function* findCounterpartyRoleHistory( + transaction: CounterpartyTransaction, + tenantId: string, + legalEntityId: string | undefined, + counterpartyId: string, + ): Effect.fn.Return, CounterpartyPersistenceUnavailable> { + const counterparty = yield* findCounterpartyReadRow(transaction, tenantId, legalEntityId, counterpartyId); + if (Option.isNone(counterparty)) { + return { _tag: 'not_found' } as const; + } + const now = yield* DateTime.nowAsDate; + const roles = yield* listRoleReadRows(transaction, counterparty.value, legalEntityId === undefined, false, now); + return { + _tag: 'found', + value: roles.map((role) => roleDto(role, now)), + } as const; + }, +); diff --git a/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts b/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts index 486141d43..154076024 100644 --- a/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts +++ b/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts @@ -3,23 +3,15 @@ import { findPostgresFailure } from '@app/core-runtime'; import { and, eq } from 'drizzle-orm'; import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; import { DateTime, Effect, Option, Schema } from 'effect'; -import type { - OrganizationEngagementProfile, - PersonEngagementProfile, -} from '../../shared/domain/engagement-profile.ts'; + +import type { OrganizationEngagementProfile, PersonEngagementProfile } from '../../shared/domain/engagement-profile.ts'; import { EngagementProfileConflict, EngagementProfilePersistenceUnavailable, } from '../../shared/domain/engagement-profile.ts'; import type { CounterpartyRef, PartyRef } from '../../shared/party-registry-references.ts'; -import type { - OrganizationEngagementProfileRecord, - PersonEngagementProfileRecord, -} from '../db/engagement-schema.ts'; -import { - organizationEngagementProfiles, - personEngagementProfiles, -} from '../db/engagement-schema.ts'; +import type { OrganizationEngagementProfileRecord, PersonEngagementProfileRecord } from '../db/engagement-schema.ts'; +import { organizationEngagementProfiles, personEngagementProfiles } from '../db/engagement-schema.ts'; import type { ContactsTransaction } from '../db/engagement-types.ts'; type ScopedTransaction = Pick; @@ -32,9 +24,7 @@ export type LookupResult = Schema.Schema.Type(value: Schema.Schema) => Schema.Union([lookupResultSchema(value), Schema.TaggedStruct('conflict', { value })]); -export type LifecycleResult = Schema.Schema.Type< - ReturnType> ->; +export type LifecycleResult = Schema.Schema.Type>>; const unavailable = (cause?: unknown) => { const error = new EngagementProfilePersistenceUnavailable({ @@ -88,9 +78,7 @@ export const organizationEngagementProfileFromRecord = ( ): OrganizationEngagementProfile => ({ archivedAt: row.archivedAt?.toISOString() ?? null, counterpartyRef: - row.counterpartyResourceId === null - ? null - : counterpartyRef(row.tenantId, row.counterpartyResourceId), + row.counterpartyResourceId === null ? null : counterpartyRef(row.tenantId, row.counterpartyResourceId), createdAt: row.createdAt.toISOString(), partyRef: partyRef(row.tenantId, row.partyResourceId), profileRef: { @@ -105,9 +93,7 @@ export const organizationEngagementProfileFromRecord = ( const personDto = (row: PersonEngagementProfileRecord): PersonEngagementProfile => ({ archivedAt: row.archivedAt?.toISOString() ?? null, counterpartyRef: - row.counterpartyResourceId === null - ? null - : counterpartyRef(row.tenantId, row.counterpartyResourceId), + row.counterpartyResourceId === null ? null : counterpartyRef(row.tenantId, row.counterpartyResourceId), createdAt: row.createdAt.toISOString(), partyRef: partyRef(row.tenantId, row.partyResourceId), profileRef: { @@ -121,7 +107,10 @@ const personDto = (row: PersonEngagementProfileRecord): PersonEngagementProfile export const ensureReferencesBelongToTenant = ( tenantId: string, - refs: { readonly counterpartyRef?: CounterpartyRef; readonly partyRef: PartyRef }, + refs: { + readonly counterpartyRef?: CounterpartyRef; + readonly partyRef: PartyRef; + }, ) => refs.partyRef.tenantId === tenantId && (refs.counterpartyRef === undefined || refs.counterpartyRef.tenantId === tenantId) @@ -161,44 +150,43 @@ const engagementProfilePersistence = ( .returning() .pipe(Effect.mapError(mutationFailure)), ), - Effect.flatMap(([row]) => - row === undefined ? Effect.fail(unavailable()) : Effect.succeed(toDto(row)), - ), + Effect.flatMap(([row]) => (row === undefined ? Effect.fail(unavailable()) : Effect.succeed(toDto(row)))), ), - transition: Effect.fn('EngagementProfilePersistenceService.transition')( - function* transitionProfile( - transaction: ScopedTransaction, - tenantId: string, - profileId: string, - state: 'active' | 'archived', - ): Effect.fn.Return, EngagementProfilePersistenceUnavailable> { - const predicate = profilePredicate(tenantId, profileId); - const [current] = yield* transaction - .select() - .from(table) - .where(predicate) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if (current === undefined) { - return { _tag: 'not_found' } as const; - } - if ((state === 'archived') === (current.archivedAt !== null)) { - return { _tag: 'conflict', value: toDto(current) } as const; - } - const now = yield* DateTime.nowAsDate; - const [updated] = yield* transaction - .update(table) - .set({ archivedAt: state === 'archived' ? now : null, updatedAt: now }) - .where(predicate) - .returning() - .pipe(Effect.mapError(unavailable)); - if (updated === undefined) { - return yield* unavailable(); - } - return { _tag: 'found', value: toDto(updated) } as const; - }, - ), + transition: Effect.fn('EngagementProfilePersistenceService.transition')(function* transitionProfile( + transaction: ScopedTransaction, + tenantId: string, + profileId: string, + state: 'active' | 'archived', + ): Effect.fn.Return, EngagementProfilePersistenceUnavailable> { + const predicate = profilePredicate(tenantId, profileId); + const [current] = yield* transaction + .select() + .from(table) + .where(predicate) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if (current === undefined) { + return { _tag: 'not_found' } as const; + } + if ((state === 'archived') === (current.archivedAt !== null)) { + return { _tag: 'conflict', value: toDto(current) } as const; + } + const now = yield* DateTime.nowAsDate; + const [updated] = yield* transaction + .update(table) + .set({ + archivedAt: state === 'archived' ? now : null, + updatedAt: now, + }) + .where(predicate) + .returning() + .pipe(Effect.mapError(unavailable)); + if (updated === undefined) { + return yield* unavailable(); + } + return { _tag: 'found', value: toDto(updated) } as const; + }), find: (transaction: ScopedTransaction, tenantId: string, profileId: string) => transaction .select() @@ -208,9 +196,7 @@ const engagementProfilePersistence = ( .pipe( Effect.mapError(unavailable), Effect.map(([row]) => - row === undefined - ? ({ _tag: 'not_found' } as const) - : ({ _tag: 'found', value: toDto(row) } as const), + row === undefined ? ({ _tag: 'not_found' } as const) : ({ _tag: 'found', value: toDto(row) } as const), ), ), }; @@ -220,10 +206,7 @@ export const { create: createOrganizationEngagementProfile, transition: transitionOrganizationEngagementProfile, find: findOrganizationEngagementProfile, -} = engagementProfilePersistence( - organizationEngagementProfiles, - organizationEngagementProfileFromRecord, -); +} = engagementProfilePersistence(organizationEngagementProfiles, organizationEngagementProfileFromRecord); export const { create: createPersonEngagementProfile, diff --git a/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts b/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts index 3c115241d..a0539f55d 100644 --- a/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts +++ b/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts @@ -1,12 +1,13 @@ import { Effect, Match, Option } from 'effect'; -import type { CounterpartyRef, PartyRef } from '../../shared/party-registry-references.ts'; + +import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { EngagementProfileConflict, PartyRegistryReferenceUnavailable, } from '../../shared/domain/engagement-profile.ts'; -import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; -import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; import type { PartyPersistenceUnavailableError } from '../../shared/domain/identity-contracts.ts'; +import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; +import type { CounterpartyRef, PartyRef } from '../../shared/party-registry-references.ts'; import type { PartyTransaction } from '../db/types.ts'; import { resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; import { findCounterpartyRecord } from './counterparty-persistence.service.ts'; @@ -36,9 +37,7 @@ export interface PartyRegistryReferenceOperations { readonly readCounterparty: ( ref: CounterpartyRef, ) => Effect.Effect; - readonly readParty: ( - ref: PartyRef, - ) => Effect.Effect; + readonly readParty: (ref: PartyRef) => Effect.Effect; } type ReferencePersistenceError = @@ -83,9 +82,7 @@ export const partyRegistryReferenceOperations = ({ Effect.mapError(unavailable), Effect.flatMap((result) => Match.value(result).pipe( - Match.tag('not_found', () => - Effect.fail(mismatch('The Counterparty reference does not exist')), - ), + Match.tag('not_found', () => Effect.fail(mismatch('The Counterparty reference does not exist'))), Match.tag('found', ({ value }) => Effect.succeed({ counterpartyRef: value.counterpartyRef, @@ -107,9 +104,7 @@ export const partyRegistryReferenceOperations = ({ Effect.mapError(unavailable), Effect.flatMap((result) => Match.value(result).pipe( - Match.tag('not_found', () => - Effect.fail(mismatch('The Party reference does not exist')), - ), + Match.tag('not_found', () => Effect.fail(mismatch('The Party reference does not exist'))), Match.tag('found', ({ value }) => Effect.succeed({ archived: Option.isSome(value.archivedAt), @@ -127,35 +122,35 @@ export const partyRegistryReferenceOperations = ({ : Effect.fail(mismatch('The Party reference does not belong to the trusted tenant')), }); -const validateCounterpartyReference = Effect.fn( - 'EngagementReferenceValidationService.validateCounterpartyReference', -)(function* validateCounterpartyReferenceEffect( - operations: PartyRegistryReferenceOperations, - counterpartyRef: CounterpartyRef, - partyRef: PartyRef, - party: PartyRegistryPartyProjection, -) { - const counterparty = yield* operations.readCounterparty(counterpartyRef); - if ( - counterparty.counterpartyRef.resourceId !== counterpartyRef.resourceId || - counterparty.counterpartyRef.tenantId !== counterpartyRef.tenantId || - counterpartyRef.tenantId !== partyRef.tenantId || - counterparty.partyRef.resourceId !== party.partyRef.resourceId || - counterparty.partyRef.tenantId !== partyRef.tenantId +const validateCounterpartyReference = Effect.fn('EngagementReferenceValidationService.validateCounterpartyReference')( + function* validateCounterpartyReferenceEffect( + operations: PartyRegistryReferenceOperations, + counterpartyRef: CounterpartyRef, + partyRef: PartyRef, + party: PartyRegistryPartyProjection, ) { - return yield* new EngagementProfileConflict({ - code: 'contacts_party_counterparty_mismatch', - reason: 'The Counterparty does not resolve to the supplied Party', - }); - } - if (!counterparty.roleTypes.includes('CUSTOMER')) { - return yield* new EngagementProfileConflict({ - code: 'contacts_counterparty_customer_role_required', - reason: 'An explicit commercial context requires a current CUSTOMER role', - }); - } - return yield* Effect.void; -}); + const counterparty = yield* operations.readCounterparty(counterpartyRef); + if ( + counterparty.counterpartyRef.resourceId !== counterpartyRef.resourceId || + counterparty.counterpartyRef.tenantId !== counterpartyRef.tenantId || + counterpartyRef.tenantId !== partyRef.tenantId || + counterparty.partyRef.resourceId !== party.partyRef.resourceId || + counterparty.partyRef.tenantId !== partyRef.tenantId + ) { + return yield* new EngagementProfileConflict({ + code: 'contacts_party_counterparty_mismatch', + reason: 'The Counterparty does not resolve to the supplied Party', + }); + } + if (!counterparty.roleTypes.includes('CUSTOMER')) { + return yield* new EngagementProfileConflict({ + code: 'contacts_counterparty_customer_role_required', + reason: 'An explicit commercial context requires a current CUSTOMER role', + }); + } + return yield* Effect.void; + }, +); export const validatePartyRegistryReferences = Effect.fn( 'EngagementReferenceValidationService.validatePartyRegistryReferences', @@ -199,10 +194,5 @@ export const validatePartyRegistryReferences = Effect.fn( if (refs.counterpartyRef === undefined) { return yield* Effect.void; } - return yield* validateCounterpartyReference( - operations, - refs.counterpartyRef, - refs.partyRef, - party, - ); + return yield* validateCounterpartyReference(operations, refs.counterpartyRef, refs.partyRef, party); }); diff --git a/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts b/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts index 0c8291e33..cb85b0564 100644 --- a/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts @@ -4,6 +4,7 @@ import type { OperationalScope } from '@app/core-runtime'; import { and, asc, eq, ne, sql } from 'drizzle-orm'; import { DateTime, Effect, Match, Option, Schema } from 'effect'; + import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; import { PartyContactPointAlreadyExists, @@ -15,10 +16,7 @@ import { PartyContactPointPersistenceUnavailable, PartyContactPointRevisionConflict, } from '../../shared/domain/contact-point-errors.ts'; -import type { - AddressPurposeAssignment, - PartyContactPoint, -} from '../../shared/domain/contact-point.ts'; +import type { AddressPurposeAssignment, PartyContactPoint } from '../../shared/domain/contact-point.ts'; import { ContactPointInputSchema, assertAddressPurposeRules, @@ -31,24 +29,15 @@ import type { AddContactPointCommand } from '../actions/add-contact-point.action import type { EndContactPointCommand } from '../actions/end-contact-point.action.ts'; import type { UpdateContactPointCommand } from '../actions/update-contact-point.action.ts'; import type { PartyContactPointRecord } from '../db/schema.ts'; -import { - parties, - partyContactPointPurposes, - partyContactPoints, - partyCorrections, -} from '../db/schema.ts'; +import { parties, partyContactPointPurposes, partyContactPoints, partyCorrections } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; import type { PartyAliasResolutionService as AliasOperations } from '../merge/party-alias-resolution.service.ts'; -import { - requireCanonicalPartyWriteTarget, - resolvePartyAlias, -} from '../merge/party-alias-resolution.service.ts'; +import { requireCanonicalPartyWriteTarget, resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; type PartyScopedTransaction = Pick; type PurposeRecord = typeof partyContactPointPurposes.$inferSelect; const transactionAliasService = (transaction: PartyScopedTransaction): AliasOperations => ({ - requireCanonicalWriteTarget: (tenantId, partyId) => - requireCanonicalPartyWriteTarget(transaction, tenantId, partyId), + requireCanonicalWriteTarget: (tenantId, partyId) => requireCanonicalPartyWriteTarget(transaction, tenantId, partyId), resolvePartyAlias: (tenantId, partyId) => resolvePartyAlias(transaction, tenantId, partyId), }); @@ -61,11 +50,9 @@ const unavailable = (cause?: unknown) => { cause }, ); -const instantAsDate = (instant: string | DateTime.Utc): Date => - DateTime.toDateUtc(DateTime.makeUnsafe(instant)); +const instantAsDate = (instant: string | DateTime.Utc): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); const instantFromDate = (instant: Date): DateTime.Utc => DateTime.makeUnsafe(instant); -const toEpochMillis = (instant: Date): number => - DateTime.toEpochMillis(DateTime.makeUnsafe(instant)); +const toEpochMillis = (instant: Date): number => DateTime.toEpochMillis(DateTime.makeUnsafe(instant)); const partyRef = (tenantId: string, partyId: string) => ({ moduleId: 'party.registry' as const, @@ -83,11 +70,7 @@ const contactPointRef = (tenantId: string, contactPointId: string) => ({ type ProvenanceRecord = Pick< PartyContactPointRecord, - | 'evidenceReference' - | 'externalEvidence' - | 'provenanceAuthoritative' - | 'provenanceMethod' - | 'provenanceSource' + 'evidenceReference' | 'externalEvidence' | 'provenanceAuthoritative' | 'provenanceMethod' | 'provenanceSource' >; type VerificationRecord = Pick< PartyContactPointRecord, @@ -105,41 +88,30 @@ type EndRecord = Pick< | 'validTo' >; -const provenanceDto = Effect.fn('PartyContactPointPersistenceService.provenanceDto')( - function* makeProvenanceDto( - row: ProvenanceRecord, - additionalEvidenceRefs: readonly string[] = [], - ) { - const externalEvidence = - row.externalEvidence === null - ? undefined - : yield* Schema.decodeUnknownEffect(AresAppliedEvidenceSchema)(row.externalEvidence).pipe( - Effect.mapError(unavailable), - ); - return { - authoritative: row.provenanceAuthoritative, - evidenceReferences: [ - ...new Set([ - ...(row.evidenceReference === null ? [] : [row.evidenceReference]), - ...additionalEvidenceRefs, - ]), - ], - ...(row.evidenceReference === null ? {} : { evidenceReference: row.evidenceReference }), - ...(externalEvidence === undefined ? {} : { externalEvidence }), - method: row.provenanceMethod as - | 'DECLARED_BY_PARTY' - | 'DOCUMENT_REVIEW' - | 'MANUAL_CONFIRMATION' - | 'MIGRATION' - | 'PROVIDER_OBSERVATION', - source: row.provenanceSource as - | 'EXTERNAL_EVIDENCE' - | 'MIGRATION_DATASET' - | 'PARTY_DECLARATION' - | 'USER_ASSERTION', - }; - }, -); +const provenanceDto = Effect.fn('PartyContactPointPersistenceService.provenanceDto')(function* makeProvenanceDto( + row: ProvenanceRecord, + additionalEvidenceRefs: readonly string[] = [], +) { + const externalEvidence = + row.externalEvidence === null + ? undefined + : yield* Schema.decodeEffect(AresAppliedEvidenceSchema)(row.externalEvidence).pipe(Effect.mapError(unavailable)); + return { + authoritative: row.provenanceAuthoritative, + evidenceReferences: [ + ...new Set([...(row.evidenceReference === null ? [] : [row.evidenceReference]), ...additionalEvidenceRefs]), + ], + ...(row.evidenceReference === null ? {} : { evidenceReference: row.evidenceReference }), + ...(externalEvidence === undefined ? {} : { externalEvidence }), + method: row.provenanceMethod as + | 'DECLARED_BY_PARTY' + | 'DOCUMENT_REVIEW' + | 'MANUAL_CONFIRMATION' + | 'MIGRATION' + | 'PROVIDER_OBSERVATION', + source: row.provenanceSource as 'EXTERNAL_EVIDENCE' | 'MIGRATION_DATASET' | 'PARTY_DECLARATION' | 'USER_ASSERTION', + }; +}); const encodeExternalEvidence = (provenance: AddContactPointCommand['provenance']) => provenance.externalEvidence === undefined @@ -155,9 +127,7 @@ const verificationDto = (row: VerificationRecord) => ({ ...(row.verifierReference === null ? {} : { verifierReference: row.verifierReference }), }); -const endDto = Effect.fn('PartyContactPointPersistenceService.endDto')(function* makeEndDto( - row: EndRecord, -) { +const endDto = Effect.fn('PartyContactPointPersistenceService.endDto')(function* makeEndDto(row: EndRecord) { if (row.validTo === null) { return null; } @@ -197,9 +167,7 @@ const endDto = Effect.fn('PartyContactPointPersistenceService.endDto')(function* const hasExactEndSemantics = (row: EndRecord, command: EndContactPointCommand): boolean => { const requestedEvidence = - command.provenance.evidenceReference === undefined - ? [] - : [command.provenance.evidenceReference]; + command.provenance.evidenceReference === undefined ? [] : [command.provenance.evidenceReference]; return ( row.endReason === command.reason && row.endProvenanceSource === command.provenance.source && @@ -227,31 +195,32 @@ const purposeRegistryColumns = (context: AddressPurposeAssignment['registryConte registryContext: context?.registryKey ?? 'GENERAL', }); -const purposeDto = Effect.fn('PartyContactPointPersistenceService.purposeDto')( - function* makePurposeDto(row: PurposeRecord, now: Date) { - return { - current: isPurposeCurrentAt(row, now), - end: yield* endDto(row), - preferred: row.preferred, - provenance: yield* provenanceDto(row), - purpose: row.purposeKey as 'BILLING' | 'CORRESPONDENCE' | 'DELIVERY' | 'REGISTERED', - recordedAt: instantFromDate(row.recordedAt), - revision: row.revision, - state: row.state as 'ACTIVE' | 'DISPUTED' | 'ENDED' | 'RETRACTED' | 'SUPERSEDED', - validFrom: instantFromDate(row.validFrom), - validTo: row.validTo === null ? null : instantFromDate(row.validTo), - verification: verificationDto(row), - ...(row.purposeKey === 'REGISTERED' - ? { - registryContext: { - jurisdiction: row.jurisdiction, - registryKey: row.registryContext, - }, - } - : {}), - }; - }, -); +const purposeDto = Effect.fn('PartyContactPointPersistenceService.purposeDto')(function* makePurposeDto( + row: PurposeRecord, + now: Date, +) { + return { + current: isPurposeCurrentAt(row, now), + end: yield* endDto(row), + preferred: row.preferred, + provenance: yield* provenanceDto(row), + purpose: row.purposeKey as 'BILLING' | 'CORRESPONDENCE' | 'DELIVERY' | 'REGISTERED', + recordedAt: instantFromDate(row.recordedAt), + revision: row.revision, + state: row.state as 'ACTIVE' | 'DISPUTED' | 'ENDED' | 'RETRACTED' | 'SUPERSEDED', + validFrom: instantFromDate(row.validFrom), + validTo: row.validTo === null ? null : instantFromDate(row.validTo), + verification: verificationDto(row), + ...(row.purposeKey === 'REGISTERED' + ? { + registryContext: { + jurisdiction: row.jurisdiction, + registryKey: row.registryContext, + }, + } + : {}), + }; +}); const contactPointValueDto = ( row: PartyContactPointRecord, @@ -303,14 +272,10 @@ const contactPointDto = Effect.fn('PartyContactPointPersistenceService.contactPo const value = contactPointValueDto(row, purposeDtos); return { contactPointRef: contactPointRef(row.tenantId, row.contactPointId), - current: - isCurrentAt(row, now) && (row.contactPointType !== 'ADDRESS' || currentPurposes.length > 0), + current: isCurrentAt(row, now) && (row.contactPointType !== 'ADDRESS' || currentPurposes.length > 0), end: yield* endDto(row), partyRef: partyRef(row.tenantId, row.partyId), - privacyClassification: row.privacyClassification as - | 'BUSINESS_SENSITIVE' - | 'PERSONAL' - | 'PUBLIC', + privacyClassification: row.privacyClassification as 'BUSINESS_SENSITIVE' | 'PERSONAL' | 'PUBLIC', provenance: yield* provenanceDto(row, row.additionalEvidenceRefs), recordedAt: instantFromDate(row.recordedAt), revision: row.revision, @@ -324,11 +289,7 @@ const contactPointDto = Effect.fn('PartyContactPointPersistenceService.contactPo }, ); -const loadPurposes = ( - transaction: PartyScopedTransaction, - tenantId: string, - contactPointId: string, -) => +const loadPurposes = (transaction: PartyScopedTransaction, tenantId: string, contactPointId: string) => transaction .select() .from(partyContactPointPurposes) @@ -341,30 +302,26 @@ const loadPurposes = ( .orderBy(asc(partyContactPointPurposes.recordedAt)) .pipe(Effect.mapError(unavailable)); -const loadDto = Effect.fn('PartyContactPointPersistenceService.loadDto')( - function* loadContactPointDto( - transaction: PartyScopedTransaction, - row: PartyContactPointRecord, - now: Date, - aliases: AliasOperations = transactionAliasService(transaction), - ) { - const { purposes, resolution } = yield* Effect.all( - { - purposes: loadPurposes(transaction, row.tenantId, row.contactPointId), - resolution: aliases - .resolvePartyAlias(row.tenantId, row.partyId) - .pipe(Effect.mapError(unavailable)), - }, - { concurrency: 1 }, - ); - const contactPoint = yield* contactPointDto({ now, purposes, row }); - return { - ...contactPoint, - partyRef: partyRef(row.tenantId, resolution.canonicalPartyId), - storedPartyRef: partyRef(row.tenantId, row.partyId), - }; - }, -); +const loadDto = Effect.fn('PartyContactPointPersistenceService.loadDto')(function* loadContactPointDto( + transaction: PartyScopedTransaction, + row: PartyContactPointRecord, + now: Date, + aliases: AliasOperations = transactionAliasService(transaction), +) { + const { purposes, resolution } = yield* Effect.all( + { + purposes: loadPurposes(transaction, row.tenantId, row.contactPointId), + resolution: aliases.resolvePartyAlias(row.tenantId, row.partyId).pipe(Effect.mapError(unavailable)), + }, + { concurrency: 1 }, + ); + const contactPoint = yield* contactPointDto({ now, purposes, row }); + return { + ...contactPoint, + partyRef: partyRef(row.tenantId, resolution.canonicalPartyId), + storedPartyRef: partyRef(row.tenantId, row.partyId), + }; +}); const storedAddressKey = (row: PartyContactPointRecord) => normalizedAddressKey({ @@ -376,15 +333,11 @@ const storedAddressKey = (row: PartyContactPointRecord) => ...(row.region === null ? {} : { region: row.region }), }); -const enrichedEvidenceReferences = ( - row: PartyContactPointRecord, - provenance: AddContactPointCommand['provenance'], -) => { +const enrichedEvidenceReferences = (row: PartyContactPointRecord, provenance: AddContactPointCommand['provenance']) => { const additionalEvidenceRefs = [ ...new Set([ ...row.additionalEvidenceRefs, - ...(provenance.evidenceReference === undefined || - provenance.evidenceReference === row.evidenceReference + ...(provenance.evidenceReference === undefined || provenance.evidenceReference === row.evidenceReference ? [] : [provenance.evidenceReference]), ]), @@ -392,10 +345,7 @@ const enrichedEvidenceReferences = ( return additionalEvidenceRefs; }; -const sameCanonicalContact = ( - row: PartyContactPointRecord, - command: AddContactPointCommand, -): boolean => { +const sameCanonicalContact = (row: PartyContactPointRecord, command: AddContactPointCommand): boolean => { const normalized = normalizeContactPointInput(command.contactPoint); if (normalized.type !== row.contactPointType) { return false; @@ -404,9 +354,7 @@ const sameCanonicalContact = ( return row.normalizedValue === normalized.lookupValue; } if (normalized.type === 'PHONE') { - return ( - row.normalizedValue === normalized.lookupValue && row.phoneExtension === normalized.extension - ); + return row.normalizedValue === normalized.lookupValue && row.phoneExtension === normalized.extension; } if (command.contactPoint.type !== 'ADDRESS') { return false; @@ -423,12 +371,7 @@ const lockParty = (transaction: PartyScopedTransaction, tenantId: string, partyI .for('update') .pipe(Effect.mapError(unavailable)); -const currentContactRows = ( - transaction: PartyScopedTransaction, - tenantId: string, - partyId: string, - type: string, -) => +const currentContactRows = (transaction: PartyScopedTransaction, tenantId: string, partyId: string, type: string) => transaction .select() .from(partyContactPoints) @@ -458,14 +401,8 @@ const loadPurposeScope = Effect.fn('PartyContactPointPersistenceService.loadPurp eq(partyContactPointPurposes.tenantId, tenantId), eq(partyContactPointPurposes.partyId, partyId), eq(partyContactPointPurposes.purposeKey, assignment.purpose), - eq( - partyContactPointPurposes.registryContext, - assignment.registryContext?.registryKey ?? 'GENERAL', - ), - eq( - partyContactPointPurposes.jurisdiction, - assignment.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ', - ), + eq(partyContactPointPurposes.registryContext, assignment.registryContext?.registryKey ?? 'GENERAL'), + eq(partyContactPointPurposes.jurisdiction, assignment.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ'), eq(partyContactPointPurposes.isCurrent, true), ), ) @@ -493,59 +430,52 @@ const loadPurposeScope = Effect.fn('PartyContactPointPersistenceService.loadPurp .pipe(Effect.mapError(unavailable)), { concurrency: 1, discard: true }, ); - return rows.filter( - (row) => row.validTo === null || toEpochMillis(row.validTo) > toEpochMillis(effectiveAt), - ); + return rows.filter((row) => row.validTo === null || toEpochMillis(row.validTo) > toEpochMillis(effectiveAt)); }, ); -const transferPurposePreference = Effect.fn( - 'PartyContactPointPersistenceService.transferPurposePreference', -)(function* transferPreferredPurpose( - transaction: PartyScopedTransaction, - tenantId: string, - targetContactPointId: string, - rows: readonly PurposeRecord[], - targetPurposeId?: string, -) { - const preferredRows = rows.filter( - (row) => row.preferred && row.contactPointPurposeId !== targetPurposeId, - ); - const transferSinglePurposePreference = Effect.fn( - 'PartyContactPointPersistenceService.transferSinglePurposePreference', - )(function* transferSinglePurposePreference(row: PurposeRecord) { - yield* transaction - .update(partyContactPointPurposes) - .set({ - preferred: false, - revision: row.revision + 1, - }) - .where( - and( - eq(partyContactPointPurposes.tenantId, tenantId), - eq(partyContactPointPurposes.contactPointPurposeId, row.contactPointPurposeId), - ), - ); - if (row.contactPointId === targetContactPointId) { - return; - } - yield* transaction - .update(partyContactPoints) - .set({ - revision: sql`${partyContactPoints.revision} + 1`, - }) - .where( - and( - eq(partyContactPoints.tenantId, tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ); - }, Effect.mapError(unavailable)); - yield* Effect.forEach(preferredRows, transferSinglePurposePreference, { - concurrency: 1, - discard: true, - }); -}); +const transferPurposePreference = Effect.fn('PartyContactPointPersistenceService.transferPurposePreference')( + function* transferPreferredPurpose( + transaction: PartyScopedTransaction, + tenantId: string, + targetContactPointId: string, + rows: readonly PurposeRecord[], + targetPurposeId?: string, + ) { + const preferredRows = rows.filter((row) => row.preferred && row.contactPointPurposeId !== targetPurposeId); + const transferSinglePurposePreference = Effect.fn( + 'PartyContactPointPersistenceService.transferSinglePurposePreference', + )(function* transferSinglePurposePreference(row: PurposeRecord) { + yield* transaction + .update(partyContactPointPurposes) + .set({ + preferred: false, + revision: row.revision + 1, + }) + .where( + and( + eq(partyContactPointPurposes.tenantId, tenantId), + eq(partyContactPointPurposes.contactPointPurposeId, row.contactPointPurposeId), + ), + ); + if (row.contactPointId === targetContactPointId) { + return; + } + yield* transaction + .update(partyContactPoints) + .set({ + revision: sql`${partyContactPoints.revision} + 1`, + }) + .where( + and(eq(partyContactPoints.tenantId, tenantId), eq(partyContactPoints.contactPointId, row.contactPointId)), + ); + }, Effect.mapError(unavailable)); + yield* Effect.forEach(preferredRows, transferSinglePurposePreference, { + concurrency: 1, + discard: true, + }); + }, +); const contactValueColumns = (normalized: ReturnType) => { const addressColumns = @@ -587,239 +517,210 @@ const acceptedVerificationColumns = (command: AddContactPointCommand) => ({ verificationMethod: command.verification.method ?? null, verificationState: command.verification.state, verifiedAt: - command.verification.verifiedAt === undefined - ? null - : DateTime.toDateUtc(command.verification.verifiedAt), - verifiedByPrincipalId: - command.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, + command.verification.verifiedAt === undefined ? null : DateTime.toDateUtc(command.verification.verifiedAt), + verifiedByPrincipalId: command.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, verifierReference: command.verification.verifierReference ?? null, }); -export const addContactPointRecord = Effect.fn( - 'PartyContactPointPersistenceService.addContactPointRecord', -)(function* addContactPoint( - transaction: PartyScopedTransaction, - scope: OperationalScope, - command: AddContactPointCommand, - aliases: AliasOperations = transactionAliasService(transaction), -) { - const contactPoint = yield* Schema.decodeUnknownEffect(ContactPointInputSchema, { - onExcessProperty: 'error', - })(command.contactPoint).pipe( - Effect.mapError((cause) => - Object.assign( - new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'Contact Point value does not satisfy the closed type rules', - }), - { cause }, - ), - ), - ); - const normalized = yield* Effect.try({ - catch: (cause) => - Object.assign( - new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'Contact Point value or evidence does not satisfy the closed type rules', - }), - { cause }, - ), - try: () => { - assertVerificationRules(command.verification); - if (contactPoint.type === 'ADDRESS') { - assertAddressPurposeRules(contactPoint.purposes, command.provenance); - } - return normalizeContactPointInput(contactPoint); - }, - }); - const externalEvidence = yield* encodeExternalEvidence(command.provenance); - const [party] = yield* lockParty(transaction, scope.tenantId, command.partyRef.resourceId); - if (party === undefined) { - return yield* new PartyContactPointPartyNotFound({ - code: 'party_contact_point_party_not_found', - partyRef: command.partyRef, - reason: 'The target Party does not exist in this Tenant', - }); - } - yield* aliases - .requireCanonicalWriteTarget(scope.tenantId, command.partyRef.resourceId) - .pipe( - Effect.mapError((error) => - Schema.is(PartyAliasWriteRejected)(error) ? error : unavailable(error), +export const addContactPointRecord = Effect.fn('PartyContactPointPersistenceService.addContactPointRecord')( + function* addContactPoint( + transaction: PartyScopedTransaction, + scope: OperationalScope, + command: AddContactPointCommand, + aliases: AliasOperations = transactionAliasService(transaction), + ) { + const contactPoint = yield* Schema.decodeEffect(ContactPointInputSchema, { + onExcessProperty: 'error', + })(command.contactPoint).pipe( + Effect.mapError((cause) => + Object.assign( + new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'Contact Point value does not satisfy the closed type rules', + }), + { cause }, + ), ), ); - const selected = yield* currentContactRows( - transaction, - scope.tenantId, - command.partyRef.resourceId, - command.contactPoint.type, - ); - const now = yield* DateTime.nowAsDate; - const expired = selected.filter( - (row) => row.validTo !== null && toEpochMillis(row.validTo) <= toEpochMillis(now), - ); - yield* Effect.forEach( - expired, - (row) => - transaction - .update(partyContactPoints) - .set({ isCurrent: false, preferred: false, state: 'ENDED' }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ) - .pipe(Effect.mapError(unavailable)), - { concurrency: 1, discard: true }, - ); - const existing = selected.filter( - (row) => row.validTo === null || toEpochMillis(row.validTo) > toEpochMillis(now), - ); - const duplicate = existing.find((row) => sameCanonicalContact(row, command)); - if (duplicate !== undefined) { - return yield* new PartyContactPointAlreadyExists({ - code: 'party_contact_point_already_exists', - existingContactPointRef: contactPointRef(scope.tenantId, duplicate.contactPointId), - reason: - 'This Party already has the same current canonical Contact Point; update its metadata instead', + const normalized = yield* Effect.try({ + catch: (cause) => + Object.assign( + new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'Contact Point value or evidence does not satisfy the closed type rules', + }), + { cause }, + ), + try: () => { + assertVerificationRules(command.verification); + if (contactPoint.type === 'ADDRESS') { + assertAddressPurposeRules(contactPoint.purposes, command.provenance); + } + return normalizeContactPointInput(contactPoint); + }, }); - } - const preparePurpose = Effect.fn('PartyContactPointPersistenceService.preparePurpose')( - function* preparePurpose(assignment: AddressPurposeAssignment) { - const rows = yield* loadPurposeScope( - transaction, - scope.tenantId, - command.partyRef.resourceId, - assignment, - now, - ); + const externalEvidence = yield* encodeExternalEvidence(command.provenance); + const [party] = yield* lockParty(transaction, scope.tenantId, command.partyRef.resourceId); + if (party === undefined) { + return yield* new PartyContactPointPartyNotFound({ + code: 'party_contact_point_party_not_found', + partyRef: command.partyRef, + reason: 'The target Party does not exist in this Tenant', + }); + } + yield* aliases + .requireCanonicalWriteTarget(scope.tenantId, command.partyRef.resourceId) + .pipe(Effect.mapError((error) => (Schema.is(PartyAliasWriteRejected)(error) ? error : unavailable(error)))); + const selected = yield* currentContactRows( + transaction, + scope.tenantId, + command.partyRef.resourceId, + command.contactPoint.type, + ); + const now = yield* DateTime.nowAsDate; + const expired = selected.filter((row) => row.validTo !== null && toEpochMillis(row.validTo) <= toEpochMillis(now)); + yield* Effect.forEach( + expired, + (row) => + transaction + .update(partyContactPoints) + .set({ isCurrent: false, preferred: false, state: 'ENDED' }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.contactPointId, row.contactPointId), + ), + ) + .pipe(Effect.mapError(unavailable)), + { concurrency: 1, discard: true }, + ); + const existing = selected.filter((row) => row.validTo === null || toEpochMillis(row.validTo) > toEpochMillis(now)); + const duplicate = existing.find((row) => sameCanonicalContact(row, command)); + if (duplicate !== undefined) { + return yield* new PartyContactPointAlreadyExists({ + code: 'party_contact_point_already_exists', + existingContactPointRef: contactPointRef(scope.tenantId, duplicate.contactPointId), + reason: 'This Party already has the same current canonical Contact Point; update its metadata instead', + }); + } + const preparePurpose = Effect.fn('PartyContactPointPersistenceService.preparePurpose')(function* preparePurpose( + assignment: AddressPurposeAssignment, + ) { + const rows = yield* loadPurposeScope(transaction, scope.tenantId, command.partyRef.resourceId, assignment, now); if (assignment.purpose === 'REGISTERED' && rows[0] !== undefined) { return yield* new PartyContactPointAlreadyExists({ code: 'party_contact_point_already_exists', existingContactPointRef: contactPointRef(scope.tenantId, rows[0].contactPointId), - reason: - 'An accepted REGISTERED address already occupies this registry context; end or correct it explicitly', + reason: 'An accepted REGISTERED address already occupies this registry context; end or correct it explicitly', }); } return { assignment, rows }; - }, - ); - const preparedPurposes = - normalized.type === 'ADDRESS' - ? yield* Effect.forEach(normalized.purposes, preparePurpose, { concurrency: 1 }) - : []; - if ((normalized.type === 'EMAIL' || normalized.type === 'PHONE') && normalized.preferred) { - yield* transaction - .update(partyContactPoints) - .set({ preferred: false, revision: sql`${partyContactPoints.revision} + 1` }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.partyId, command.partyRef.resourceId), - eq(partyContactPoints.contactPointType, normalized.type), - eq(partyContactPoints.isCurrent, true), - eq(partyContactPoints.preferred, true), - ), - ) - .pipe(Effect.mapError(unavailable)); - } - const [created] = yield* transaction - .insert(partyContactPoints) - .values({ - ...contactValueColumns(normalized), - acceptedByActionInvocationId: command.acceptedByActionInvocationId, - acceptedByPrincipalId: command.acceptedByPrincipalId, - contactPointType: normalized.type, - evidenceReference: command.provenance.evidenceReference ?? null, - externalEvidence, - isCurrent: true, - partyId: command.partyRef.resourceId, - policyVersion: 'party-contact-point.v1', - privacyClassification: command.privacyClassification, - provenanceAuthoritative: command.provenance.authoritative, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - tenantId: scope.tenantId, - validFrom: instantAsDate(command.validFrom), - ...acceptedVerificationColumns(command), - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (created === undefined) { - return yield* unavailable(); - } - const insertPurpose = Effect.fn('PartyContactPointPersistenceService.insertPurpose')( - function* insertPurpose(input: { - readonly assignment: AddressPurposeAssignment; - readonly rows: readonly PurposeRecord[]; - }) { - const { assignment, rows } = input; - const context = assignment.registryContext; - if (assignment.preferred) { - yield* transferPurposePreference(transaction, scope.tenantId, created.contactPointId, rows); - } + }); + const preparedPurposes = + normalized.type === 'ADDRESS' + ? yield* Effect.forEach(normalized.purposes, preparePurpose, { + concurrency: 1, + }) + : []; + if ((normalized.type === 'EMAIL' || normalized.type === 'PHONE') && normalized.preferred) { yield* transaction - .insert(partyContactPointPurposes) - .values({ - acceptedByActionInvocationId: command.acceptedByActionInvocationId, - acceptedByPrincipalId: command.acceptedByPrincipalId, - contactPointId: created.contactPointId, - evidenceReference: command.provenance.evidenceReference ?? null, - externalEvidence, - isCurrent: true, - jurisdiction: context?.jurisdiction.toUpperCase() ?? 'ZZ', - partyId: command.partyRef.resourceId, - policyVersion: 'party-contact-point.v1', - preferred: assignment.preferred, - provenanceAuthoritative: command.provenance.authoritative, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - purposeKey: assignment.purpose, - registryContext: context?.registryKey ?? 'GENERAL', - tenantId: scope.tenantId, - validFrom: instantAsDate(command.validFrom), + .update(partyContactPoints) + .set({ + preferred: false, + revision: sql`${partyContactPoints.revision} + 1`, }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.partyId, command.partyRef.resourceId), + eq(partyContactPoints.contactPointType, normalized.type), + eq(partyContactPoints.isCurrent, true), + eq(partyContactPoints.preferred, true), + ), + ) .pipe(Effect.mapError(unavailable)); - }, - ); - yield* Effect.forEach(preparedPurposes, insertPurpose, { concurrency: 1, discard: true }); - return yield* loadDto(transaction, created, now, aliases); -}); + } + const [created] = yield* transaction + .insert(partyContactPoints) + .values({ + ...contactValueColumns(normalized), + acceptedByActionInvocationId: command.acceptedByActionInvocationId, + acceptedByPrincipalId: command.acceptedByPrincipalId, + contactPointType: normalized.type, + evidenceReference: command.provenance.evidenceReference ?? null, + externalEvidence, + isCurrent: true, + partyId: command.partyRef.resourceId, + policyVersion: 'party-contact-point.v1', + privacyClassification: command.privacyClassification, + provenanceAuthoritative: command.provenance.authoritative, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + tenantId: scope.tenantId, + validFrom: instantAsDate(command.validFrom), + ...acceptedVerificationColumns(command), + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (created === undefined) { + return yield* unavailable(); + } + const insertPurpose = Effect.fn('PartyContactPointPersistenceService.insertPurpose')( + function* insertPurpose(input: { + readonly assignment: AddressPurposeAssignment; + readonly rows: readonly PurposeRecord[]; + }) { + const { assignment, rows } = input; + const context = assignment.registryContext; + if (assignment.preferred) { + yield* transferPurposePreference(transaction, scope.tenantId, created.contactPointId, rows); + } + yield* transaction + .insert(partyContactPointPurposes) + .values({ + acceptedByActionInvocationId: command.acceptedByActionInvocationId, + acceptedByPrincipalId: command.acceptedByPrincipalId, + contactPointId: created.contactPointId, + evidenceReference: command.provenance.evidenceReference ?? null, + externalEvidence, + isCurrent: true, + jurisdiction: context?.jurisdiction.toUpperCase() ?? 'ZZ', + partyId: command.partyRef.resourceId, + policyVersion: 'party-contact-point.v1', + preferred: assignment.preferred, + provenanceAuthoritative: command.provenance.authoritative, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + purposeKey: assignment.purpose, + registryContext: context?.registryKey ?? 'GENERAL', + tenantId: scope.tenantId, + validFrom: instantAsDate(command.validFrom), + }) + .pipe(Effect.mapError(unavailable)); + }, + ); + yield* Effect.forEach(preparedPurposes, insertPurpose, { + concurrency: 1, + discard: true, + }); + return yield* loadDto(transaction, created, now, aliases); + }, +); -const lockContactPoint = ( - transaction: PartyScopedTransaction, - tenantId: string, - contactPointId: string, -) => +const lockContactPoint = (transaction: PartyScopedTransaction, tenantId: string, contactPointId: string) => transaction .select() .from(partyContactPoints) - .where( - and( - eq(partyContactPoints.tenantId, tenantId), - eq(partyContactPoints.contactPointId, contactPointId), - ), - ) + .where(and(eq(partyContactPoints.tenantId, tenantId), eq(partyContactPoints.contactPointId, contactPointId))) .limit(1) .for('update') .pipe(Effect.mapError(unavailable)); -const findContactPointRow = ( - transaction: PartyScopedTransaction, - tenantId: string, - contactPointId: string, -) => +const findContactPointRow = (transaction: PartyScopedTransaction, tenantId: string, contactPointId: string) => transaction .select() .from(partyContactPoints) - .where( - and( - eq(partyContactPoints.tenantId, tenantId), - eq(partyContactPoints.contactPointId, contactPointId), - ), - ) + .where(and(eq(partyContactPoints.tenantId, tenantId), eq(partyContactPoints.contactPointId, contactPointId))) .limit(1) .pipe(Effect.mapError(unavailable)); @@ -857,22 +758,22 @@ const requireMutableContact = ( return Effect.succeed(row); }; -const loadUpdatedContactPoint = Effect.fn( - 'PartyContactPointPersistenceService.loadUpdatedContactPoint', -)(function* loadUpdatedContactPoint(input: { - readonly aliases: AliasOperations; - readonly row: PartyContactPointRecord; - readonly scope: OperationalScope; - readonly transaction: PartyScopedTransaction; -}) { - const { aliases, row, scope, transaction } = input; - const [updated] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); - if (updated === undefined) { - return yield* unavailable(); - } - const now = yield* DateTime.nowAsDate; - return yield* loadDto(transaction, updated, now, aliases); -}); +const loadUpdatedContactPoint = Effect.fn('PartyContactPointPersistenceService.loadUpdatedContactPoint')( + function* loadUpdatedContactPoint(input: { + readonly aliases: AliasOperations; + readonly row: PartyContactPointRecord; + readonly scope: OperationalScope; + readonly transaction: PartyScopedTransaction; + }) { + const { aliases, row, scope, transaction } = input; + const [updated] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); + if (updated === undefined) { + return yield* unavailable(); + } + const now = yield* DateTime.nowAsDate; + return yield* loadDto(transaction, updated, now, aliases); + }, +); type EndContactPointFailure = | PartyContactPointCorrectionRequired @@ -886,106 +787,33 @@ const endContactPointFromUpdate = ( scope: OperationalScope, command: EndContactPointCommand, aliases: AliasOperations, -): Effect.Effect< - Readonly<{ changed: boolean; contactPoint: PartyContactPoint }>, - EndContactPointFailure -> => endContactPointRecord(transaction, scope, command, aliases); +): Effect.Effect, EndContactPointFailure> => + endContactPointRecord(transaction, scope, command, aliases); -export const updateContactPointRecord = Effect.fn( - 'PartyContactPointPersistenceService.updateContactPointRecord', -)(function* updateContactPoint( - transaction: PartyScopedTransaction, - scope: OperationalScope, - command: UpdateContactPointCommand, - aliases: AliasOperations = transactionAliasService(transaction), -) { - const { change } = command; - const [observed] = yield* findContactPointRow( - transaction, - scope.tenantId, - command.contactPointRef.resourceId, - ); - const canonicalPartyId = - observed === undefined - ? null - : (yield* aliases - .resolvePartyAlias(scope.tenantId, observed.partyId) - .pipe(Effect.mapError(unavailable))).canonicalPartyId; - if (canonicalPartyId !== null) { - yield* lockParty(transaction, scope.tenantId, canonicalPartyId); - } - const [locked] = yield* lockContactPoint( - transaction, - scope.tenantId, - command.contactPointRef.resourceId, - ); - const row = yield* requireMutableContact( - locked, - command.contactPointRef, - command.expectedRevision, - ); - const operationTime = yield* DateTime.nowAsDate; - if (row.validTo !== null && toEpochMillis(row.validTo) <= toEpochMillis(operationTime)) { - yield* transaction - .update(partyContactPoints) - .set({ isCurrent: false, preferred: false, state: 'ENDED' }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ) - .pipe(Effect.mapError(unavailable)); - return yield* new PartyContactPointLifecycleConflict({ - code: 'party_contact_point_lifecycle_conflict', - reason: 'The Contact Point reached its effective end before this update', - }); - } - const nextRevision = row.revision + 1; - if (change.type === 'END_ADDRESS_PURPOSE') { - return yield* Effect.gen(function* endPurposeFromUpdate() { - const result = yield* endContactPointFromUpdate( - transaction, - scope, - { - acceptedByActionInvocationId: command.acceptedByActionInvocationId, - acceptedByPrincipalId: command.acceptedByPrincipalId, - contactPointRef: command.contactPointRef, - effectiveEnd: change.effectiveEnd, - provenance: command.provenance, - reason: change.reason, - target: { target: change.target, type: 'ADDRESS_PURPOSE' }, - }, - aliases, - ); - return result.contactPoint; - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.endPurposeFromUpdate')); - } - if (change.type === 'CORRECT_CONTACT_POINT') { - return yield* Effect.gen(function* correctContactPoint() { - const correctionEffectiveEnd = - toEpochMillis(row.validFrom) > toEpochMillis(operationTime) ? row.validFrom : operationTime; - const correctionEndAudit = { - endEvidenceRefs: change.evidenceReferences, - endProvenanceMethod: command.provenance.method, - endProvenanceSource: command.provenance.source, - endReason: change.reason, - endedByActionInvocationId: command.acceptedByActionInvocationId, - endedByPrincipalId: command.acceptedByPrincipalId, - endedRecordedAt: operationTime, - }; - const correctedState: 'RETRACTED' | 'SUPERSEDED' = - change.replacement === undefined ? 'RETRACTED' : 'SUPERSEDED'; +export const updateContactPointRecord = Effect.fn('PartyContactPointPersistenceService.updateContactPointRecord')( + function* updateContactPoint( + transaction: PartyScopedTransaction, + scope: OperationalScope, + command: UpdateContactPointCommand, + aliases: AliasOperations = transactionAliasService(transaction), + ) { + const { change } = command; + const [observed] = yield* findContactPointRow(transaction, scope.tenantId, command.contactPointRef.resourceId); + const canonicalPartyId = + observed === undefined + ? null + : (yield* aliases.resolvePartyAlias(scope.tenantId, observed.partyId).pipe(Effect.mapError(unavailable))) + .canonicalPartyId; + if (canonicalPartyId !== null) { + yield* lockParty(transaction, scope.tenantId, canonicalPartyId); + } + const [locked] = yield* lockContactPoint(transaction, scope.tenantId, command.contactPointRef.resourceId); + const row = yield* requireMutableContact(locked, command.contactPointRef, command.expectedRevision); + const operationTime = yield* DateTime.nowAsDate; + if (row.validTo !== null && toEpochMillis(row.validTo) <= toEpochMillis(operationTime)) { yield* transaction .update(partyContactPoints) - .set({ - ...correctionEndAudit, - isCurrent: false, - preferred: false, - revision: nextRevision, - state: correctedState, - validTo: correctionEffectiveEnd, - }) + .set({ isCurrent: false, preferred: false, state: 'ENDED' }) .where( and( eq(partyContactPoints.tenantId, scope.tenantId), @@ -993,163 +821,329 @@ export const updateContactPointRecord = Effect.fn( ), ) .pipe(Effect.mapError(unavailable)); - if (row.contactPointType === 'ADDRESS') { + return yield* new PartyContactPointLifecycleConflict({ + code: 'party_contact_point_lifecycle_conflict', + reason: 'The Contact Point reached its effective end before this update', + }); + } + const nextRevision = row.revision + 1; + if (change.type === 'END_ADDRESS_PURPOSE') { + return yield* Effect.gen(function* endPurposeFromUpdate() { + const result = yield* endContactPointFromUpdate( + transaction, + scope, + { + acceptedByActionInvocationId: command.acceptedByActionInvocationId, + acceptedByPrincipalId: command.acceptedByPrincipalId, + contactPointRef: command.contactPointRef, + effectiveEnd: change.effectiveEnd, + provenance: command.provenance, + reason: change.reason, + target: { target: change.target, type: 'ADDRESS_PURPOSE' }, + }, + aliases, + ); + return result.contactPoint; + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.endPurposeFromUpdate')); + } + if (change.type === 'CORRECT_CONTACT_POINT') { + return yield* Effect.gen(function* correctContactPoint() { + const correctionEffectiveEnd = + toEpochMillis(row.validFrom) > toEpochMillis(operationTime) ? row.validFrom : operationTime; + const correctionEndAudit = { + endEvidenceRefs: change.evidenceReferences, + endProvenanceMethod: command.provenance.method, + endProvenanceSource: command.provenance.source, + endReason: change.reason, + endedByActionInvocationId: command.acceptedByActionInvocationId, + endedByPrincipalId: command.acceptedByPrincipalId, + endedRecordedAt: operationTime, + }; + const correctedState: 'RETRACTED' | 'SUPERSEDED' = + change.replacement === undefined ? 'RETRACTED' : 'SUPERSEDED'; yield* transaction - .update(partyContactPointPurposes) + .update(partyContactPoints) .set({ ...correctionEndAudit, isCurrent: false, preferred: false, + revision: nextRevision, state: correctedState, validTo: correctionEffectiveEnd, }) .where( and( - eq(partyContactPointPurposes.tenantId, scope.tenantId), - eq(partyContactPointPurposes.contactPointId, row.contactPointId), - eq(partyContactPointPurposes.isCurrent, true), + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.contactPointId, row.contactPointId), ), ) .pipe(Effect.mapError(unavailable)); - } - const replacement = - change.replacement === undefined - ? null - : yield* addContactPointRecord( - transaction, - scope, - { - ...change.replacement, - acceptedByActionInvocationId: command.acceptedByActionInvocationId, - acceptedByPrincipalId: command.acceptedByPrincipalId, - partyRef: partyRef(scope.tenantId, canonicalPartyId ?? row.partyId), - }, - aliases, - ); - yield* transaction - .insert(partyCorrections) - .values({ - actingPrincipalId: command.acceptedByPrincipalId, - actionInvocationId: command.acceptedByActionInvocationId, - contactPointId: row.contactPointId, - evidenceRefs: change.evidenceReferences, - partyId: row.partyId, - policyVersion: 'party-contact-point-correction.v1', - reason: change.reason, - replacementContactPointId: replacement?.contactPointRef.resourceId ?? null, - tenantId: scope.tenantId, - }) - .pipe(Effect.mapError(unavailable)); - if (replacement !== null) { - return replacement; - } - const [corrected] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); - if (corrected === undefined) { - return yield* unavailable(); - } - return yield* loadDto(transaction, corrected, operationTime, aliases); - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.correctContactPoint')); - } - if (change.type === 'SET_CHANNEL_PREFERRED') { - return yield* Effect.gen(function* setChannelPreference() { - if (row.contactPointType === 'ADDRESS') { - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'ADDRESS preference is scoped per purpose, not on the address as a whole', + if (row.contactPointType === 'ADDRESS') { + yield* transaction + .update(partyContactPointPurposes) + .set({ + ...correctionEndAudit, + isCurrent: false, + preferred: false, + state: correctedState, + validTo: correctionEffectiveEnd, + }) + .where( + and( + eq(partyContactPointPurposes.tenantId, scope.tenantId), + eq(partyContactPointPurposes.contactPointId, row.contactPointId), + eq(partyContactPointPurposes.isCurrent, true), + ), + ) + .pipe(Effect.mapError(unavailable)); + } + const replacement = + change.replacement === undefined + ? null + : yield* addContactPointRecord( + transaction, + scope, + { + ...change.replacement, + acceptedByActionInvocationId: command.acceptedByActionInvocationId, + acceptedByPrincipalId: command.acceptedByPrincipalId, + partyRef: partyRef(scope.tenantId, canonicalPartyId ?? row.partyId), + }, + aliases, + ); + yield* transaction + .insert(partyCorrections) + .values({ + actingPrincipalId: command.acceptedByPrincipalId, + actionInvocationId: command.acceptedByActionInvocationId, + contactPointId: row.contactPointId, + evidenceRefs: change.evidenceReferences, + partyId: row.partyId, + policyVersion: 'party-contact-point-correction.v1', + reason: change.reason, + replacementContactPointId: replacement?.contactPointRef.resourceId ?? null, + tenantId: scope.tenantId, + }) + .pipe(Effect.mapError(unavailable)); + if (replacement !== null) { + return replacement; + } + const [corrected] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); + if (corrected === undefined) { + return yield* unavailable(); + } + return yield* loadDto(transaction, corrected, operationTime, aliases); + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.correctContactPoint')); + } + if (change.type === 'SET_CHANNEL_PREFERRED') { + return yield* Effect.gen(function* setChannelPreference() { + if (row.contactPointType === 'ADDRESS') { + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'ADDRESS preference is scoped per purpose, not on the address as a whole', + }); + } + if (change.preferred) { + yield* transaction + .update(partyContactPoints) + .set({ + preferred: false, + revision: sql`${partyContactPoints.revision} + 1`, + }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.partyId, row.partyId), + eq(partyContactPoints.contactPointType, row.contactPointType), + eq(partyContactPoints.isCurrent, true), + eq(partyContactPoints.preferred, true), + ne(partyContactPoints.contactPointId, row.contactPointId), + ), + ) + .pipe(Effect.mapError(unavailable)); + } + yield* transaction + .update(partyContactPoints) + .set({ preferred: change.preferred, revision: nextRevision }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.contactPointId, row.contactPointId), + ), + ) + .pipe(Effect.mapError(unavailable)); + return yield* loadUpdatedContactPoint({ + aliases, + row, + scope, + transaction, }); - } - if (change.preferred) { + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.setChannelPreference')); + } + if (change.type === 'ENRICH_VERIFICATION') { + return yield* Effect.gen(function* enrichVerification() { + yield* transaction + .update(partyContactPoints) + .set({ + revision: nextRevision, + verificationMethod: change.verification.method ?? null, + verificationState: change.verification.state, + verifiedAt: + change.verification.verifiedAt === undefined ? null : DateTime.toDateUtc(change.verification.verifiedAt), + verifiedByPrincipalId: change.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, + verifierReference: change.verification.verifierReference ?? null, + }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.contactPointId, row.contactPointId), + ), + ) + .pipe(Effect.mapError(unavailable)); + return yield* loadUpdatedContactPoint({ + aliases, + row, + scope, + transaction, + }); + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.enrichVerification')); + } + if (change.type === 'ADD_PROVENANCE') { + return yield* Effect.gen(function* addProvenance() { + if (row.provenanceSource !== change.provenance.source || row.provenanceMethod !== change.provenance.method) { + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'Replacing provenance would erase assertion history; use correction semantics', + }); + } + if ( + row.externalEvidence !== null && + change.provenance.externalEvidence !== undefined && + row.externalEvidence.evidenceRef !== change.provenance.externalEvidence.evidenceRef + ) { + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'Replacing external observation evidence would erase accepted provenance', + }); + } + const additionalEvidenceRefs = enrichedEvidenceReferences(row, change.provenance); + if (additionalEvidenceRefs.length > 32) { + return yield* new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'Contact Point evidence enrichment exceeds its bounded history', + }); + } + const externalEvidence = yield* encodeExternalEvidence(change.provenance); yield* transaction .update(partyContactPoints) - .set({ preferred: false, revision: sql`${partyContactPoints.revision} + 1` }) + .set({ + additionalEvidenceRefs, + externalEvidence: row.externalEvidence ?? externalEvidence, + provenanceAuthoritative: row.provenanceAuthoritative || change.provenance.authoritative, + revision: nextRevision, + }) .where( and( eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.partyId, row.partyId), - eq(partyContactPoints.contactPointType, row.contactPointType), - eq(partyContactPoints.isCurrent, true), - eq(partyContactPoints.preferred, true), - ne(partyContactPoints.contactPointId, row.contactPointId), + eq(partyContactPoints.contactPointId, row.contactPointId), ), ) .pipe(Effect.mapError(unavailable)); + return yield* loadUpdatedContactPoint({ + aliases, + row, + scope, + transaction, + }); + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.addProvenance')); + } + return yield* Effect.gen(function* setAddressPurpose() { + if (row.contactPointType !== 'ADDRESS') { + return yield* new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'Address purpose metadata can be changed only on ADDRESS Contact Points', + }); } - yield* transaction - .update(partyContactPoints) - .set({ preferred: change.preferred, revision: nextRevision }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ) - .pipe(Effect.mapError(unavailable)); - return yield* loadUpdatedContactPoint({ aliases, row, scope, transaction }); - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.setChannelPreference')); - } - if (change.type === 'ENRICH_VERIFICATION') { - return yield* Effect.gen(function* enrichVerification() { - yield* transaction - .update(partyContactPoints) - .set({ - revision: nextRevision, - verificationMethod: change.verification.method ?? null, - verificationState: change.verification.state, - verifiedAt: - change.verification.verifiedAt === undefined - ? null - : DateTime.toDateUtc(change.verification.verifiedAt), - verifiedByPrincipalId: - change.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, - verifierReference: change.verification.verifierReference ?? null, - }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), + const assignment = change.assignment; + yield* Effect.try({ + try: () => assertAddressPurposeRules([assignment], command.provenance), + catch: (cause) => + Object.assign( + new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'Address purpose requires a valid registry context and authoritative provenance', + }), + { cause }, ), - ) - .pipe(Effect.mapError(unavailable)); - return yield* loadUpdatedContactPoint({ aliases, row, scope, transaction }); - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.enrichVerification')); - } - if (change.type === 'ADD_PROVENANCE') { - return yield* Effect.gen(function* addProvenance() { - if ( - row.provenanceSource !== change.provenance.source || - row.provenanceMethod !== change.provenance.method - ) { - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'Replacing provenance would erase assertion history; use correction semantics', + }); + const purposes = yield* loadPurposeScope(transaction, scope.tenantId, row.partyId, assignment, operationTime); + const current = purposes.find( + (purpose) => purpose.contactPointId === row.contactPointId && isPurposeCurrentAt(purpose, operationTime), + ); + const conflicting = purposes.find((purpose) => purpose.contactPointId !== row.contactPointId); + if (assignment.purpose === 'REGISTERED' && conflicting !== undefined) { + return yield* new PartyContactPointAlreadyExists({ + code: 'party_contact_point_already_exists', + existingContactPointRef: contactPointRef(scope.tenantId, conflicting.contactPointId), + reason: 'An accepted REGISTERED address already occupies this registry context; end or correct it explicitly', }); } - if ( - row.externalEvidence !== null && - change.provenance.externalEvidence !== undefined && - row.externalEvidence.evidenceRef !== change.provenance.externalEvidence.evidenceRef - ) { - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'Replacing external observation evidence would erase accepted provenance', + if (current === undefined && purposes.some((purpose) => purpose.contactPointId === row.contactPointId)) { + return yield* new PartyContactPointLifecycleConflict({ + code: 'party_contact_point_lifecycle_conflict', + reason: 'A future ADDRESS purpose already reserves this effective period', }); } - const additionalEvidenceRefs = enrichedEvidenceReferences(row, change.provenance); - if (additionalEvidenceRefs.length > 32) { - return yield* new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'Contact Point evidence enrichment exceeds its bounded history', - }); + if (assignment.preferred) { + yield* transferPurposePreference( + transaction, + scope.tenantId, + row.contactPointId, + purposes, + current?.contactPointPurposeId, + ); + } + const externalEvidence = yield* encodeExternalEvidence(command.provenance); + if (current === undefined) { + yield* transaction + .insert(partyContactPointPurposes) + .values({ + acceptedByActionInvocationId: command.acceptedByActionInvocationId, + acceptedByPrincipalId: command.acceptedByPrincipalId, + contactPointId: row.contactPointId, + evidenceReference: command.provenance.evidenceReference ?? null, + externalEvidence, + isCurrent: true, + ...purposeRegistryColumns(assignment.registryContext), + partyId: row.partyId, + policyVersion: 'party-contact-point.v1', + preferred: assignment.preferred, + provenanceAuthoritative: command.provenance.authoritative, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + purposeKey: assignment.purpose, + tenantId: scope.tenantId, + validFrom: operationTime, + }) + .pipe(Effect.mapError(unavailable)); + } else { + yield* transaction + .update(partyContactPointPurposes) + .set({ + preferred: assignment.preferred, + revision: current.revision + 1, + }) + .where( + and( + eq(partyContactPointPurposes.tenantId, scope.tenantId), + eq(partyContactPointPurposes.contactPointPurposeId, current.contactPointPurposeId), + ), + ) + .pipe(Effect.mapError(unavailable)); } - const externalEvidence = yield* encodeExternalEvidence(change.provenance); yield* transaction .update(partyContactPoints) - .set({ - additionalEvidenceRefs, - externalEvidence: row.externalEvidence ?? externalEvidence, - provenanceAuthoritative: row.provenanceAuthoritative || change.provenance.authoritative, - revision: nextRevision, - }) + .set({ revision: nextRevision }) .where( and( eq(partyContactPoints.tenantId, scope.tenantId), @@ -1158,114 +1152,9 @@ export const updateContactPointRecord = Effect.fn( ) .pipe(Effect.mapError(unavailable)); return yield* loadUpdatedContactPoint({ aliases, row, scope, transaction }); - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.addProvenance')); - } - return yield* Effect.gen(function* setAddressPurpose() { - if (row.contactPointType !== 'ADDRESS') { - return yield* new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'Address purpose metadata can be changed only on ADDRESS Contact Points', - }); - } - const assignment = change.assignment; - yield* Effect.try({ - try: () => assertAddressPurposeRules([assignment], command.provenance), - catch: (cause) => - Object.assign( - new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: - 'Address purpose requires a valid registry context and authoritative provenance', - }), - { cause }, - ), - }); - const purposes = yield* loadPurposeScope( - transaction, - scope.tenantId, - row.partyId, - assignment, - operationTime, - ); - const current = purposes.find( - (purpose) => - purpose.contactPointId === row.contactPointId && isPurposeCurrentAt(purpose, operationTime), - ); - const conflicting = purposes.find((purpose) => purpose.contactPointId !== row.contactPointId); - if (assignment.purpose === 'REGISTERED' && conflicting !== undefined) { - return yield* new PartyContactPointAlreadyExists({ - code: 'party_contact_point_already_exists', - existingContactPointRef: contactPointRef(scope.tenantId, conflicting.contactPointId), - reason: - 'An accepted REGISTERED address already occupies this registry context; end or correct it explicitly', - }); - } - if ( - current === undefined && - purposes.some((purpose) => purpose.contactPointId === row.contactPointId) - ) { - return yield* new PartyContactPointLifecycleConflict({ - code: 'party_contact_point_lifecycle_conflict', - reason: 'A future ADDRESS purpose already reserves this effective period', - }); - } - if (assignment.preferred) { - yield* transferPurposePreference( - transaction, - scope.tenantId, - row.contactPointId, - purposes, - current?.contactPointPurposeId, - ); - } - const externalEvidence = yield* encodeExternalEvidence(command.provenance); - if (current === undefined) { - yield* transaction - .insert(partyContactPointPurposes) - .values({ - acceptedByActionInvocationId: command.acceptedByActionInvocationId, - acceptedByPrincipalId: command.acceptedByPrincipalId, - contactPointId: row.contactPointId, - evidenceReference: command.provenance.evidenceReference ?? null, - externalEvidence, - isCurrent: true, - ...purposeRegistryColumns(assignment.registryContext), - partyId: row.partyId, - policyVersion: 'party-contact-point.v1', - preferred: assignment.preferred, - provenanceAuthoritative: command.provenance.authoritative, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - purposeKey: assignment.purpose, - tenantId: scope.tenantId, - validFrom: operationTime, - }) - .pipe(Effect.mapError(unavailable)); - } else { - yield* transaction - .update(partyContactPointPurposes) - .set({ preferred: assignment.preferred, revision: current.revision + 1 }) - .where( - and( - eq(partyContactPointPurposes.tenantId, scope.tenantId), - eq(partyContactPointPurposes.contactPointPurposeId, current.contactPointPurposeId), - ), - ) - .pipe(Effect.mapError(unavailable)); - } - yield* transaction - .update(partyContactPoints) - .set({ revision: nextRevision }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ) - .pipe(Effect.mapError(unavailable)); - return yield* loadUpdatedContactPoint({ aliases, row, scope, transaction }); - }).pipe(Effect.withSpan('PartyContactPointPersistenceService.setAddressPurpose')); -}); + }).pipe(Effect.withSpan('PartyContactPointPersistenceService.setAddressPurpose')); + }, +); const validatePurposeEnd = Effect.fn('PartyContactPointPersistenceService.validatePurposeEnd')( function* validatePurposeEnd(purpose: PurposeRecord, effectiveEndMillis: number) { @@ -1284,78 +1173,59 @@ const validatePurposeEnd = Effect.fn('PartyContactPointPersistenceService.valida }, ); -export const endContactPointRecord = Effect.fn( - 'PartyContactPointPersistenceService.endContactPointRecord', -)(function* endContactPoint( - transaction: PartyScopedTransaction, - scope: OperationalScope, - command: EndContactPointCommand, - aliases: AliasOperations = transactionAliasService(transaction), -) { - const { target } = command; - const [observed] = yield* findContactPointRow( - transaction, - scope.tenantId, - command.contactPointRef.resourceId, - ); - if (observed !== undefined) { - const resolution = yield* aliases - .resolvePartyAlias(scope.tenantId, observed.partyId) - .pipe(Effect.mapError(unavailable)); - yield* lockParty(transaction, scope.tenantId, resolution.canonicalPartyId); - } - const [row] = yield* lockContactPoint( - transaction, - scope.tenantId, - command.contactPointRef.resourceId, - ); - if (row === undefined) { - return yield* new PartyContactPointNotFound({ - code: 'party_contact_point_not_found', - contactPointRef: command.contactPointRef, - reason: 'The requested Party Contact Point does not exist', - }); - } - const effectiveEnd = instantAsDate(command.effectiveEnd); - if (toEpochMillis(effectiveEnd) < toEpochMillis(row.validFrom)) { - return yield* new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'The effective end cannot precede the Contact Point effective start', - }); - } - const now = yield* DateTime.nowAsDate; - const effectiveEndMillis = toEpochMillis(effectiveEnd); - const nowMillis = toEpochMillis(now); - const isFutureEnd = effectiveEndMillis > nowMillis; - const endAudit = { - endEvidenceRefs: - command.provenance.evidenceReference === undefined - ? [] - : [command.provenance.evidenceReference], - endProvenanceMethod: command.provenance.method, - endProvenanceSource: command.provenance.source, - endReason: command.reason, - endedByActionInvocationId: command.acceptedByActionInvocationId, - endedByPrincipalId: command.acceptedByPrincipalId, - endedRecordedAt: now, - }; - if ( - effectiveEndMillis < nowMillis - 60_000 && - command.provenance.evidenceReference === undefined +export const endContactPointRecord = Effect.fn('PartyContactPointPersistenceService.endContactPointRecord')( + function* endContactPoint( + transaction: PartyScopedTransaction, + scope: OperationalScope, + command: EndContactPointCommand, + aliases: AliasOperations = transactionAliasService(transaction), ) { - return yield* new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'A backdated effective end requires bounded evidence provenance', - }); - } - const unchanged = yield* Match.value(target).pipe( - Match.discriminatorsExhaustive('type')({ - ADDRESS_PURPOSE: Effect.fn('PartyContactPointPersistenceService.endAddressPurpose')( - function* endAddressPurpose( - selectedTarget: Extract< - EndContactPointCommand['target'], - { readonly type: 'ADDRESS_PURPOSE' } - >, + const { target } = command; + const [observed] = yield* findContactPointRow(transaction, scope.tenantId, command.contactPointRef.resourceId); + if (observed !== undefined) { + const resolution = yield* aliases + .resolvePartyAlias(scope.tenantId, observed.partyId) + .pipe(Effect.mapError(unavailable)); + yield* lockParty(transaction, scope.tenantId, resolution.canonicalPartyId); + } + const [row] = yield* lockContactPoint(transaction, scope.tenantId, command.contactPointRef.resourceId); + if (row === undefined) { + return yield* new PartyContactPointNotFound({ + code: 'party_contact_point_not_found', + contactPointRef: command.contactPointRef, + reason: 'The requested Party Contact Point does not exist', + }); + } + const effectiveEnd = instantAsDate(command.effectiveEnd); + if (toEpochMillis(effectiveEnd) < toEpochMillis(row.validFrom)) { + return yield* new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'The effective end cannot precede the Contact Point effective start', + }); + } + const now = yield* DateTime.nowAsDate; + const effectiveEndMillis = toEpochMillis(effectiveEnd); + const nowMillis = toEpochMillis(now); + const isFutureEnd = effectiveEndMillis > nowMillis; + const endAudit = { + endEvidenceRefs: command.provenance.evidenceReference === undefined ? [] : [command.provenance.evidenceReference], + endProvenanceMethod: command.provenance.method, + endProvenanceSource: command.provenance.source, + endReason: command.reason, + endedByActionInvocationId: command.acceptedByActionInvocationId, + endedByPrincipalId: command.acceptedByPrincipalId, + endedRecordedAt: now, + }; + if (effectiveEndMillis < nowMillis - 60_000 && command.provenance.evidenceReference === undefined) { + return yield* new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'A backdated effective end requires bounded evidence provenance', + }); + } + const unchanged = yield* Match.value(target).pipe( + Match.discriminatorsExhaustive('type')({ + ADDRESS_PURPOSE: Effect.fn('PartyContactPointPersistenceService.endAddressPurpose')(function* endAddressPurpose( + selectedTarget: Extract, ) { if (row.contactPointType !== 'ADDRESS') { return yield* new PartyContactPointInvalid({ @@ -1376,12 +1246,9 @@ export const endContactPointRecord = Effect.fn( const sameBoundary = purposes.find( (candidate) => candidate.purposeKey === selectedTarget.target.purpose && - candidate.registryContext === - (selectedTarget.target.registryContext?.registryKey ?? 'GENERAL') && - candidate.jurisdiction === - (selectedTarget.target.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ') && - (candidate.validTo === null ? undefined : toEpochMillis(candidate.validTo)) === - effectiveEndMillis, + candidate.registryContext === (selectedTarget.target.registryContext?.registryKey ?? 'GENERAL') && + candidate.jurisdiction === (selectedTarget.target.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ') && + (candidate.validTo === null ? undefined : toEpochMillis(candidate.validTo)) === effectiveEndMillis, ); if (sameBoundary !== undefined) { if (hasExactEndSemantics(sameBoundary, command)) { @@ -1398,10 +1265,8 @@ export const endContactPointRecord = Effect.fn( const purpose = purposes.find( (candidate) => candidate.purposeKey === selectedTarget.target.purpose && - candidate.registryContext === - (selectedTarget.target.registryContext?.registryKey ?? 'GENERAL') && - candidate.jurisdiction === - (selectedTarget.target.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ') && + candidate.registryContext === (selectedTarget.target.registryContext?.registryKey ?? 'GENERAL') && + candidate.jurisdiction === (selectedTarget.target.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ') && candidate.isCurrent, ); if (purpose === undefined) { @@ -1429,9 +1294,7 @@ export const endContactPointRecord = Effect.fn( ) .pipe(Effect.mapError(unavailable)); const remaining = purposes.filter( - (candidate) => - candidate.contactPointPurposeId !== purpose.contactPointPurposeId && - candidate.isCurrent, + (candidate) => candidate.contactPointPurposeId !== purpose.contactPointPurposeId && candidate.isCurrent, ); if (remaining.length === 0 && !isFutureEnd) { yield* transaction @@ -1464,111 +1327,106 @@ export const endContactPointRecord = Effect.fn( .pipe(Effect.mapError(unavailable)); } return yield* Effect.void; - }, - ), - WHOLE_CONTACT_POINT: Effect.fn('PartyContactPointPersistenceService.endWholeContactPoint')( - function* endWholeContactPoint() { - if ( - (row.validTo === null ? undefined : toEpochMillis(row.validTo)) === effectiveEndMillis - ) { - if (hasExactEndSemantics(row, command)) { - return { - changed: false, - contactPoint: yield* loadDto(transaction, row, now, aliases), - }; + }), + WHOLE_CONTACT_POINT: Effect.fn('PartyContactPointPersistenceService.endWholeContactPoint')( + function* endWholeContactPoint() { + if ((row.validTo === null ? undefined : toEpochMillis(row.validTo)) === effectiveEndMillis) { + if (hasExactEndSemantics(row, command)) { + return { + changed: false, + contactPoint: yield* loadDto(transaction, row, now, aliases), + }; + } + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'The Contact Point has different end evidence at this effective boundary', + }); } - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'The Contact Point has different end evidence at this effective boundary', - }); - } - if (row.validTo !== null) { - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'Changing a planned Contact Point end requires correction semantics', - }); - } - if (!row.isCurrent) { - return yield* new PartyContactPointLifecycleConflict({ - code: 'party_contact_point_lifecycle_conflict', - reason: 'The Contact Point was already ended at a different effective time', - }); - } - yield* transaction - .update(partyContactPoints) - .set({ - ...endAudit, - isCurrent: isFutureEnd, - preferred: isFutureEnd ? row.preferred : false, - revision: row.revision + 1, - state: isFutureEnd ? 'ACTIVE' : 'ENDED', - validTo: effectiveEnd, - }) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, row.contactPointId), - ), - ) - .pipe(Effect.mapError(unavailable)); - if (row.contactPointType === 'ADDRESS') { - const purposes = yield* loadPurposes(transaction, scope.tenantId, row.contactPointId); - if ( - purposes.some( - (purpose) => - purpose.isCurrent && effectiveEndMillis < toEpochMillis(purpose.validFrom), - ) - ) { + if (row.validTo !== null) { return yield* new PartyContactPointCorrectionRequired({ code: 'party_contact_point_correction_required', - reason: 'The Contact Point end predates a current ADDRESS purpose', + reason: 'Changing a planned Contact Point end requires correction semantics', + }); + } + if (!row.isCurrent) { + return yield* new PartyContactPointLifecycleConflict({ + code: 'party_contact_point_lifecycle_conflict', + reason: 'The Contact Point was already ended at a different effective time', }); } - const purposesToEnd = purposes.filter( - (purpose) => - purpose.isCurrent && - (purpose.validTo === null || toEpochMillis(purpose.validTo) > effectiveEndMillis), - ); - yield* Effect.forEach( - purposesToEnd, - (purpose) => - transaction - .update(partyContactPointPurposes) - .set({ - ...endAudit, - isCurrent: isFutureEnd, - preferred: isFutureEnd ? purpose.preferred : false, - revision: purpose.revision + 1, - state: isFutureEnd ? 'ACTIVE' : 'ENDED', - validTo: effectiveEnd, - }) - .where( - and( - eq(partyContactPointPurposes.tenantId, scope.tenantId), - eq( - partyContactPointPurposes.contactPointPurposeId, - purpose.contactPointPurposeId, + yield* transaction + .update(partyContactPoints) + .set({ + ...endAudit, + isCurrent: isFutureEnd, + preferred: isFutureEnd ? row.preferred : false, + revision: row.revision + 1, + state: isFutureEnd ? 'ACTIVE' : 'ENDED', + validTo: effectiveEnd, + }) + .where( + and( + eq(partyContactPoints.tenantId, scope.tenantId), + eq(partyContactPoints.contactPointId, row.contactPointId), + ), + ) + .pipe(Effect.mapError(unavailable)); + if (row.contactPointType === 'ADDRESS') { + const purposes = yield* loadPurposes(transaction, scope.tenantId, row.contactPointId); + if ( + purposes.some((purpose) => purpose.isCurrent && effectiveEndMillis < toEpochMillis(purpose.validFrom)) + ) { + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'The Contact Point end predates a current ADDRESS purpose', + }); + } + const purposesToEnd = purposes.filter( + (purpose) => + purpose.isCurrent && + (purpose.validTo === null || toEpochMillis(purpose.validTo) > effectiveEndMillis), + ); + yield* Effect.forEach( + purposesToEnd, + (purpose) => + transaction + .update(partyContactPointPurposes) + .set({ + ...endAudit, + isCurrent: isFutureEnd, + preferred: isFutureEnd ? purpose.preferred : false, + revision: purpose.revision + 1, + state: isFutureEnd ? 'ACTIVE' : 'ENDED', + validTo: effectiveEnd, + }) + .where( + and( + eq(partyContactPointPurposes.tenantId, scope.tenantId), + eq(partyContactPointPurposes.contactPointPurposeId, purpose.contactPointPurposeId), ), - ), - ) - .pipe(Effect.mapError(unavailable)), - { concurrency: 1, discard: true }, - ); - } - return yield* Effect.void; - }, - ), - }), - ); - if (unchanged !== undefined) { - return unchanged; - } - const [updated] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); - if (updated === undefined) { - return yield* unavailable(); - } - return { changed: true, contactPoint: yield* loadDto(transaction, updated, now, aliases) }; -}); + ) + .pipe(Effect.mapError(unavailable)), + { concurrency: 1, discard: true }, + ); + } + return yield* Effect.void; + }, + ), + }), + ); + if (unchanged !== undefined) { + return unchanged; + } + const [updated] = yield* lockContactPoint(transaction, scope.tenantId, row.contactPointId); + if (updated === undefined) { + return yield* unavailable(); + } + return { + changed: true, + contactPoint: yield* loadDto(transaction, updated, now, aliases), + }; + }, +); export const listPartyContactPointRecords = Effect.fn( 'PartyContactPointPersistenceService.listPartyContactPointRecords', @@ -1582,9 +1440,7 @@ export const listPartyContactPointRecords = Effect.fn( }, aliases: AliasOperations = transactionAliasService(transaction), ) { - const resolution = yield* aliases - .resolvePartyAlias(scope.tenantId, input.partyId) - .pipe(Effect.mapError(unavailable)); + const resolution = yield* aliases.resolvePartyAlias(scope.tenantId, input.partyId).pipe(Effect.mapError(unavailable)); const rows = yield* transaction .select() .from(partyContactPoints) @@ -1604,28 +1460,25 @@ export const listPartyContactPointRecords = Effect.fn( return input.includeHistorical ? dtos : dtos.filter(({ current }) => current); }); -export const findPartyContactPointRecord = Effect.fn( - 'PartyContactPointPersistenceService.findPartyContactPointRecord', -)(function* findContactPoint( - transaction: PartyScopedTransaction, - scope: OperationalScope, - contactPointId: string, - aliases: AliasOperations = transactionAliasService(transaction), -) { - const [row] = yield* transaction - .select() - .from(partyContactPoints) - .where( - and( - eq(partyContactPoints.tenantId, scope.tenantId), - eq(partyContactPoints.contactPointId, contactPointId), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (row === undefined) { - return Option.none(); - } - const now = yield* DateTime.nowAsDate; - return Option.some(yield* loadDto(transaction, row, now, aliases)); -}); +export const findPartyContactPointRecord = Effect.fn('PartyContactPointPersistenceService.findPartyContactPointRecord')( + function* findContactPoint( + transaction: PartyScopedTransaction, + scope: OperationalScope, + contactPointId: string, + aliases: AliasOperations = transactionAliasService(transaction), + ) { + const [row] = yield* transaction + .select() + .from(partyContactPoints) + .where( + and(eq(partyContactPoints.tenantId, scope.tenantId), eq(partyContactPoints.contactPointId, contactPointId)), + ) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (row === undefined) { + return Option.none(); + } + const now = yield* DateTime.nowAsDate; + return Option.some(yield* loadDto(transaction, row, now, aliases)); + }, +); diff --git a/app/verticals/party-registry/src/services/party-correction.service.ts b/app/verticals/party-registry/src/services/party-correction.service.ts index 862047556..9d74ead84 100644 --- a/app/verticals/party-registry/src/services/party-correction.service.ts +++ b/app/verticals/party-registry/src/services/party-correction.service.ts @@ -1,8 +1,8 @@ -import { evaluatePartySubjectEvidence } from '../policies/create-party-without-strong-identifier.policy.ts'; // @generated by OntOS Codesmith Action Service v1 import { findPostgresFailure } from '@app/core-runtime'; import { and, eq, sql } from 'drizzle-orm'; import { DateTime, Effect, Match, Option, Result, Schema } from 'effect'; + import type { CorrectablePartyFact, IdentityCorrectionCommand, @@ -37,10 +37,7 @@ import { // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Pure ResourceRef value constructor. makePartyRef, } from '../../shared/domain/identity-contracts.ts'; -import { - PartyRelationshipProvenanceSchema, - partyRelationshipRef, -} from '../../shared/domain/relationship-contract.ts'; +import { PartyRelationshipProvenanceSchema, partyRelationshipRef } from '../../shared/domain/relationship-contract.ts'; // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- This is a pure ResourceRef value factory, not an Effect service constructor. import { makePartyCorrectionRef } from '../../shared/resources/party-correction.ts'; import { @@ -52,14 +49,9 @@ import { partyRelationships, } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; -import { - requireCanonicalPartyWriteTarget, - resolvePartyAlias, -} from '../merge/party-alias-resolution.service.ts'; -import { - lockAndResolveClaims, - lockTenantIdentityWrites, -} from './party-identifier-claim.service.ts'; +import { requireCanonicalPartyWriteTarget, resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; +import { evaluatePartySubjectEvidence } from '../policies/create-party-without-strong-identifier.policy.ts'; +import { lockAndResolveClaims, lockTenantIdentityWrites } from './party-identifier-claim.service.ts'; import { reconcilePartyIdentifierClaims } from './party-identity-persistence.service.ts'; type CorrectionTransaction = Pick; @@ -92,35 +84,31 @@ const relationshipMutationFailure = ( }) : unavailable(failure); -const requireCanonicalCorrectionTarget = Effect.fn( - 'PartyCorrectionService.requireCanonicalCorrectionTarget', -)(function* requireCanonicalTarget( - transaction: Pick, - tenantId: string, - partyId: string, -) { - const [party] = yield* transaction - .select() - .from(parties) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if (party === undefined) { - return yield* new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'A correction must explicitly target an existing canonical Party', - }); - } - yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId).pipe( - Effect.mapError((error) => - Match.value(error).pipe( - Match.tag('PartyAliasWriteRejected', (aliasRejection) => aliasRejection), - Match.orElse((failure) => unavailable(failure)), +const requireCanonicalCorrectionTarget = Effect.fn('PartyCorrectionService.requireCanonicalCorrectionTarget')( + function* requireCanonicalTarget(transaction: Pick, tenantId: string, partyId: string) { + const [party] = yield* transaction + .select() + .from(parties) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if (party === undefined) { + return yield* new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'A correction must explicitly target an existing canonical Party', + }); + } + yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId).pipe( + Effect.mapError((error) => + Match.value(error).pipe( + Match.tag('PartyAliasWriteRejected', (aliasRejection) => aliasRejection), + Match.orElse((failure) => unavailable(failure)), + ), ), - ), - ); -}); + ); + }, +); const StoredCorrectionReasonSchema = Schema.Struct({ evidenceSource: PartyCorrectionEvidenceSourceSchema, @@ -144,9 +132,7 @@ export const encodeStoredCorrectionReason = (command: PartyCorrectionCommand): s ); const decodeStoredCorrectionReason = (stored: string) => - Schema.decodeUnknownEffect(StoredCorrectionReasonCodec)(stored).pipe( - Effect.mapError(unavailable), - ); + Schema.decodeEffect(StoredCorrectionReasonCodec)(stored).pipe(Effect.mapError(unavailable)); const requireActiveRelationshipTarget = ( target: typeof partyRelationships.$inferSelect | undefined, @@ -161,14 +147,17 @@ const requireActiveRelationshipTarget = ( ) : Effect.succeed(target); -const requireActiveCurrentAssertion = < - Row extends { readonly isCurrent: boolean; readonly state: string }, ->( +const requireActiveCurrentAssertion = ( target: Row | undefined, reason: string, ) => target === undefined || target.state !== 'ACTIVE' || !target.isCurrent - ? Effect.fail(new PartyCorrectionConflict({ code: 'party_correction_conflict', reason })) + ? Effect.fail( + new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason, + }), + ) : Effect.succeed(target); const validatePartyTypeCorrection = Effect.fn('PartyCorrectionService.validatePartyTypeCorrection')( @@ -181,9 +170,7 @@ const validatePartyTypeCorrection = Effect.fn('PartyCorrectionService.validatePa if (command.factKind !== 'PARTY_TYPE') { return null; } - const nextType = yield* Schema.decodeUnknownEffect(PartyTypeSchema)( - command.replacementValue, - ).pipe( + const nextType = yield* Schema.decodeUnknownEffect(PartyTypeSchema)(command.replacementValue).pipe( Effect.mapError((cause) => attachCause( new PartyCorrectionConflict({ @@ -210,12 +197,7 @@ const validatePartyTypeCorrection = Effect.fn('PartyCorrectionService.validatePa reason: 'Resolving an UNRESOLVED Party Type is enrichment; use Update Party', }); } - const claims = yield* reconcilePartyIdentifierClaims( - transaction, - tenantId, - command.partyId, - nextType, - ); + const claims = yield* reconcilePartyIdentifierClaims(transaction, tenantId, command.partyId, nextType); const hasConflictingClaims = Match.value(claims).pipe( Match.tag('available', () => false), Match.orElse(() => true), @@ -237,113 +219,113 @@ const transitionedAssertionState = (replacementValue: string | undefined) => const optionalRelationshipRef = (tenantId: string, relationshipId: null | string) => relationshipId === null ? null : partyRelationshipRef(tenantId, relationshipId); -const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationship')( - function* correctRelationship( - transaction: CorrectionTransaction, - tenantId: string, - command: RelationshipCorrectionCommand, - acceptance: { readonly actionInvocationId: string; readonly principalId: string }, - ) { - let replacementRelationshipId: null | string = null; - if (command.relationshipRef.tenantId !== tenantId) { - return yield* new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'The target Party Relationship is absent or not active', - }); - } - const [targetRow] = yield* transaction - .select() - .from(partyRelationships) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, command.relationshipRef.resourceId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - const target = yield* requireActiveRelationshipTarget(targetRow, command.expectedRevision); - const correctedPartyId = target.fromPartyId; - // A durable Relationship remains correctable after either stored endpoint becomes an alias. - // Resolve for invariant reads, but never rewrite the immutable stored endpoint identity. - yield* resolvePartyAlias(transaction, tenantId, target.fromPartyId).pipe( - Effect.mapError(unavailable), - ); - yield* resolvePartyAlias(transaction, tenantId, target.toPartyId).pipe( - Effect.mapError(unavailable), - ); - const { relationshipId } = target; - const [transitioned] = yield* transaction - .update(partyRelationships) - .set({ - assertionState: transitionedRelationshipState(command), - revision: target.revision + 1, +const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationship')(function* correctRelationship( + transaction: CorrectionTransaction, + tenantId: string, + command: RelationshipCorrectionCommand, + acceptance: { + readonly actionInvocationId: string; + readonly principalId: string; + }, +) { + let replacementRelationshipId: null | string = null; + if (command.relationshipRef.tenantId !== tenantId) { + return yield* new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'The target Party Relationship is absent or not active', + }); + } + const [targetRow] = yield* transaction + .select() + .from(partyRelationships) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, command.relationshipRef.resourceId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + const target = yield* requireActiveRelationshipTarget(targetRow, command.expectedRevision); + const correctedPartyId = target.fromPartyId; + // A durable Relationship remains correctable after either stored endpoint becomes an alias. + // Resolve for invariant reads, but never rewrite the immutable stored endpoint identity. + yield* resolvePartyAlias(transaction, tenantId, target.fromPartyId).pipe(Effect.mapError(unavailable)); + yield* resolvePartyAlias(transaction, tenantId, target.toPartyId).pipe(Effect.mapError(unavailable)); + const { relationshipId } = target; + const [transitioned] = yield* transaction + .update(partyRelationships) + .set({ + assertionState: transitionedRelationshipState(command), + revision: target.revision + 1, + }) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, target.relationshipId), + eq(partyRelationships.revision, target.revision), + eq(partyRelationships.assertionState, 'ACTIVE'), + ), + ) + .returning() + .pipe(Effect.mapError(unavailable)); + if (transitioned === undefined) { + return yield* new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'The target Party Relationship changed concurrently', + }); + } + if (command.correctionMode === 'SUPERSEDE') { + const [replacement] = yield* transaction + .insert(partyRelationships) + .values({ + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + assertionState: 'ACTIVE', + fromPartyId: target.fromPartyId, + policyVersion: command.policyVersion, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + relationshipType: target.relationshipType, + revision: 1, + supersedesRelationshipId: target.relationshipId, + tenantId, + toPartyId: target.toPartyId, + validFrom: Option.match(command.replacementValidFrom, { + onNone: () => null, + onSome: instantAsDate, + }), + validTo: Option.match(command.replacementValidTo, { + onNone: () => null, + onSome: instantAsDate, + }), }) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, target.relationshipId), - eq(partyRelationships.revision, target.revision), - eq(partyRelationships.assertionState, 'ACTIVE'), - ), - ) .returning() - .pipe(Effect.mapError(unavailable)); - if (transitioned === undefined) { - return yield* new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'The target Party Relationship changed concurrently', - }); - } - if (command.correctionMode === 'SUPERSEDE') { - const [replacement] = yield* transaction - .insert(partyRelationships) - .values({ - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - assertionState: 'ACTIVE', - fromPartyId: target.fromPartyId, - policyVersion: command.policyVersion, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - relationshipType: target.relationshipType, - revision: 1, - supersedesRelationshipId: target.relationshipId, - tenantId, - toPartyId: target.toPartyId, - validFrom: Option.match(command.replacementValidFrom, { - onNone: () => null, - onSome: instantAsDate, - }), - validTo: Option.match(command.replacementValidTo, { - onNone: () => null, - onSome: instantAsDate, - }), - }) - .returning() - .pipe(Effect.mapError(relationshipMutationFailure)); - if (replacement === undefined) { - return yield* unavailable(); - } - replacementRelationshipId = replacement.relationshipId; + .pipe(Effect.mapError(relationshipMutationFailure)); + if (replacement === undefined) { + return yield* unavailable(); } + replacementRelationshipId = replacement.relationshipId; + } - return { - correctedPartyId, - relationshipId, - replacementRelationshipId, - replacementAssertionId: replacementRelationshipId, - }; - }, -); + return { + correctedPartyId, + relationshipId, + replacementRelationshipId, + replacementAssertionId: replacementRelationshipId, + }; +}); const correctOfficialIdentifier = Effect.fn('PartyCorrectionService.correctOfficialIdentifier')( function* correctOfficialIdentifier( transaction: CorrectionTransaction, tenantId: string, command: IdentityCorrectionCommand, - acceptance: { readonly actionInvocationId: string; readonly principalId: string }, + acceptance: { + readonly actionInvocationId: string; + readonly principalId: string; + }, now: Date, ) { let replacementOfficialIdentifierId: null | string = null; @@ -460,7 +442,10 @@ const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdenti transaction: CorrectionTransaction, tenantId: string, command: IdentityCorrectionCommand, - acceptance: { readonly actionInvocationId: string; readonly principalId: string }, + acceptance: { + readonly actionInvocationId: string; + readonly principalId: string; + }, now: Date, ) { let replacementAssertionId: null | string = null; @@ -498,12 +483,7 @@ const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdenti state: transitionedAssertionState(command.replacementValue), validTo: now, }) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.assertionId, target.assertionId), - ), - ) + .where(and(eq(partyFactAssertions.tenantId, tenantId), eq(partyFactAssertions.assertionId, target.assertionId))) .pipe(Effect.mapError(unavailable)); if (command.replacementValue !== undefined) { const { replacementValue } = command; @@ -553,82 +533,77 @@ const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdenti }, ); -export const correctPartyFactRecord = Effect.fn('PartyCorrectionService.correctPartyFactRecord')( - function* correctFact( - transaction: CorrectionTransaction, - tenantId: string, - command: PartyCorrectionCommand, - acceptance: { readonly actionInvocationId: string; readonly principalId: string }, - ) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const now = yield* DateTime.nowAsDate; - const correctionTarget = yield* Match.value(command).pipe( - Match.when({ factKind: 'RELATIONSHIP' }, (relationship) => - correctRelationship(transaction, tenantId, relationship, acceptance), - ), - Match.when({ factKind: 'OFFICIAL_IDENTIFIER' }, (identifier) => - correctOfficialIdentifier(transaction, tenantId, identifier, acceptance, now), - ), - Match.orElse((identity) => - correctIdentityAssertion(transaction, tenantId, identity, acceptance, now), - ), - ); - const { - correctedPartyId, - replacementAssertionId, - partyFactAssertionId, +export const correctPartyFactRecord = Effect.fn('PartyCorrectionService.correctPartyFactRecord')(function* correctFact( + transaction: CorrectionTransaction, + tenantId: string, + command: PartyCorrectionCommand, + acceptance: { + readonly actionInvocationId: string; + readonly principalId: string; + }, +) { + yield* lockTenantIdentityWrites(transaction, tenantId); + const now = yield* DateTime.nowAsDate; + const correctionTarget = yield* Match.value(command).pipe( + Match.when({ factKind: 'RELATIONSHIP' }, (relationship) => + correctRelationship(transaction, tenantId, relationship, acceptance), + ), + Match.when({ factKind: 'OFFICIAL_IDENTIFIER' }, (identifier) => + correctOfficialIdentifier(transaction, tenantId, identifier, acceptance, now), + ), + Match.orElse((identity) => correctIdentityAssertion(transaction, tenantId, identity, acceptance, now)), + ); + const { + correctedPartyId, + replacementAssertionId, + partyFactAssertionId, + officialIdentifierId, + replacementOfficialIdentifierId, + relationshipId, + replacementRelationshipId, + } = { + partyFactAssertionId: null, + officialIdentifierId: null, + replacementOfficialIdentifierId: null, + relationshipId: null, + replacementRelationshipId: null, + ...correctionTarget, + }; + const [correction] = yield* transaction + .insert(partyCorrections) + .values({ + actingPrincipalId: acceptance.principalId, + actionInvocationId: acceptance.actionInvocationId, + evidenceRefs: command.evidenceRefs, officialIdentifierId, - replacementOfficialIdentifierId, + partyFactAssertionId, + partyId: correctedPartyId, + policyVersion: command.policyVersion, + reason: encodeStoredCorrectionReason(command), relationshipId, + replacementOfficialIdentifierId, + replacementPartyFactAssertionId: + command.factKind === 'PARTY_TYPE' || command.factKind === 'DISPLAY_NAME' ? replacementAssertionId : null, replacementRelationshipId, - } = { - partyFactAssertionId: null, - officialIdentifierId: null, - replacementOfficialIdentifierId: null, - relationshipId: null, - replacementRelationshipId: null, - ...correctionTarget, - }; - const [correction] = yield* transaction - .insert(partyCorrections) - .values({ - actingPrincipalId: acceptance.principalId, - actionInvocationId: acceptance.actionInvocationId, - evidenceRefs: command.evidenceRefs, - officialIdentifierId, - partyFactAssertionId, - partyId: correctedPartyId, - policyVersion: command.policyVersion, - reason: encodeStoredCorrectionReason(command), - relationshipId, - replacementOfficialIdentifierId, - replacementPartyFactAssertionId: - command.factKind === 'PARTY_TYPE' || command.factKind === 'DISPLAY_NAME' - ? replacementAssertionId - : null, - replacementRelationshipId, - tenantId, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (correction === undefined) { - return yield* unavailable(); - } - return yield* Schema.decodeUnknownEffect(PartyCorrectionResultSchema)({ - correctionRef: makePartyCorrectionRef(tenantId, correction.correctionId), - factKind: command.factKind, - followUp: classifyCorrectionRoute(command.factKind), - partyRef: makePartyRef(tenantId, correctedPartyId), - relationshipRef: optionalRelationshipRef(tenantId, relationshipId), - replacementAssertionId, - replacementRelationshipRef: optionalRelationshipRef(tenantId, replacementRelationshipId), - retractedAssertionId: - command.factKind === 'RELATIONSHIP' - ? command.relationshipRef.resourceId - : command.targetAssertionId, - }).pipe(Effect.mapError(unavailable)); - }, -); + tenantId, + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (correction === undefined) { + return yield* unavailable(); + } + return yield* Schema.decodeEffect(PartyCorrectionResultSchema)({ + correctionRef: makePartyCorrectionRef(tenantId, correction.correctionId), + factKind: command.factKind, + followUp: classifyCorrectionRoute(command.factKind), + partyRef: makePartyRef(tenantId, correctedPartyId), + relationshipRef: optionalRelationshipRef(tenantId, relationshipId), + replacementAssertionId, + replacementRelationshipRef: optionalRelationshipRef(tenantId, replacementRelationshipId), + retractedAssertionId: + command.factKind === 'RELATIONSHIP' ? command.relationshipRef.resourceId : command.targetAssertionId, + }).pipe(Effect.mapError(unavailable)); +}); const relationshipEndEvidence = (row: typeof partyRelationships.$inferSelect) => row.validTo !== null && @@ -637,7 +612,10 @@ const relationshipEndEvidence = (row: typeof partyRelationships.$inferSelect) => row.endedRecordedAt !== null ? { effectiveAt: row.validTo.toISOString(), - provenance: { method: row.endProvenanceMethod, source: row.endProvenanceSource }, + provenance: { + method: row.endProvenanceMethod, + source: row.endProvenanceSource, + }, reason: row.endReason, recordedAt: row.endedRecordedAt.toISOString(), } @@ -654,12 +632,7 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection const [row] = yield* transaction .select() .from(partyRelationships) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, assertionId), - ), - ) + .where(and(eq(partyRelationships.tenantId, tenantId), eq(partyRelationships.relationshipId, assertionId))) .limit(1) .pipe(Effect.mapError(unavailable)); if (row === undefined) { @@ -671,7 +644,10 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection endEvidence: relationshipEndEvidence(row), factKind, fromPartyRef: makePartyRef(tenantId, row.fromPartyId), - provenance: { method: row.provenanceMethod, source: row.provenanceSource }, + provenance: { + method: row.provenanceMethod, + source: row.provenanceSource, + }, recordedAt: row.recordedAt.toISOString(), relationshipType: row.relationshipType, toPartyRef: makePartyRef(tenantId, row.toPartyId), @@ -699,7 +675,10 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection factKind, identifierType: row.identifierTypeKey, namespace: row.namespace, - provenance: { method: row.provenanceMethod, source: row.provenanceSource }, + provenance: { + method: row.provenanceMethod, + source: row.provenanceSource, + }, recordedAt: row.recordedAt.toISOString(), state: row.state, validFrom: row.validFrom.toISOString(), @@ -711,12 +690,7 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection const [row] = yield* transaction .select() .from(partyFactAssertions) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.assertionId, assertionId), - ), - ) + .where(and(eq(partyFactAssertions.tenantId, tenantId), eq(partyFactAssertions.assertionId, assertionId))) .limit(1) .pipe(Effect.mapError(unavailable)); if (row === undefined) { @@ -725,7 +699,10 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection return yield* Schema.decodeUnknownEffect(PartyCorrectionAssertionValueSchema)({ assertionId: row.assertionId, factKind: row.factKind, - provenance: { method: row.provenanceMethod, source: row.provenanceSource }, + provenance: { + method: row.provenanceMethod, + source: row.provenanceSource, + }, recordedAt: row.recordedAt.toISOString(), state: row.state, validFrom: row.validFrom.toISOString(), @@ -747,99 +724,74 @@ const correctionAssertionTargets = Effect.fn('PartyCorrectionService.correctionA if (row.relationshipId !== null) { factKind = 'RELATIONSHIP'; } - const targetAssertionId = - row.partyFactAssertionId ?? row.officialIdentifierId ?? row.relationshipId; + const targetAssertionId = row.partyFactAssertionId ?? row.officialIdentifierId ?? row.relationshipId; if (targetAssertionId === null) { return yield* unavailable(); } const replacementAssertionId = - row.replacementPartyFactAssertionId ?? - row.replacementOfficialIdentifierId ?? - row.replacementRelationshipId; + row.replacementPartyFactAssertionId ?? row.replacementOfficialIdentifierId ?? row.replacementRelationshipId; return { factKind, targetAssertionId, replacementAssertionId }; }, ); -export const findPartyCorrection = Effect.fn('PartyCorrectionService.findPartyCorrection')( - function* findCorrection( - transaction: Pick, - tenantId: string, - correctionId: string, - ) { - const [row] = yield* transaction - .select() - .from(partyCorrections) - .where( - and( - eq(partyCorrections.tenantId, tenantId), - eq(partyCorrections.correctionId, correctionId), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (row === undefined) { - return { _tag: 'not_found' } as const; - } - if (row.policyVersion !== PartyCorrectionPolicyVersion) { - return yield* unavailable(); - } - const storedReason = yield* decodeStoredCorrectionReason(row.reason); - const { factKind, targetAssertionId, replacementAssertionId } = - yield* correctionAssertionTargets(row, storedReason); - const originalAssertion = yield* loadCorrectionAssertion( - transaction, - tenantId, - factKind, - targetAssertionId, - ); - const resultingAssertion = - replacementAssertionId === null - ? null - : yield* loadCorrectionAssertion(transaction, tenantId, factKind, replacementAssertionId); - const actingPrincipalId = Result.getOrThrow( - Schema.decodeUnknownResult(ActingPrincipalIdSchema)(row.actingPrincipalId), - ); - const actionInvocationId = Result.getOrThrow( - Schema.decodeUnknownResult(ActionInvocationIdSchema)(row.actionInvocationId), - ); - const approvingPrincipalId = Option.map(Option.fromNullishOr(row.approvingPrincipalId), (id) => - Result.getOrThrow(Schema.decodeUnknownResult(ApprovingPrincipalIdSchema)(id)), - ); - const replacementAssertionRef = Option.map(Option.fromNullishOr(replacementAssertionId), (id) => - Result.getOrThrow(Schema.decodeUnknownResult(ReplacementAssertionIdSchema)(id)), - ); - const targetAssertionRef = Result.getOrThrow( - Schema.decodeUnknownResult(TargetAssertionIdSchema)(targetAssertionId), - ); - return { - _tag: 'found', - value: { - actingPrincipalId, - actionInvocationId, - approvingPrincipalId, - correctionRef: makePartyCorrectionRef(tenantId, row.correctionId), - evidenceRefs: row.evidenceRefs, - evidenceSource: storedReason.evidenceSource, - factKind: originalAssertion.factKind, - governance: PartyCorrectionGovernance, - originalAssertion, - partyRef: makePartyRef(tenantId, row.partyId), - policyVersion: PartyCorrectionPolicyVersion, - provenance: storedReason.provenance, - reasonCode: storedReason.reasonCode, - reasonDetail: storedReason.reasonDetail, - recordedAt: DateTime.makeUnsafe(row.recordedAt), - relationshipRef: Option.map(Option.fromNullishOr(row.relationshipId), (id) => - partyRelationshipRef(tenantId, id), - ), - replacementAssertionId: replacementAssertionRef, - replacementRelationshipRef: Option.map( - Option.fromNullishOr(row.replacementRelationshipId), - (id) => partyRelationshipRef(tenantId, id), - ), - resultingAssertion: Option.fromNullishOr(resultingAssertion), - targetAssertionId: targetAssertionRef, - }, - } as const; - }, -); +export const findPartyCorrection = Effect.fn('PartyCorrectionService.findPartyCorrection')(function* findCorrection( + transaction: Pick, + tenantId: string, + correctionId: string, +) { + const [row] = yield* transaction + .select() + .from(partyCorrections) + .where(and(eq(partyCorrections.tenantId, tenantId), eq(partyCorrections.correctionId, correctionId))) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (row === undefined) { + return { _tag: 'not_found' } as const; + } + if (row.policyVersion !== PartyCorrectionPolicyVersion) { + return yield* unavailable(); + } + const storedReason = yield* decodeStoredCorrectionReason(row.reason); + const { factKind, targetAssertionId, replacementAssertionId } = yield* correctionAssertionTargets(row, storedReason); + const originalAssertion = yield* loadCorrectionAssertion(transaction, tenantId, factKind, targetAssertionId); + const resultingAssertion = + replacementAssertionId === null + ? null + : yield* loadCorrectionAssertion(transaction, tenantId, factKind, replacementAssertionId); + const actingPrincipalId = Result.getOrThrow(Schema.decodeResult(ActingPrincipalIdSchema)(row.actingPrincipalId)); + const actionInvocationId = Result.getOrThrow(Schema.decodeResult(ActionInvocationIdSchema)(row.actionInvocationId)); + const approvingPrincipalId = Option.map(Option.fromNullishOr(row.approvingPrincipalId), (id) => + Result.getOrThrow(Schema.decodeResult(ApprovingPrincipalIdSchema)(id)), + ); + const replacementAssertionRef = Option.map(Option.fromNullishOr(replacementAssertionId), (id) => + Result.getOrThrow(Schema.decodeResult(ReplacementAssertionIdSchema)(id)), + ); + const targetAssertionRef = Result.getOrThrow(Schema.decodeResult(TargetAssertionIdSchema)(targetAssertionId)); + return { + _tag: 'found', + value: { + actingPrincipalId, + actionInvocationId, + approvingPrincipalId, + correctionRef: makePartyCorrectionRef(tenantId, row.correctionId), + evidenceRefs: row.evidenceRefs, + evidenceSource: storedReason.evidenceSource, + factKind: originalAssertion.factKind, + governance: PartyCorrectionGovernance, + originalAssertion, + partyRef: makePartyRef(tenantId, row.partyId), + policyVersion: PartyCorrectionPolicyVersion, + provenance: storedReason.provenance, + reasonCode: storedReason.reasonCode, + reasonDetail: storedReason.reasonDetail, + recordedAt: DateTime.makeUnsafe(row.recordedAt), + relationshipRef: Option.map(Option.fromNullishOr(row.relationshipId), (id) => partyRelationshipRef(tenantId, id)), + replacementAssertionId: replacementAssertionRef, + replacementRelationshipRef: Option.map(Option.fromNullishOr(row.replacementRelationshipId), (id) => + partyRelationshipRef(tenantId, id), + ), + resultingAssertion: Option.fromNullishOr(resultingAssertion), + targetAssertionId: targetAssertionRef, + }, + } as const; +}); diff --git a/app/verticals/party-registry/src/services/party-detail-persistence.service.ts b/app/verticals/party-registry/src/services/party-detail-persistence.service.ts index 2b3d8250e..026ec6b0e 100644 --- a/app/verticals/party-registry/src/services/party-detail-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-detail-persistence.service.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith Action Service v1 import { and, asc, eq, sql } from 'drizzle-orm'; import { Effect, Option, Schema } from 'effect'; + import type { PartyFactAssertion } from '../../shared/apis/party-detail.ts'; import { PartyFactAssertionSchema } from '../../shared/apis/party-detail.ts'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; @@ -24,65 +25,64 @@ const unavailable = (cause?: unknown) => { return error; }; -export const findPartyDetailAssertions = Effect.fn( - 'PartyDetailPersistenceService.findPartyDetailAssertions', -)(function* readSafeFactAssertions( - transaction: Pick, - tenantId: string, - partyId: string, - includeFactHistory: boolean, -) { - // Ordinary reads omit provenance. Reviewer-authorized history may include bounded provider - // evidence; actor and sensitive Correction metadata remain private. SQL NULL keeps the same - // projection shape without selecting protected evidence for ordinary reads. - const rows = yield* transaction - .select({ - assertionId: partyFactAssertions.assertionId, - externalEvidence: includeFactHistory ? partyFactAssertions.externalEvidence : sql`null`, - factKind: partyFactAssertions.factKind, - isCurrent: partyFactAssertions.isCurrent, - recordedAt: partyFactAssertions.recordedAt, - retractsAssertionId: partyFactAssertions.retractsAssertionId, - state: partyFactAssertions.state, - supersedesAssertionId: partyFactAssertions.supersedesAssertionId, - validFrom: partyFactAssertions.validFrom, - validTo: partyFactAssertions.validTo, - value: partyFactAssertions.normalizedValue, - }) - .from(partyFactAssertions) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.partyId, partyId), - includeFactHistory - ? undefined - : and(eq(partyFactAssertions.state, 'ACTIVE'), eq(partyFactAssertions.isCurrent, true)), - ), - ) - .orderBy(asc(partyFactAssertions.recordedAt), asc(partyFactAssertions.assertionId)); - const assertions = yield* Schema.decodeUnknownEffect(Schema.Array(PartyFactAssertionSchema))( - rows.map(({ externalEvidence, ...row }) => { - const assertion = { - ...row, - partyRef: PartyRefSchema.make({ - moduleId: 'party.registry', - resourceId: partyId, - resourceType: 'party.registry.party', - tenantId, - }), - recordedAt: row.recordedAt.toISOString(), - validFrom: row.validFrom.toISOString(), - validTo: row.validTo?.toISOString() ?? null, - }; - return externalEvidence === null || externalEvidence === undefined - ? assertion - : { ...assertion, externalEvidence }; - }), - ); - return { - currentFactAssertions: assertions.filter( - (assertion) => assertion.state === 'ACTIVE' && assertion.isCurrent, - ), - factHistory: includeFactHistory ? Option.some(assertions) : Option.none(), - } satisfies PartyDetailAssertions; -}, Effect.mapError(unavailable)); +export const findPartyDetailAssertions = Effect.fn('PartyDetailPersistenceService.findPartyDetailAssertions')( + function* readSafeFactAssertions( + transaction: Pick, + tenantId: string, + partyId: string, + includeFactHistory: boolean, + ) { + // Ordinary reads omit provenance. Reviewer-authorized history may include bounded provider + // evidence; actor and sensitive Correction metadata remain private. SQL NULL keeps the same + // projection shape without selecting protected evidence for ordinary reads. + const rows = yield* transaction + .select({ + assertionId: partyFactAssertions.assertionId, + externalEvidence: includeFactHistory ? partyFactAssertions.externalEvidence : sql`null`, + factKind: partyFactAssertions.factKind, + isCurrent: partyFactAssertions.isCurrent, + recordedAt: partyFactAssertions.recordedAt, + retractsAssertionId: partyFactAssertions.retractsAssertionId, + state: partyFactAssertions.state, + supersedesAssertionId: partyFactAssertions.supersedesAssertionId, + validFrom: partyFactAssertions.validFrom, + validTo: partyFactAssertions.validTo, + value: partyFactAssertions.normalizedValue, + }) + .from(partyFactAssertions) + .where( + and( + eq(partyFactAssertions.tenantId, tenantId), + eq(partyFactAssertions.partyId, partyId), + includeFactHistory + ? undefined + : and(eq(partyFactAssertions.state, 'ACTIVE'), eq(partyFactAssertions.isCurrent, true)), + ), + ) + .orderBy(asc(partyFactAssertions.recordedAt), asc(partyFactAssertions.assertionId)); + const assertions = yield* Schema.decodeUnknownEffect(Schema.Array(PartyFactAssertionSchema))( + rows.map(({ externalEvidence, ...row }) => { + const assertion = { + ...row, + partyRef: PartyRefSchema.make({ + moduleId: 'party.registry', + resourceId: partyId, + resourceType: 'party.registry.party', + tenantId, + }), + recordedAt: row.recordedAt.toISOString(), + validFrom: row.validFrom.toISOString(), + validTo: row.validTo?.toISOString() ?? null, + }; + return externalEvidence === null || externalEvidence === undefined + ? assertion + : { ...assertion, externalEvidence }; + }), + ); + return { + currentFactAssertions: assertions.filter((assertion) => assertion.state === 'ACTIVE' && assertion.isCurrent), + factHistory: includeFactHistory ? Option.some(assertions) : Option.none(), + } satisfies PartyDetailAssertions; + }, + Effect.mapError(unavailable), +); diff --git a/app/verticals/party-registry/src/services/party-identifier-claim.service.ts b/app/verticals/party-registry/src/services/party-identifier-claim.service.ts index af5831f11..191728da9 100644 --- a/app/verticals/party-registry/src/services/party-identifier-claim.service.ts +++ b/app/verticals/party-registry/src/services/party-identifier-claim.service.ts @@ -1,6 +1,7 @@ // @generated by OntOS Codesmith Action Service v1 import { and, eq, sql } from 'drizzle-orm'; import { Effect, Option, Result, Schema } from 'effect'; + import type { NormalizedOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; import { partyIdentifierClaims } from '../db/schema.ts'; @@ -33,14 +34,9 @@ const encodeTenantClaimLockKey = Schema.encodeResult(TenantClaimLockKeyCodec); * The capability remains owner-local and exposes no general SQL executor. */ export const tenantIdentityWriteLockKey = (tenantId: string): string => - Result.getOrThrow( - encodeTenantIdentityWriteLockKey(['party.registry.identity-write.v1', tenantId]), - ); + Result.getOrThrow(encodeTenantIdentityWriteLockKey(['party.registry.identity-write.v1', tenantId])); -export const lockTenantIdentityWrites = ( - transaction: Pick, - tenantId: string, -) => +export const lockTenantIdentityWrites = (transaction: Pick, tenantId: string) => transaction .select({ lock: sql`pg_advisory_xact_lock(hashtextextended(${tenantIdentityWriteLockKey(tenantId)}, 0))`, @@ -48,20 +44,12 @@ export const lockTenantIdentityWrites = ( .from(sql`(values (1)) as party_identity_lock_anchor(value)`) .pipe(Effect.mapError(unavailable), Effect.asVoid); -export const tenantClaimLockKeys = ( - tenantId: string, - claims: readonly NormalizedOfficialIdentifier[], -) => +export const tenantClaimLockKeys = (tenantId: string, claims: readonly NormalizedOfficialIdentifier[]) => [ ...new Set( claims.map((claim) => Result.getOrThrow( - encodeTenantClaimLockKey([ - tenantId, - claim.identifierType, - claim.namespace, - claim.normalizedValue, - ]), + encodeTenantClaimLockKey([tenantId, claim.identifierType, claim.namespace, claim.normalizedValue]), ), ), ), diff --git a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts index e21d0e275..04efa1dec 100644 --- a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts @@ -1,18 +1,15 @@ +import { createHash } from 'node:crypto'; + // @generated by OntOS Codesmith Action Service v1 import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, or } from 'drizzle-orm'; import { DateTime, Effect, Option, Result, Schema } from 'effect'; -import { createHash } from 'node:crypto'; + import type { UnarchivePartyBlocked } from '../../shared/actions/unarchive-party.ts'; import type { AresAppliedEvidence } from '../../shared/domain/ares-application.ts'; import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; import type { NormalizedOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; import { qualifiesForExclusiveClaim } from '../../shared/domain/identifier-contracts.ts'; -import type { - Party, - PartyCandidate, - PartySubjectEvidence, - PartyType, -} from '../../shared/domain/identity-contracts.ts'; +import type { Party, PartyCandidate, PartySubjectEvidence, PartyType } from '../../shared/domain/identity-contracts.ts'; import { PartyPersistenceUnavailable, PartySchema, @@ -21,11 +18,7 @@ import { import { DuplicateCandidateCaseRefSchema } from '../../shared/resources/duplicate-candidate-case.ts'; import { PartyMatchDecisionRefSchema } from '../../shared/resources/party-match-decision.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; -import type { - PartyCandidateSnapshot, - PartyEvidenceExplanation, - PartyRecord, -} from '../db/schema.ts'; +import type { PartyCandidateSnapshot, PartyEvidenceExplanation, PartyRecord } from '../db/schema.ts'; import { duplicateCandidateCaseParties, duplicateCandidateCases, @@ -38,15 +31,14 @@ import { import type { PartyTransaction } from '../db/types.ts'; import { requireCanonicalPartyWriteTarget } from '../merge/party-alias-resolution.service.ts'; import { requirePartySubjectEvidence } from '../policies/create-party-without-strong-identifier.policy.ts'; -import { - lockAndResolveClaims, - lockTenantIdentityWrites, -} from './party-identifier-claim.service.ts'; +import { lockAndResolveClaims, lockTenantIdentityWrites } from './party-identifier-claim.service.ts'; const PartyFoundSchema = Schema.TaggedStruct('found', { value: PartySchema }); const PartyNotFoundSchema = Schema.TaggedStruct('not_found', {}); const PartyLookupSchema = Schema.Union([PartyFoundSchema, PartyNotFoundSchema]); -const PartyConflictSchema = Schema.TaggedStruct('conflict', { value: PartySchema }); +const PartyConflictSchema = Schema.TaggedStruct('conflict', { + value: PartySchema, +}); const PartyLifecycleSchema = Schema.Union([PartyLookupSchema, PartyConflictSchema]); const PartyUnarchiveIdentityConflictSchema = Schema.TaggedStruct('identity_conflict', { conflictingPartyId: PartyRefSchema.fields.resourceId, @@ -94,16 +86,12 @@ export const findOpenDuplicateCandidateCase = ( ) .limit(1); -const instantAsDate = (instant: string | DateTime.Utc): Date => - DateTime.toDateUtc(DateTime.makeUnsafe(instant)); -const ClaimKeyJsonCodec = Schema.fromJsonString( - Schema.Tuple([Schema.String, Schema.String, Schema.String]), -); +const instantAsDate = (instant: string | DateTime.Utc): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); +const ClaimKeyJsonCodec = Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.String])); const JsonCodec = Schema.fromJsonString(Schema.Any); const encodeClaimKey = (claimKey: readonly [string, string, string]): string => Result.getOrThrow(Schema.encodeResult(ClaimKeyJsonCodec)(claimKey)); -const encodeJson = (value: Value): string => - Result.getOrThrow(Schema.encodeResult(JsonCodec)(value)); +const encodeJson = (value: Value): string => Result.getOrThrow(Schema.encodeResult(JsonCodec)(value)); export const endedPartyFactTransition = { isCurrent: false, @@ -156,11 +144,7 @@ export const partyDto = (row: PartyRecord): Party => ({ updatedAt: DateTime.makeUnsafe(row.updatedAt), }); -export const findPartyRecord = ( - transaction: Pick, - tenantId: string, - partyId: string, -) => +export const findPartyRecord = (transaction: Pick, tenantId: string, partyId: string) => transaction .select() .from(parties) @@ -173,76 +157,74 @@ export const findPartyRecord = ( ), ); -export const insertPartyRecord = Effect.fn('PartyIdentityPersistenceService.insertPartyRecord')( - function* insertParty( - transaction: Pick, - tenantId: string, - candidate: PartyCandidate, - acceptance: { - readonly actionInvocationId: string; - readonly principalId: string; - readonly policyVersion: string; - }, - ) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const evaluation = yield* requirePartySubjectEvidence(candidate); - const externalEvidence = - candidate.provenance.externalEvidence === undefined - ? null - : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)( - candidate.provenance.externalEvidence, - ).pipe(Effect.mapError(unavailable)); - const [party] = yield* transaction - .insert(parties) - .values({ - currentDisplayName: candidate.displayName ?? null, - currentType: candidate.partyType, - tenantId, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (party === undefined) { - return yield* unavailable(); - } - yield* transaction - .insert(partyFactAssertions) - .values([ - { - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - evidenceEvaluation: evaluation, - externalEvidence, - factKind: 'PARTY_TYPE', - normalizedValue: candidate.partyType, - partyId: party.partyId, - policyVersion: acceptance.policyVersion, - provenanceMethod: candidate.provenance.method, - provenanceSource: candidate.provenance.source, - tenantId, - validFrom: instantAsDate(candidate.validFrom), - }, - ...(candidate.displayName === undefined - ? [] - : [ - { - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - externalEvidence, - factKind: 'DISPLAY_NAME', - normalizedValue: candidate.displayName, - partyId: party.partyId, - policyVersion: acceptance.policyVersion, - provenanceMethod: candidate.provenance.method, - provenanceSource: candidate.provenance.source, - tenantId, - validFrom: instantAsDate(candidate.validFrom), - }, - ]), - ]) - .pipe(Effect.mapError(unavailable)); - return partyDto(party); +export const insertPartyRecord = Effect.fn('PartyIdentityPersistenceService.insertPartyRecord')(function* insertParty( + transaction: Pick, + tenantId: string, + candidate: PartyCandidate, + acceptance: { + readonly actionInvocationId: string; + readonly principalId: string; + readonly policyVersion: string; }, -); +) { + yield* lockTenantIdentityWrites(transaction, tenantId); + const evaluation = yield* requirePartySubjectEvidence(candidate); + const externalEvidence = + candidate.provenance.externalEvidence === undefined + ? null + : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(candidate.provenance.externalEvidence).pipe( + Effect.mapError(unavailable), + ); + const [party] = yield* transaction + .insert(parties) + .values({ + currentDisplayName: candidate.displayName ?? null, + currentType: candidate.partyType, + tenantId, + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (party === undefined) { + return yield* unavailable(); + } + yield* transaction + .insert(partyFactAssertions) + .values([ + { + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + evidenceEvaluation: evaluation, + externalEvidence, + factKind: 'PARTY_TYPE', + normalizedValue: candidate.partyType, + partyId: party.partyId, + policyVersion: acceptance.policyVersion, + provenanceMethod: candidate.provenance.method, + provenanceSource: candidate.provenance.source, + tenantId, + validFrom: instantAsDate(candidate.validFrom), + }, + ...(candidate.displayName === undefined + ? [] + : [ + { + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + externalEvidence, + factKind: 'DISPLAY_NAME', + normalizedValue: candidate.displayName, + partyId: party.partyId, + policyVersion: acceptance.policyVersion, + provenanceMethod: candidate.provenance.method, + provenanceSource: candidate.provenance.source, + tenantId, + validFrom: instantAsDate(candidate.validFrom), + }, + ]), + ]) + .pipe(Effect.mapError(unavailable)); + return partyDto(party); +}); /** Reconciles exclusive claims before a Party type or active-state transition commits. */ export const reconcilePartyIdentifierClaims = Effect.fn( @@ -281,15 +263,11 @@ export const reconcilePartyIdentifierClaims = Effect.fn( // SAFETY: The owner-local verification CHECK admits only the declared verification states. verification: row.verificationState as NormalizedOfficialIdentifier['verification'], })) - .filter((identifier) => - qualifiesForExclusiveClaim(identifier, nextPartyType, MATCH_RULE_VERSION), - ); + .filter((identifier) => qualifiesForExclusiveClaim(identifier, nextPartyType, MATCH_RULE_VERSION)); const uniqueIdentifiers = [ ...new Map( eligibleIdentifiers.map((identifier) => [ - [identifier.identifierType, identifier.namespace, identifier.normalizedValue].join( - '\u0000', - ), + [identifier.identifierType, identifier.namespace, identifier.normalizedValue].join('\u0000'), identifier, ]), ).values(), @@ -303,9 +281,7 @@ export const reconcilePartyIdentifierClaims = Effect.fn( const ownedClaims = yield* transaction .select() .from(partyIdentifierClaims) - .where( - and(eq(partyIdentifierClaims.tenantId, tenantId), eq(partyIdentifierClaims.partyId, partyId)), - ) + .where(and(eq(partyIdentifierClaims.tenantId, tenantId), eq(partyIdentifierClaims.partyId, partyId))) .for('update') .pipe(Effect.mapError(unavailable)); const eligibleKeys = new Set( @@ -315,10 +291,7 @@ export const reconcilePartyIdentifierClaims = Effect.fn( ); const releasedClaimIds = ownedClaims .filter( - (claim) => - !eligibleKeys.has( - encodeClaimKey([claim.identifierTypeKey, claim.namespace, claim.normalizedValue]), - ), + (claim) => !eligibleKeys.has(encodeClaimKey([claim.identifierTypeKey, claim.namespace, claim.normalizedValue])), ) .map((claim) => claim.identifierClaimId); if (releasedClaimIds.length > 0) { @@ -335,10 +308,7 @@ export const reconcilePartyIdentifierClaims = Effect.fn( } const identifiersByClaimKey = new Map( - identifierRows.map((row) => [ - [row.identifierTypeKey, row.namespace, row.normalizedValue].join('\u0000'), - row, - ]), + identifierRows.map((row) => [[row.identifierTypeKey, row.namespace, row.normalizedValue].join('\u0000'), row]), ); const unclaimedIdentifiers = resolvedClaims.flatMap(({ claim, partyId: ownerPartyId }) => { if (ownerPartyId !== undefined) { @@ -365,7 +335,10 @@ export const reconcilePartyIdentifierClaims = Effect.fn( .pipe(Effect.mapError(unavailable)); } - return { _tag: 'available', eligibleClaimCount: uniqueIdentifiers.length } as const; + return { + _tag: 'available', + eligibleClaimCount: uniqueIdentifiers.length, + } as const; }); interface UpdatePartyIdentityInput { @@ -392,11 +365,7 @@ const evaluateTypeEnrichment = (current: PartyRecord, input: UpdatePartyIdentity }); }; -const identityUpdateConflicts = ( - current: PartyRecord, - input: UpdatePartyIdentityInput, - now: Date, -): boolean => +const identityUpdateConflicts = (current: PartyRecord, input: UpdatePartyIdentityInput, now: Date): boolean => current.revision !== input.expectedRevision || current.archivedAt !== null || instantAsDate(input.validFrom) > now || @@ -406,22 +375,15 @@ const identityUpdateConflicts = ( const changedIdentityFactKinds = (current: PartyRecord, input: UpdatePartyIdentityInput) => [ ...(input.displayName === undefined ? [] : ['DISPLAY_NAME' as const]), - ...(input.partyType === undefined || input.partyType === current.currentType - ? [] - : ['PARTY_TYPE' as const]), + ...(input.partyType === undefined || input.partyType === current.currentType ? [] : ['PARTY_TYPE' as const]), ]; const invalidIdentityFactInterval = ( assertions: readonly Pick[], requestedValidFrom: string | DateTime.Utc, -): boolean => - assertions.some((assertion) => instantAsDate(requestedValidFrom) < assertion.validFrom); +): boolean => assertions.some((assertion) => instantAsDate(requestedValidFrom) < assertion.validFrom); -const identityRecordChanges = ( - current: PartyRecord, - input: UpdatePartyIdentityInput, - now: Date, -) => { +const identityRecordChanges = (current: PartyRecord, input: UpdatePartyIdentityInput, now: Date) => { const changes: Partial = { revision: current.revision + 1, updatedAt: now, @@ -477,256 +439,239 @@ const identityUpdateAssertions = ( return assertions; }; -export const updatePartyIdentityRecord = Effect.fn( - 'PartyIdentityPersistenceService.updatePartyIdentityRecord', -)(function* updateParty( - transaction: Pick, - tenantId: string, - input: UpdatePartyIdentityInput, -) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const [current] = yield* transaction - .select() - .from(parties) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, input.partyId))) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if (current === undefined) { - return { _tag: 'not_found' } as const; - } - yield* requireCanonicalPartyWriteTarget(transaction, tenantId, input.partyId); - const now = yield* DateTime.nowAsDate; - if (identityUpdateConflicts(current, input, now)) { - return { _tag: 'conflict', value: partyDto(current) } as const; - } - const [evaluation, currentAssertions] = yield* Effect.all( - [ - evaluateTypeEnrichment(current, input), - transaction - .select({ validFrom: partyFactAssertions.validFrom }) - .from(partyFactAssertions) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.partyId, input.partyId), - inArray(partyFactAssertions.factKind, changedIdentityFactKinds(current, input)), - eq(partyFactAssertions.state, 'ACTIVE'), - eq(partyFactAssertions.isCurrent, true), - ), - ) - .for('update') - .pipe(Effect.mapError(unavailable)), - ], - { concurrency: 1 }, - ); - if (invalidIdentityFactInterval(currentAssertions, input.validFrom)) { - return { _tag: 'conflict', value: partyDto(current) } as const; - } - if (input.partyType !== undefined && input.partyType !== current.currentType) { - const claims = yield* reconcilePartyIdentifierClaims( - transaction, - tenantId, - input.partyId, - input.partyType, +export const updatePartyIdentityRecord = Effect.fn('PartyIdentityPersistenceService.updatePartyIdentityRecord')( + function* updateParty( + transaction: Pick, + tenantId: string, + input: UpdatePartyIdentityInput, + ) { + yield* lockTenantIdentityWrites(transaction, tenantId); + const [current] = yield* transaction + .select() + .from(parties) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, input.partyId))) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if (current === undefined) { + return { _tag: 'not_found' } as const; + } + yield* requireCanonicalPartyWriteTarget(transaction, tenantId, input.partyId); + const now = yield* DateTime.nowAsDate; + if (identityUpdateConflicts(current, input, now)) { + return { _tag: 'conflict', value: partyDto(current) } as const; + } + const [evaluation, currentAssertions] = yield* Effect.all( + [ + evaluateTypeEnrichment(current, input), + transaction + .select({ validFrom: partyFactAssertions.validFrom }) + .from(partyFactAssertions) + .where( + and( + eq(partyFactAssertions.tenantId, tenantId), + eq(partyFactAssertions.partyId, input.partyId), + inArray(partyFactAssertions.factKind, changedIdentityFactKinds(current, input)), + eq(partyFactAssertions.state, 'ACTIVE'), + eq(partyFactAssertions.isCurrent, true), + ), + ) + .for('update') + .pipe(Effect.mapError(unavailable)), + ], + { concurrency: 1 }, ); - if (!Schema.is(ClaimAvailableSchema)(claims)) { + if (invalidIdentityFactInterval(currentAssertions, input.validFrom)) { return { _tag: 'conflict', value: partyDto(current) } as const; } - } - const externalEvidence = - input.externalEvidence === undefined - ? null - : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(input.externalEvidence).pipe( - Effect.mapError(unavailable), - ); - const changes = identityRecordChanges(current, input, now); - const [updated] = yield* transaction - .update(parties) - .set(changes) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, input.partyId))) - .returning() - .pipe(Effect.mapError(unavailable)); - if (updated === undefined) { - return yield* unavailable(); - } - const assertions = identityUpdateAssertions( - current, - input, - tenantId, - externalEvidence, - evaluation, - ); - yield* Effect.forEach( - changedIdentityFactKinds(current, input), - (factKind) => - transaction - .update(partyFactAssertions) - .set({ ...endedPartyFactTransition, validTo: instantAsDate(input.validFrom) }) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.partyId, input.partyId), - eq(partyFactAssertions.factKind, factKind), - eq(partyFactAssertions.state, 'ACTIVE'), - eq(partyFactAssertions.isCurrent, true), - ), - ) - .pipe(Effect.mapError(unavailable)), - { concurrency: 1, discard: true }, - ); - if (assertions.length > 0) { - yield* transaction - .insert(partyFactAssertions) - .values(assertions) + if (input.partyType !== undefined && input.partyType !== current.currentType) { + const claims = yield* reconcilePartyIdentifierClaims(transaction, tenantId, input.partyId, input.partyType); + if (!Schema.is(ClaimAvailableSchema)(claims)) { + return { _tag: 'conflict', value: partyDto(current) } as const; + } + } + const externalEvidence = + input.externalEvidence === undefined + ? null + : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(input.externalEvidence).pipe( + Effect.mapError(unavailable), + ); + const changes = identityRecordChanges(current, input, now); + const [updated] = yield* transaction + .update(parties) + .set(changes) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, input.partyId))) + .returning() .pipe(Effect.mapError(unavailable)); - } - return { _tag: 'found', value: partyDto(updated) } as const; -}); + if (updated === undefined) { + return yield* unavailable(); + } + const assertions = identityUpdateAssertions(current, input, tenantId, externalEvidence, evaluation); + yield* Effect.forEach( + changedIdentityFactKinds(current, input), + (factKind) => + transaction + .update(partyFactAssertions) + .set({ + ...endedPartyFactTransition, + validTo: instantAsDate(input.validFrom), + }) + .where( + and( + eq(partyFactAssertions.tenantId, tenantId), + eq(partyFactAssertions.partyId, input.partyId), + eq(partyFactAssertions.factKind, factKind), + eq(partyFactAssertions.state, 'ACTIVE'), + eq(partyFactAssertions.isCurrent, true), + ), + ) + .pipe(Effect.mapError(unavailable)), + { concurrency: 1, discard: true }, + ); + if (assertions.length > 0) { + yield* transaction.insert(partyFactAssertions).values(assertions).pipe(Effect.mapError(unavailable)); + } + return { _tag: 'found', value: partyDto(updated) } as const; + }, +); -const lockPartyIdentityRecord = Effect.fn( - 'PartyIdentityPersistenceService.lockPartyIdentityRecord', -)(function* lockPartyIdentityRecord( - transaction: Pick, - tenantId: string, - partyId: string, -) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const [current] = yield* transaction - .select() - .from(parties) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - return current; -}); +const lockPartyIdentityRecord = Effect.fn('PartyIdentityPersistenceService.lockPartyIdentityRecord')( + function* lockPartyIdentityRecord(transaction: Pick, tenantId: string, partyId: string) { + yield* lockTenantIdentityWrites(transaction, tenantId); + const [current] = yield* transaction + .select() + .from(parties) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + return current; + }, +); -export const transitionPartyRecord = Effect.fn( - 'PartyIdentityPersistenceService.transitionPartyRecord', -)(function* transitionParty( - transaction: Pick, - tenantId: string, - partyId: string, - expectedRevision: number, - state: 'ARCHIVED', -) { - const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); - if (current === undefined) { - return { _tag: 'not_found' } as const; - } - yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId); - const archived = current.archivedAt !== null; - if (current.revision !== expectedRevision || archived === (state === 'ARCHIVED')) { - return { _tag: 'conflict', value: partyDto(current) } as const; - } - const now = yield* DateTime.nowAsDate; - const [updated] = yield* transaction - .update(parties) - .set({ - archivedAt: now, - revision: current.revision + 1, - updatedAt: now, - }) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) - .returning() - .pipe(Effect.mapError(unavailable)); - return updated === undefined - ? yield* unavailable() - : ({ _tag: 'found', value: partyDto(updated) } as const); -}); +export const transitionPartyRecord = Effect.fn('PartyIdentityPersistenceService.transitionPartyRecord')( + function* transitionParty( + transaction: Pick, + tenantId: string, + partyId: string, + expectedRevision: number, + state: 'ARCHIVED', + ) { + const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); + if (current === undefined) { + return { _tag: 'not_found' } as const; + } + yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId); + const archived = current.archivedAt !== null; + if (current.revision !== expectedRevision || archived === (state === 'ARCHIVED')) { + return { _tag: 'conflict', value: partyDto(current) } as const; + } + const now = yield* DateTime.nowAsDate; + const [updated] = yield* transaction + .update(parties) + .set({ + archivedAt: now, + revision: current.revision + 1, + updatedAt: now, + }) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) + .returning() + .pipe(Effect.mapError(unavailable)); + return updated === undefined ? yield* unavailable() : ({ _tag: 'found', value: partyDto(updated) } as const); + }, +); -export const unarchivePartyRecord = Effect.fn( - 'PartyIdentityPersistenceService.unarchivePartyRecord', -)(function* unarchiveParty( - transaction: Pick, - tenantId: string, - partyId: string, - expectedRevision: number, -) { - const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); - if (current === undefined) { - return { _tag: 'not_found' } as const; - } - yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId); - if (current.revision !== expectedRevision || current.archivedAt === null) { - return { _tag: 'conflict', value: partyDto(current) } as const; - } +export const unarchivePartyRecord = Effect.fn('PartyIdentityPersistenceService.unarchivePartyRecord')( + function* unarchiveParty( + transaction: Pick, + tenantId: string, + partyId: string, + expectedRevision: number, + ) { + const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); + if (current === undefined) { + return { _tag: 'not_found' } as const; + } + yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId); + if (current.revision !== expectedRevision || current.archivedAt === null) { + return { _tag: 'conflict', value: partyDto(current) } as const; + } - const openCases = yield* transaction - .select({ candidateCaseId: duplicateCandidateCases.candidateCaseId }) - .from(duplicateCandidateCaseParties) - .innerJoin( - duplicateCandidateCases, - and( - eq(duplicateCandidateCases.tenantId, duplicateCandidateCaseParties.tenantId), - eq(duplicateCandidateCases.candidateCaseId, duplicateCandidateCaseParties.candidateCaseId), - ), - ) - .where( - and( - eq(duplicateCandidateCaseParties.tenantId, tenantId), - eq(duplicateCandidateCaseParties.partyId, partyId), - inArray(duplicateCandidateCases.lifecycleState, ['OPEN', 'NEEDS_EVIDENCE']), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if (openCases.length > 0) { - return { - _tag: 'review_required', - caseIds: openCases.map((candidateCase) => candidateCase.candidateCaseId), - reasonCode: 'OPEN_DUPLICATE_CASE', - } as const; - } - if (current.currentType === 'UNRESOLVED') { - const reviewedAcceptances = yield* transaction + const openCases = yield* transaction .select({ candidateCaseId: duplicateCandidateCases.candidateCaseId }) - .from(duplicateCandidateCases) + .from(duplicateCandidateCaseParties) + .innerJoin( + duplicateCandidateCases, + and( + eq(duplicateCandidateCases.tenantId, duplicateCandidateCaseParties.tenantId), + eq(duplicateCandidateCases.candidateCaseId, duplicateCandidateCaseParties.candidateCaseId), + ), + ) .where( and( - eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.selectedPartyId, partyId), - eq(duplicateCandidateCases.lifecycleState, 'RESOLVED'), - eq(duplicateCandidateCases.resolutionOutcome, 'CREATE_NEW'), - isNotNull(duplicateCandidateCases.resolutionActionInvocationId), + eq(duplicateCandidateCaseParties.tenantId, tenantId), + eq(duplicateCandidateCaseParties.partyId, partyId), + inArray(duplicateCandidateCases.lifecycleState, ['OPEN', 'NEEDS_EVIDENCE']), ), ) .limit(1) + .for('update') .pipe(Effect.mapError(unavailable)); - if (reviewedAcceptances.length === 0) { - return { _tag: 'review_required', caseIds: [], reasonCode: 'UNRESOLVED_IDENTITY' } as const; + if (openCases.length > 0) { + return { + _tag: 'review_required', + caseIds: openCases.map((candidateCase) => candidateCase.candidateCaseId), + reasonCode: 'OPEN_DUPLICATE_CASE', + } as const; } - } - const claims = yield* reconcilePartyIdentifierClaims( - transaction, - tenantId, - partyId, - // SAFETY: The owner-local parties type CHECK admits exactly the PartyType values. - current.currentType as PartyType, - ); - if (!Schema.is(ClaimAvailableSchema)(claims)) { - return claims; - } - const now = yield* DateTime.nowAsDate; - const [updated] = yield* transaction - .update(parties) - .set({ archivedAt: null, revision: current.revision + 1, updatedAt: now }) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) - .returning() - .pipe(Effect.mapError(unavailable)); - return updated === undefined - ? yield* unavailable() - : ({ _tag: 'found', value: partyDto(updated) } as const); -}); + if (current.currentType === 'UNRESOLVED') { + const reviewedAcceptances = yield* transaction + .select({ candidateCaseId: duplicateCandidateCases.candidateCaseId }) + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, tenantId), + eq(duplicateCandidateCases.selectedPartyId, partyId), + eq(duplicateCandidateCases.lifecycleState, 'RESOLVED'), + eq(duplicateCandidateCases.resolutionOutcome, 'CREATE_NEW'), + isNotNull(duplicateCandidateCases.resolutionActionInvocationId), + ), + ) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (reviewedAcceptances.length === 0) { + return { + _tag: 'review_required', + caseIds: [], + reasonCode: 'UNRESOLVED_IDENTITY', + } as const; + } + } + const claims = yield* reconcilePartyIdentifierClaims( + transaction, + tenantId, + partyId, + // SAFETY: The owner-local parties type CHECK admits exactly the PartyType values. + current.currentType as PartyType, + ); + if (!Schema.is(ClaimAvailableSchema)(claims)) { + return claims; + } + const now = yield* DateTime.nowAsDate; + const [updated] = yield* transaction + .update(parties) + .set({ archivedAt: null, revision: current.revision + 1, updatedAt: now }) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) + .returning() + .pipe(Effect.mapError(unavailable)); + return updated === undefined ? yield* unavailable() : ({ _tag: 'found', value: partyDto(updated) } as const); + }, +); type BlockedUnarchiveCheck = Exclude; type UnarchiveTransaction = Parameters[0]; -const unarchiveReviewReason = ( - check: BlockedUnarchiveCheck, -): UnarchivePartyBlocked['reasonCode'] => { +const unarchiveReviewReason = (check: BlockedUnarchiveCheck): UnarchivePartyBlocked['reasonCode'] => { if (Schema.is(PartyUnarchiveIdentityConflictSchema)(check)) { return 'EXACT_CLAIM_CONFLICT'; } @@ -770,9 +715,7 @@ const unarchiveEvaluationFingerprint = ( evaluatedEvidence: readonly PartyEvidenceExplanation[], partyIds: readonly string[], ): string => - createHash('sha256') - .update(encodeJson({ candidateFingerprint, evaluatedEvidence, partyIds })) - .digest('hex'); + createHash('sha256').update(encodeJson({ candidateFingerprint, evaluatedEvidence, partyIds })).digest('hex'); interface CreateUnarchiveReviewCaseInput { readonly check: BlockedUnarchiveCheck; @@ -780,184 +723,184 @@ interface CreateUnarchiveReviewCaseInput { readonly tenantId: string; } -const createUnarchiveReviewCase = Effect.fn( - 'PartyIdentityPersistenceService.createUnarchiveReviewCase', -)(function* createReviewCase( - transaction: UnarchiveTransaction, - { check, party, tenantId }: CreateUnarchiveReviewCaseInput, -) { - const existingCaseIds = Schema.is(PartyUnarchiveReviewRequiredSchema)(check) ? check.caseIds : []; - const [existingCaseId] = existingCaseIds; - if (existingCaseId !== undefined) { - const [existing] = yield* transaction +const createUnarchiveReviewCase = Effect.fn('PartyIdentityPersistenceService.createUnarchiveReviewCase')( + function* createReviewCase( + transaction: UnarchiveTransaction, + { check, party, tenantId }: CreateUnarchiveReviewCaseInput, + ) { + const existingCaseIds = Schema.is(PartyUnarchiveReviewRequiredSchema)(check) ? check.caseIds : []; + const [existingCaseId] = existingCaseIds; + if (existingCaseId !== undefined) { + const [existing] = yield* transaction + .select() + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, tenantId), + eq(duplicateCandidateCases.candidateCaseId, existingCaseId), + ), + ) + .limit(1) + .pipe(Effect.mapError(unavailable)); + return existing === undefined ? yield* unavailable() : existing; + } + const now = yield* DateTime.nowAsDate; + const identifiers = yield* transaction + .select() + .from(partyOfficialIdentifiers) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.partyId, party.partyRef.resourceId), + eq(partyOfficialIdentifiers.state, 'ACTIVE'), + eq(partyOfficialIdentifiers.isCurrent, true), + lte(partyOfficialIdentifiers.validFrom, now), + or(isNull(partyOfficialIdentifiers.validTo), gt(partyOfficialIdentifiers.validTo, now)), + ), + ) + .orderBy(partyOfficialIdentifiers.officialIdentifierId) + .pipe(Effect.mapError(unavailable)); + const snapshot: PartyCandidateSnapshot = { + intent: 'UNARCHIVE', + names: Option.match(party.displayName, { + onNone: () => [], + onSome: (name) => [name], + }), + officialIdentifiers: identifiers.map((identifier) => ({ + // SAFETY: Owner-local CHECK constraints restrict identifier and verification types. + identifierTypeKey: identifier.identifierTypeKey as 'ICO' | 'CZ_DIC', + namespace: identifier.namespace, + normalizedValue: identifier.normalizedValue, + // SAFETY: Owner-local CHECK constraints restrict verification types. + verificationState: identifier.verificationState as 'VERIFIED' | 'UNVERIFIED' | 'REJECTED', + })), + partyType: party.partyType, + policyVersion: MATCH_RULE_VERSION, + provenance: { + method: 'UNARCHIVE_CANONICAL_RECHECK', + source: 'party.registry', + }, + sourceRecordRefs: [partyReviewSourceRef(tenantId, party.partyRef.resourceId)], + validFrom: DateTime.formatIso(party.updatedAt), + }; + const reasonCode = unarchiveReviewReason(check); + const partyIds = unarchiveReviewParties(party.partyRef.resourceId, check); + const explanation: PartyEvidenceExplanation = { + evidenceRefs: partyIds.map((id) => partyReviewSourceRef(tenantId, id)), + outcome: 'AMBIGUOUS', + reason: unarchiveReviewExplanations[reasonCode], + ruleKey: `party-unarchive-review.v1:${reasonCode}`, + }; + const evaluatedEvidence: readonly PartyEvidenceExplanation[] = [explanation]; + const candidateFingerprint = unarchiveCandidateFingerprint(snapshot); + const evaluationFingerprint = unarchiveEvaluationFingerprint(candidateFingerprint, evaluatedEvidence, partyIds); + const [open] = yield* findOpenDuplicateCandidateCase(transaction, tenantId, evaluationFingerprint).pipe( + Effect.mapError(unavailable), + ); + if (open !== undefined) { + return open; + } + const [prior] = yield* transaction .select() .from(duplicateCandidateCases) .where( and( eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.candidateCaseId, existingCaseId), + eq(duplicateCandidateCases.candidateFingerprint, candidateFingerprint), + eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), ), ) + .orderBy(desc(duplicateCandidateCases.createdAt)) .limit(1) .pipe(Effect.mapError(unavailable)); - return existing === undefined ? yield* unavailable() : existing; - } - const now = yield* DateTime.nowAsDate; - const identifiers = yield* transaction - .select() - .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.partyId, party.partyRef.resourceId), - eq(partyOfficialIdentifiers.state, 'ACTIVE'), - eq(partyOfficialIdentifiers.isCurrent, true), - lte(partyOfficialIdentifiers.validFrom, now), - or(isNull(partyOfficialIdentifiers.validTo), gt(partyOfficialIdentifiers.validTo, now)), - ), - ) - .orderBy(partyOfficialIdentifiers.officialIdentifierId) - .pipe(Effect.mapError(unavailable)); - const snapshot: PartyCandidateSnapshot = { - intent: 'UNARCHIVE', - names: Option.match(party.displayName, { onNone: () => [], onSome: (name) => [name] }), - officialIdentifiers: identifiers.map((identifier) => ({ - // SAFETY: Owner-local CHECK constraints restrict identifier and verification types. - identifierTypeKey: identifier.identifierTypeKey as 'ICO' | 'CZ_DIC', - namespace: identifier.namespace, - normalizedValue: identifier.normalizedValue, - // SAFETY: Owner-local CHECK constraints restrict verification types. - verificationState: identifier.verificationState as 'VERIFIED' | 'UNVERIFIED' | 'REJECTED', - })), - partyType: party.partyType, - policyVersion: MATCH_RULE_VERSION, - provenance: { method: 'UNARCHIVE_CANONICAL_RECHECK', source: 'party.registry' }, - sourceRecordRefs: [partyReviewSourceRef(tenantId, party.partyRef.resourceId)], - validFrom: DateTime.formatIso(party.updatedAt), - }; - const reasonCode = unarchiveReviewReason(check); - const partyIds = unarchiveReviewParties(party.partyRef.resourceId, check); - const explanation: PartyEvidenceExplanation = { - evidenceRefs: partyIds.map((id) => partyReviewSourceRef(tenantId, id)), - outcome: 'AMBIGUOUS', - reason: unarchiveReviewExplanations[reasonCode], - ruleKey: `party-unarchive-review.v1:${reasonCode}`, - }; - const evaluatedEvidence: readonly PartyEvidenceExplanation[] = [explanation]; - const candidateFingerprint = unarchiveCandidateFingerprint(snapshot); - const evaluationFingerprint = unarchiveEvaluationFingerprint( - candidateFingerprint, - evaluatedEvidence, - partyIds, - ); - const [open] = yield* findOpenDuplicateCandidateCase( - transaction, - tenantId, - evaluationFingerprint, - ).pipe(Effect.mapError(unavailable)); - if (open !== undefined) { - return open; - } - const [prior] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.candidateFingerprint, candidateFingerprint), - eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), - ), - ) - .orderBy(desc(duplicateCandidateCases.createdAt)) - .limit(1) - .pipe(Effect.mapError(unavailable)); - const [created] = yield* transaction - .insert(duplicateCandidateCases) - .values({ - candidateFingerprint, - candidateSnapshot: snapshot, - evaluatedEvidence, - evaluationFingerprint, - matchRuleVersion: MATCH_RULE_VERSION, - priorCandidateCaseId: prior?.candidateCaseId ?? null, - tenantId, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (created === undefined) { - return yield* unavailable(); - } - yield* transaction - .insert(duplicateCandidateCaseParties) - .values( - partyIds.map((id, index) => ({ - candidateCaseId: created.candidateCaseId, - evidenceExplanation: explanation, - partyId: id, - rank: index + 1, + const [created] = yield* transaction + .insert(duplicateCandidateCases) + .values({ + candidateFingerprint, + candidateSnapshot: snapshot, + evaluatedEvidence, + evaluationFingerprint, + matchRuleVersion: MATCH_RULE_VERSION, + priorCandidateCaseId: prior?.candidateCaseId ?? null, tenantId, - })), - ) - .pipe(Effect.mapError(unavailable)); - return created; -}); + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (created === undefined) { + return yield* unavailable(); + } + yield* transaction + .insert(duplicateCandidateCaseParties) + .values( + partyIds.map((id, index) => ({ + candidateCaseId: created.candidateCaseId, + evidenceExplanation: explanation, + partyId: id, + rank: index + 1, + tenantId, + })), + ) + .pipe(Effect.mapError(unavailable)); + return created; + }, +); /** A blocked recheck is a committed review result, never a rollback-only domain rejection. */ -export const unarchivePartyWithReview = Effect.fn( - 'PartyIdentityPersistenceService.unarchivePartyWithReview', -)(function* unarchiveWithReview( - transaction: UnarchiveTransaction, - tenantId: string, - partyId: string, - expectedRevision: number, - actionInvocationId: string, -) { - const check = yield* unarchivePartyRecord(transaction, tenantId, partyId, expectedRevision); - if (Schema.is(PartyLifecycleSchema)(check)) { - return check; - } - const lookup = yield* findPartyRecord(transaction, tenantId, partyId); - if (Schema.is(PartyNotFoundSchema)(lookup)) { - return yield* unavailable(); - } - const candidateCase = yield* createUnarchiveReviewCase(transaction, { - check, - party: lookup.value, - tenantId, - }); - const [decision] = yield* transaction - .insert(partyMatchDecisions) - .values({ - actionInvocationId, - candidateCaseId: candidateCase.candidateCaseId, - candidateFingerprint: candidateCase.candidateFingerprint, - evidenceExplanation: candidateCase.evaluatedEvidence, - matchRuleVersion: candidateCase.matchRuleVersion, - operation: 'LIFECYCLE', - outcome: 'AMBIGUOUS', - tenantId, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (decision === undefined) { - return yield* unavailable(); - } - const value: UnarchivePartyBlocked = { - caseRef: DuplicateCandidateCaseRefSchema.make({ - moduleId: 'party.registry', - resourceId: candidateCase.candidateCaseId, - resourceType: 'party.registry.duplicate-candidate-case', - tenantId, - }), - decisionRef: PartyMatchDecisionRefSchema.make({ - moduleId: 'party.registry', - resourceId: decision.matchDecisionId, - resourceType: 'party.registry.party-match-decision', +export const unarchivePartyWithReview = Effect.fn('PartyIdentityPersistenceService.unarchivePartyWithReview')( + function* unarchiveWithReview( + transaction: UnarchiveTransaction, + tenantId: string, + partyId: string, + expectedRevision: number, + actionInvocationId: string, + ) { + const check = yield* unarchivePartyRecord(transaction, tenantId, partyId, expectedRevision); + if (Schema.is(PartyLifecycleSchema)(check)) { + return check; + } + const lookup = yield* findPartyRecord(transaction, tenantId, partyId); + if (Schema.is(PartyNotFoundSchema)(lookup)) { + return yield* unavailable(); + } + const candidateCase = yield* createUnarchiveReviewCase(transaction, { + check, + party: lookup.value, tenantId, - }), - outcome: 'BLOCKED', - party: lookup.value, - reasonCode: unarchiveReviewReason(check), - }; - return { _tag: 'blocked', value } as const; -}); + }); + const [decision] = yield* transaction + .insert(partyMatchDecisions) + .values({ + actionInvocationId, + candidateCaseId: candidateCase.candidateCaseId, + candidateFingerprint: candidateCase.candidateFingerprint, + evidenceExplanation: candidateCase.evaluatedEvidence, + matchRuleVersion: candidateCase.matchRuleVersion, + operation: 'LIFECYCLE', + outcome: 'AMBIGUOUS', + tenantId, + }) + .returning() + .pipe(Effect.mapError(unavailable)); + if (decision === undefined) { + return yield* unavailable(); + } + const value: UnarchivePartyBlocked = { + caseRef: DuplicateCandidateCaseRefSchema.make({ + moduleId: 'party.registry', + resourceId: candidateCase.candidateCaseId, + resourceType: 'party.registry.duplicate-candidate-case', + tenantId, + }), + decisionRef: PartyMatchDecisionRefSchema.make({ + moduleId: 'party.registry', + resourceId: decision.matchDecisionId, + resourceType: 'party.registry.party-match-decision', + tenantId, + }), + outcome: 'BLOCKED', + party: lookup.value, + reasonCode: unarchiveReviewReason(check), + }; + return { _tag: 'blocked', value } as const; + }, +); diff --git a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts index 2d3b3adf1..26749159a 100644 --- a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts @@ -1,12 +1,11 @@ +import { createHash } from 'node:crypto'; + // @generated by OntOS Codesmith Action Service v1 import { and, desc, eq, gt, isNull, lte, or, sql } from 'drizzle-orm'; import { DateTime, Effect, Option, Result, Schema } from 'effect'; -import { createHash } from 'node:crypto'; + import type { NormalizedOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; -import { - normalizeOfficialIdentifier, - qualifiesForExclusiveClaim, -} from '../../shared/domain/identifier-contracts.ts'; +import { normalizeOfficialIdentifier, qualifiesForExclusiveClaim } from '../../shared/domain/identifier-contracts.ts'; import type { PartyCandidate, PartyCreateOutcome, @@ -20,14 +19,8 @@ import { makePartyRef, } from '../../shared/domain/identity-contracts.ts'; import type { DuplicateCaseResolutionResult } from '../../shared/domain/matching-contracts.ts'; -import { - ClaimOwnedByDifferentParty, - DuplicateCandidateConflict, -} from '../../shared/domain/matching-contracts.ts'; -import type { - PartyAliasResolutionError, - PartyAliasWriteRejected, -} from '../../shared/domain/merge-alias-resolution.ts'; +import { ClaimOwnedByDifferentParty, DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; +import type { PartyAliasResolutionError, PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Pure ResourceRef value constructor, not an Effect service constructor. import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Pure ResourceRef value constructor, not an Effect service constructor. @@ -45,19 +38,13 @@ import { partyOfficialIdentifiers, } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; -import { - requireCanonicalPartyWriteTarget, - resolvePartyAlias, -} from '../merge/party-alias-resolution.service.ts'; +import { requireCanonicalPartyWriteTarget, resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; import { decideCreateWithoutStrongIdentifier, evaluatePartySubjectEvidence, requirePartySubjectEvidence, } from '../policies/create-party-without-strong-identifier.policy.ts'; -import { - lockAndResolveClaims, - lockTenantIdentityWrites, -} from './party-identifier-claim.service.ts'; +import { lockAndResolveClaims, lockTenantIdentityWrites } from './party-identifier-claim.service.ts'; import { findOpenDuplicateCandidateCase, findPartyRecord, @@ -69,8 +56,7 @@ import { addOfficialIdentifierRecord } from './party-official-identifier-persist const MATCH_RULE_VERSION = 'party-exact-claims.v1'; const PartyNotFoundSchema = Schema.TaggedStruct('not_found', {}); const JsonCodec = Schema.fromJsonString(Schema.Any); -const encodeJson = (value: Value): string => - Result.getOrThrow(Schema.encodeResult(JsonCodec)(value)); +const encodeJson = (value: Value): string => Result.getOrThrow(Schema.encodeResult(JsonCodec)(value)); const unavailable = (cause?: unknown) => Object.assign( new PartyPersistenceUnavailable({ @@ -86,32 +72,20 @@ const encodedCandidateInstant = (instant: PartyCandidate['validFrom'] | string): DateTime.formatIso(candidateInstant(instant)); const hasIncompatiblePartyType = (partyType: string, candidate: PartyCandidate) => - partyType !== 'UNRESOLVED' && - candidate.partyType !== 'UNRESOLVED' && - partyType !== candidate.partyType; + partyType !== 'UNRESOLVED' && candidate.partyType !== 'UNRESOLVED' && partyType !== candidate.partyType; const qualifyingCandidateClaims = (candidate: PartyCandidate) => candidate.officialIdentifiers .map(normalizeOfficialIdentifier) - .filter((identifier) => - qualifiesForExclusiveClaim(identifier, candidate.partyType, MATCH_RULE_VERSION), - ); + .filter((identifier) => qualifiesForExclusiveClaim(identifier, candidate.partyType, MATCH_RULE_VERSION)); -const canonicalPartyId = ( - transaction: Pick, - tenantId: string, - partyId: string, -) => +const canonicalPartyId = (transaction: Pick, tenantId: string, partyId: string) => resolvePartyAlias(transaction, tenantId, partyId).pipe( Effect.map((resolution) => resolution.canonicalPartyId), Effect.mapError(unavailable), ); -const findLockedPartyRecord = ( - transaction: Pick, - tenantId: string, - partyId: string, -) => +const findLockedPartyRecord = (transaction: Pick, tenantId: string, partyId: string) => transaction .select() .from(parties) @@ -121,9 +95,7 @@ const findLockedPartyRecord = ( .pipe( Effect.mapError(unavailable), Effect.map(([row]) => - row === undefined - ? { _tag: 'not_found' as const } - : { _tag: 'found' as const, value: partyDto(row) }, + row === undefined ? { _tag: 'not_found' as const } : { _tag: 'found' as const, value: partyDto(row) }, ), ); @@ -209,10 +181,7 @@ const explainCandidateEvidence = ( : claims.map(({ claim, officialIdentifierId, partyId }) => { const reference: OfficialIdentifierReferenceFields = {}; if (officialIdentifierId !== undefined) { - reference.officialIdentifierRef = makePartyOfficialIdentifierRef( - tenantId, - officialIdentifierId, - ); + reference.officialIdentifierRef = makePartyOfficialIdentifierRef(tenantId, officialIdentifierId); } return { evidenceRefs: [...candidate.evidenceRefs].toSorted(), @@ -231,60 +200,60 @@ const explainCandidateEvidence = ( }); /** Exact weak evidence proposes review candidates only; it can never establish MATCHED. */ -const findWeakCandidatePartyIds = Effect.fn( - 'PartyMatchingPersistenceService.findWeakCandidatePartyIds', -)(function* findCanonicalWeakCandidates( - transaction: Pick, - tenantId: string, - candidate: PartyCandidate, - now: Date, -) { - const { displayName } = candidate; - const displayNamePartyIds = - displayName === undefined - ? [] - : yield* transaction - .select({ partyId: parties.partyId }) - .from(parties) - .where(and(eq(parties.tenantId, tenantId), eq(parties.currentDisplayName, displayName))) +const findWeakCandidatePartyIds = Effect.fn('PartyMatchingPersistenceService.findWeakCandidatePartyIds')( + function* findCanonicalWeakCandidates( + transaction: Pick, + tenantId: string, + candidate: PartyCandidate, + now: Date, + ) { + const { displayName } = candidate; + const displayNamePartyIds = + displayName === undefined + ? [] + : yield* transaction + .select({ partyId: parties.partyId }) + .from(parties) + .where(and(eq(parties.tenantId, tenantId), eq(parties.currentDisplayName, displayName))) + .limit(25) + .pipe( + Effect.mapError(unavailable), + Effect.map((rows) => rows.map(({ partyId }) => partyId)), + ); + const identifierPartyIds = yield* Effect.forEach( + candidate.officialIdentifiers.map(normalizeOfficialIdentifier), + (identifier) => + transaction + .select({ partyId: partyOfficialIdentifiers.partyId }) + .from(partyOfficialIdentifiers) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.identifierTypeKey, identifier.identifierType), + eq(partyOfficialIdentifiers.namespace, identifier.namespace), + eq(partyOfficialIdentifiers.normalizedValue, identifier.normalizedValue), + eq(partyOfficialIdentifiers.state, 'ACTIVE'), + eq(partyOfficialIdentifiers.isCurrent, true), + lte(partyOfficialIdentifiers.validFrom, now), + or(isNull(partyOfficialIdentifiers.validTo), gt(partyOfficialIdentifiers.validTo, now)), + ), + ) .limit(25) .pipe( Effect.mapError(unavailable), Effect.map((rows) => rows.map(({ partyId }) => partyId)), - ); - const identifierPartyIds = yield* Effect.forEach( - candidate.officialIdentifiers.map(normalizeOfficialIdentifier), - (identifier) => - transaction - .select({ partyId: partyOfficialIdentifiers.partyId }) - .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.identifierTypeKey, identifier.identifierType), - eq(partyOfficialIdentifiers.namespace, identifier.namespace), - eq(partyOfficialIdentifiers.normalizedValue, identifier.normalizedValue), - eq(partyOfficialIdentifiers.state, 'ACTIVE'), - eq(partyOfficialIdentifiers.isCurrent, true), - lte(partyOfficialIdentifiers.validFrom, now), - or(isNull(partyOfficialIdentifiers.validTo), gt(partyOfficialIdentifiers.validTo, now)), ), - ) - .limit(25) - .pipe( - Effect.mapError(unavailable), - Effect.map((rows) => rows.map(({ partyId }) => partyId)), - ), - { concurrency: 1 }, - ); - const candidateIds = [...displayNamePartyIds, ...identifierPartyIds.flat()]; - const canonicalIds = yield* Effect.forEach( - [...new Set(candidateIds)].slice(0, 25), - (partyId) => canonicalPartyId(transaction, tenantId, partyId), - { concurrency: 1 }, - ); - return [...new Set(canonicalIds)].toSorted(); -}); + { concurrency: 1 }, + ); + const candidateIds = [...displayNamePartyIds, ...identifierPartyIds.flat()]; + const canonicalIds = yield* Effect.forEach( + [...new Set(candidateIds)].slice(0, 25), + (partyId) => canonicalPartyId(transaction, tenantId, partyId), + { concurrency: 1 }, + ); + return [...new Set(canonicalIds)].toSorted(); + }, +); /** * This decision must run inside the canonical Party transaction. A Core Action Policy cannot own @@ -308,11 +277,7 @@ export const candidateFingerprint = (candidate: PartyCandidate): string => ...evaluatePartySubjectEvidence(candidate), evidence: (candidate.subjectEvidence ?? []) .map((item) => - encodeJson( - Object.fromEntries( - Object.entries(item).toSorted(([a], [b]) => a.localeCompare(b, 'en')), - ), - ), + encodeJson(Object.fromEntries(Object.entries(item).toSorted(([a], [b]) => a.localeCompare(b, 'en')))), ) .toSorted(), }, @@ -331,8 +296,8 @@ export const candidateFingerprint = (candidate: PartyCandidate): string => candidate.provenance.externalEvidence === undefined ? null : Object.fromEntries( - Object.entries(candidate.provenance.externalEvidence).toSorted( - ([left], [right]) => left.localeCompare(right, 'en'), + Object.entries(candidate.provenance.externalEvidence).toSorted(([left], [right]) => + left.localeCompare(right, 'en'), ), ), method: candidate.provenance.method, @@ -387,97 +352,88 @@ const snapshotCandidate = (candidate: PartyCandidate): PartyCandidateSnapshot => validFrom: encodedCandidateInstant(candidate.validFrom), }); -const createOrReuseCase = Effect.fn('PartyMatchingPersistenceService.createOrReuseCase')( - function* createCase( - transaction: Pick, - input: CreateOrReuseCaseInput, - ) { - const { candidate, partyIds, priorCandidateCaseId, tenantId } = input; - const evidenceExplanation = - input.evidenceExplanation ?? explainCandidateEvidence(tenantId, candidate, 'AMBIGUOUS'); - const fingerprint = candidateFingerprint(candidate); - const evaluationFingerprint = caseEvaluationFingerprint( - candidate, - partyIds, - evidenceExplanation, - ); - // Drizzle has no advisory-lock builder. This tenant-qualified transaction lock serializes - // case reuse even when the Candidate has no eligible exclusive identifier claim to lock. - const lockKey = caseLockKey(tenantId, fingerprint); +const createOrReuseCase = Effect.fn('PartyMatchingPersistenceService.createOrReuseCase')(function* createCase( + transaction: Pick, + input: CreateOrReuseCaseInput, +) { + const { candidate, partyIds, priorCandidateCaseId, tenantId } = input; + const evidenceExplanation = input.evidenceExplanation ?? explainCandidateEvidence(tenantId, candidate, 'AMBIGUOUS'); + const fingerprint = candidateFingerprint(candidate); + const evaluationFingerprint = caseEvaluationFingerprint(candidate, partyIds, evidenceExplanation); + // Drizzle has no advisory-lock builder. This tenant-qualified transaction lock serializes + // case reuse even when the Candidate has no eligible exclusive identifier claim to lock. + const lockKey = caseLockKey(tenantId, fingerprint); + yield* transaction + .select({ + lock: sql`pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`, + }) + .from(sql`(values (1)) as party_case_lock_anchor(value)`) + .pipe(Effect.mapError(unavailable)); + const [existing] = yield* findOpenDuplicateCandidateCase(transaction, tenantId, evaluationFingerprint).pipe( + Effect.mapError(unavailable), + ); + if (existing !== undefined) { + return existing; + } + const [prior] = yield* transaction + .select() + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, tenantId), + priorCandidateCaseId === undefined + ? eq(duplicateCandidateCases.candidateFingerprint, fingerprint) + : eq(duplicateCandidateCases.candidateCaseId, priorCandidateCaseId), + eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), + ), + ) + .orderBy(desc(duplicateCandidateCases.createdAt)) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (priorCandidateCaseId !== undefined && prior === undefined) { + return yield* new PartyEvidenceInsufficient({ + code: 'party_evidence_insufficient', + reason: 'The prior review case does not exist in the trusted tenant', + }); + } + const values: typeof duplicateCandidateCases.$inferInsert = { + candidateFingerprint: fingerprint, + candidateSnapshot: snapshotCandidate(candidate), + evaluatedEvidence: evidenceExplanation, + evaluationFingerprint, + matchRuleVersion: MATCH_RULE_VERSION, + tenantId, + }; + if (prior !== undefined) { + values.priorCandidateCaseId = prior.candidateCaseId; + } + const [candidateCase] = yield* transaction + .insert(duplicateCandidateCases) + .values(values) + .returning() + .pipe(Effect.mapError(unavailable)); + if (candidateCase === undefined) { + return yield* unavailable(); + } + const evaluatedParties = [...new Set(partyIds)].toSorted().map((partyId, index) => ({ + candidateCaseId: candidateCase.candidateCaseId, + evidenceExplanation: { + outcome: 'AMBIGUOUS' as const, + reason: `Canonical evidence makes Party ${partyId} relevant for identity review`, + ruleKey: MATCH_RULE_VERSION, + }, + partyId, + rank: index + 1, + tenantId, + })); + if (evaluatedParties.length > 0) { yield* transaction - .select({ - lock: sql`pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`, - }) - .from(sql`(values (1)) as party_case_lock_anchor(value)`) - .pipe(Effect.mapError(unavailable)); - const [existing] = yield* findOpenDuplicateCandidateCase( - transaction, - tenantId, - evaluationFingerprint, - ).pipe(Effect.mapError(unavailable)); - if (existing !== undefined) { - return existing; - } - const [prior] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, tenantId), - priorCandidateCaseId === undefined - ? eq(duplicateCandidateCases.candidateFingerprint, fingerprint) - : eq(duplicateCandidateCases.candidateCaseId, priorCandidateCaseId), - eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), - ), - ) - .orderBy(desc(duplicateCandidateCases.createdAt)) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (priorCandidateCaseId !== undefined && prior === undefined) { - return yield* new PartyEvidenceInsufficient({ - code: 'party_evidence_insufficient', - reason: 'The prior review case does not exist in the trusted tenant', - }); - } - const values: typeof duplicateCandidateCases.$inferInsert = { - candidateFingerprint: fingerprint, - candidateSnapshot: snapshotCandidate(candidate), - evaluatedEvidence: evidenceExplanation, - evaluationFingerprint, - matchRuleVersion: MATCH_RULE_VERSION, - tenantId, - }; - if (prior !== undefined) { - values.priorCandidateCaseId = prior.candidateCaseId; - } - const [candidateCase] = yield* transaction - .insert(duplicateCandidateCases) - .values(values) - .returning() + .insert(duplicateCandidateCaseParties) + .values(evaluatedParties) .pipe(Effect.mapError(unavailable)); - if (candidateCase === undefined) { - return yield* unavailable(); - } - const evaluatedParties = [...new Set(partyIds)].toSorted().map((partyId, index) => ({ - candidateCaseId: candidateCase.candidateCaseId, - evidenceExplanation: { - outcome: 'AMBIGUOUS' as const, - reason: `Canonical evidence makes Party ${partyId} relevant for identity review`, - ruleKey: MATCH_RULE_VERSION, - }, - partyId, - rank: index + 1, - tenantId, - })); - if (evaluatedParties.length > 0) { - yield* transaction - .insert(duplicateCandidateCaseParties) - .values(evaluatedParties) - .pipe(Effect.mapError(unavailable)); - } - return candidateCase; - }, -); + } + return candidateCase; +}); const persistDecision = ( transaction: Pick, @@ -499,20 +455,14 @@ const persistDecision = ( candidateFingerprint: input.candidateFingerprint, committedCreateOutcome: null, evidenceEvaluation: evaluatePartySubjectEvidence(input.candidate), - evidenceExplanation: explainCandidateEvidence( - input.tenantId, - input.candidate, - input.outcome, - input.claims, - ), + evidenceExplanation: explainCandidateEvidence(input.tenantId, input.candidate, input.outcome, input.claims), matchRuleVersion: MATCH_RULE_VERSION, operation: input.operation, outcome: input.outcome, tenantId: input.tenantId, }; if (input.operation === 'CREATE' || input.operation === 'REVIEW_CREATE') { - values.committedCreateOutcome = - input.outcome === 'MATCHED' ? 'MATCHED_EXISTING' : input.outcome; + values.committedCreateOutcome = input.outcome === 'MATCHED' ? 'MATCHED_EXISTING' : input.outcome; } if (input.candidateCaseId !== undefined) { values.candidateCaseId = input.candidateCaseId; @@ -526,9 +476,7 @@ const persistDecision = ( .returning() .pipe( Effect.mapError(unavailable), - Effect.flatMap(([decision]) => - decision === undefined ? Effect.fail(unavailable()) : Effect.succeed(decision), - ), + Effect.flatMap(([decision]) => (decision === undefined ? Effect.fail(unavailable()) : Effect.succeed(decision))), ); }; @@ -583,9 +531,7 @@ const acceptMatchingIdentifiers = ( validFrom: encodedCandidateInstant(input.candidate.validFrom), }).pipe( Effect.tap((record) => - Effect.sync(() => - collectNewIdentifierAcceptance(acceptedIds, record, input.actionInvocationId), - ), + Effect.sync(() => collectNewIdentifierAcceptance(acceptedIds, record, input.actionInvocationId)), ), ), { concurrency: 1, discard: true }, @@ -639,14 +585,14 @@ const matchExistingCandidate = Effect.fn('PartyMatchingPersistenceService.matchE Option.isSome(existing.value.archivedAt) || hasIncompatiblePartyType(existing.value.partyType, input.candidate) ) { - return yield* recordAmbiguousCreate(transaction, input, partyIds, { claims: resolvedClaims }); + return yield* recordAmbiguousCreate(transaction, input, partyIds, { + claims: resolvedClaims, + }); } const addedOfficialIdentifierIds = new Set(); const identifiersToAccept = [ ...resolvedClaims.filter((claim) => claim.partyId === undefined).map((claim) => claim.claim), - ...identifiers.filter( - (item) => !qualifiesForExclusiveClaim(item, input.candidate.partyType, MATCH_RULE_VERSION), - ), + ...identifiers.filter((item) => !qualifiesForExclusiveClaim(item, input.candidate.partyType, MATCH_RULE_VERSION)), ]; yield* acceptMatchingIdentifiers( transaction, @@ -709,42 +655,25 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( }); } if (eligibility.decision === 'REVIEW_REQUIRED') { - const weakPartyIds = yield* findWeakCandidatePartyIds( - transaction, - input.tenantId, - input.candidate, - now, - ); + const weakPartyIds = yield* findWeakCandidatePartyIds(transaction, input.tenantId, input.candidate, now); return yield* recordAmbiguousCreate(transaction, input, weakPartyIds); } } const resolvedClaims = yield* lockAndResolveClaims(transaction, input.tenantId, strong); const partyIds = [ - ...new Set( - resolvedClaims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId])), - ), + ...new Set(resolvedClaims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId]))), ].toSorted(); if (partyIds.length > 1) { - return yield* recordAmbiguousCreate(transaction, input, partyIds, { claims: resolvedClaims }); + return yield* recordAmbiguousCreate(transaction, input, partyIds, { + claims: resolvedClaims, + }); } const [existingPartyId] = partyIds; if (existingPartyId !== undefined) { - return yield* matchExistingCandidate( - transaction, - input, - existingPartyId, - partyIds, - resolvedClaims, - identifiers, - ); + return yield* matchExistingCandidate(transaction, input, existingPartyId, partyIds, resolvedClaims, identifiers); } if (input.createWithoutStrongIdentifierReviewed !== true) { - const weakPartyIds = yield* findWeakCandidatePartyIds( - transaction, - input.tenantId, - input.candidate, - now, - ); + const weakPartyIds = yield* findWeakCandidatePartyIds(transaction, input.tenantId, input.candidate, now); if (weakPartyIds.length > 0) { return yield* recordAmbiguousCreate(transaction, input, weakPartyIds); } @@ -757,22 +686,16 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( yield* Effect.forEach( identifiers, (identifier) => - addOfficialIdentifierRecord( - transaction, - input.tenantId, - party.partyRef.resourceId, - identifier, - { - actionInvocationId: input.actionInvocationId, - externalEvidence: input.candidate.provenance.externalEvidence, - matchRuleVersion: MATCH_RULE_VERSION, - partyType: input.candidate.partyType, - principalId: input.principalId, - provenanceMethod: input.candidate.provenance.method, - provenanceSource: input.candidate.provenance.source, - validFrom: encodedCandidateInstant(input.candidate.validFrom), - }, - ), + addOfficialIdentifierRecord(transaction, input.tenantId, party.partyRef.resourceId, identifier, { + actionInvocationId: input.actionInvocationId, + externalEvidence: input.candidate.provenance.externalEvidence, + matchRuleVersion: MATCH_RULE_VERSION, + partyType: input.candidate.partyType, + principalId: input.principalId, + provenanceMethod: input.candidate.provenance.method, + provenanceSource: input.candidate.provenance.source, + validFrom: encodedCandidateInstant(input.candidate.validFrom), + }), { concurrency: 1, discard: true }, ); const decision = yield* persistDecision(transaction, { @@ -846,10 +769,7 @@ const evaluateMatchOutcome = Effect.fn('PartyMatchingPersistenceService.evaluate if (Schema.is(PartyNotFoundSchema)(party)) { return yield* unavailable(); } - if ( - Option.isSome(party.value.archivedAt) || - hasIncompatiblePartyType(party.value.partyType, candidate) - ) { + if (Option.isSome(party.value.archivedAt) || hasIncompatiblePartyType(party.value.partyType, candidate)) { outcome = 'AMBIGUOUS'; } } @@ -858,186 +778,164 @@ const evaluateMatchOutcome = Effect.fn('PartyMatchingPersistenceService.evaluate ); /** Records an identity decision without creating or changing a canonical Party. */ -export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty')( - function* recordMatchDecision( - transaction: Pick, +export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty')(function* recordMatchDecision( + transaction: Pick, + input: { + readonly actionInvocationId: string; + readonly candidate: PartyCandidate; + readonly priorCandidateCaseId?: string; + readonly priorCaseTenantId?: string; + readonly tenantId: string; + }, +) { + yield* lockTenantIdentityWrites(transaction, input.tenantId); + yield* requirePriorReviewCase(transaction, input); + yield* requirePartySubjectEvidence(input.candidate); + const now = yield* DateTime.nowAsDate; + if (DateTime.toDateUtc(candidateInstant(input.candidate.validFrom)) > now) { + return yield* new PartyEvidenceInsufficient({ + code: 'party_evidence_insufficient', + reason: 'Future-effective evidence cannot establish a current identity decision', + }); + } + const claims = yield* lockAndResolveClaims(transaction, input.tenantId, qualifyingCandidateClaims(input.candidate)); + const canonicalIds = yield* Effect.forEach( + claims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId])), + (partyId) => canonicalPartyId(transaction, input.tenantId, partyId), + { concurrency: 1 }, + ); + const strongPartyIds = [...new Set(canonicalIds)].toSorted(); + const partyIds = + strongPartyIds.length > 0 + ? strongPartyIds + : yield* findWeakCandidatePartyIds(transaction, input.tenantId, input.candidate, now); + const outcome = yield* evaluateMatchOutcome(transaction, input.tenantId, input.candidate, partyIds, strongPartyIds); + const [solePartyId] = partyIds; + const createCaseInput: CreateOrReuseCaseInput = { + candidate: input.candidate, + evidenceExplanation: explainCandidateEvidence(input.tenantId, input.candidate, outcome, claims), + partyIds, + tenantId: input.tenantId, + }; + if (input.priorCandidateCaseId !== undefined) { + createCaseInput.priorCandidateCaseId = input.priorCandidateCaseId; + } + const candidateCase = outcome === 'AMBIGUOUS' ? yield* createOrReuseCase(transaction, createCaseInput) : undefined; + const decisionReferences: DecisionReferenceFields = {}; + if (candidateCase !== undefined) { + decisionReferences.candidateCaseId = candidateCase.candidateCaseId; + } + if (outcome === 'MATCHED' && solePartyId !== undefined) { + decisionReferences.partyId = solePartyId; + } + const decision = yield* persistDecision(transaction, { + actionInvocationId: input.actionInvocationId, + candidate: input.candidate, + candidateFingerprint: candidateFingerprint(input.candidate), + claims, + operation: 'MATCH', + outcome, + partyIds, + tenantId: input.tenantId, + ...decisionReferences, + }); + return { + candidateParties: partyIds.map((partyId) => makePartyRef(input.tenantId, partyId)), + caseRef: + candidateCase === undefined ? null : makeDuplicateCandidateCaseRef(input.tenantId, candidateCase.candidateCaseId), + decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), + evidenceExplanation: decision.evidenceExplanation.map((item) => ({ + ...item, + })), + matchRuleVersion: MATCH_RULE_VERSION, + outcome, + }; +}); + +const requireOpenCandidateCase = Effect.fn('PartyMatchingPersistenceService.requireOpenCandidateCase')( + function* requireOpenCase( + transaction: Pick, input: { - readonly actionInvocationId: string; - readonly candidate: PartyCandidate; - readonly priorCandidateCaseId?: string; - readonly priorCaseTenantId?: string; readonly tenantId: string; + readonly candidateCaseId: string; + readonly expectedRevision: number; }, ) { - yield* lockTenantIdentityWrites(transaction, input.tenantId); - yield* requirePriorReviewCase(transaction, input); - yield* requirePartySubjectEvidence(input.candidate); - const now = yield* DateTime.nowAsDate; - if (DateTime.toDateUtc(candidateInstant(input.candidate.validFrom)) > now) { - return yield* new PartyEvidenceInsufficient({ - code: 'party_evidence_insufficient', - reason: 'Future-effective evidence cannot establish a current identity decision', + const [candidateCase] = yield* transaction + .select() + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, input.tenantId), + eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if ( + candidateCase === undefined || + candidateCase.revision !== input.expectedRevision || + !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) + ) { + return yield* new DuplicateCandidateConflict({ + code: 'duplicate_candidate_conflict', + reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', }); } - const claims = yield* lockAndResolveClaims( - transaction, - input.tenantId, - qualifyingCandidateClaims(input.candidate), - ); - const canonicalIds = yield* Effect.forEach( - claims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId])), - (partyId) => canonicalPartyId(transaction, input.tenantId, partyId), - { concurrency: 1 }, - ); - const strongPartyIds = [...new Set(canonicalIds)].toSorted(); - const partyIds = - strongPartyIds.length > 0 - ? strongPartyIds - : yield* findWeakCandidatePartyIds(transaction, input.tenantId, input.candidate, now); - const outcome = yield* evaluateMatchOutcome( - transaction, - input.tenantId, - input.candidate, - partyIds, - strongPartyIds, - ); - const [solePartyId] = partyIds; - const createCaseInput: CreateOrReuseCaseInput = { - candidate: input.candidate, - evidenceExplanation: explainCandidateEvidence( - input.tenantId, - input.candidate, - outcome, - claims, - ), - partyIds, - tenantId: input.tenantId, - }; - if (input.priorCandidateCaseId !== undefined) { - createCaseInput.priorCandidateCaseId = input.priorCandidateCaseId; - } - const candidateCase = - outcome === 'AMBIGUOUS' ? yield* createOrReuseCase(transaction, createCaseInput) : undefined; - const decisionReferences: DecisionReferenceFields = {}; - if (candidateCase !== undefined) { - decisionReferences.candidateCaseId = candidateCase.candidateCaseId; - } - if (outcome === 'MATCHED' && solePartyId !== undefined) { - decisionReferences.partyId = solePartyId; - } - const decision = yield* persistDecision(transaction, { - actionInvocationId: input.actionInvocationId, - candidate: input.candidate, - candidateFingerprint: candidateFingerprint(input.candidate), - claims, - operation: 'MATCH', - outcome, - partyIds, - tenantId: input.tenantId, - ...decisionReferences, - }); - return { - candidateParties: partyIds.map((partyId) => makePartyRef(input.tenantId, partyId)), - caseRef: - candidateCase === undefined - ? null - : makeDuplicateCandidateCaseRef(input.tenantId, candidateCase.candidateCaseId), - decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), - evidenceExplanation: decision.evidenceExplanation.map((item) => ({ ...item })), - matchRuleVersion: MATCH_RULE_VERSION, - outcome, - }; + return candidateCase; }, ); -const requireOpenCandidateCase = Effect.fn( - 'PartyMatchingPersistenceService.requireOpenCandidateCase', -)(function* requireOpenCase( - transaction: Pick, +type TransitionDuplicateCandidateCaseOperation = ( + transaction: Pick, input: { - readonly tenantId: string; + readonly actionInvocationId: string; readonly candidateCaseId: string; readonly expectedRevision: number; + readonly outcome: 'NEEDS_EVIDENCE' | 'DISMISSED_AS_NON_SUBJECT' | 'CONFIRMED_DUPLICATE_PARTIES'; + readonly reason: string; + readonly tenantId: string; }, +) => Effect.Effect; + +export const transitionDuplicateCandidateCase: TransitionDuplicateCandidateCaseOperation = Effect.fn( + 'PartyMatchingPersistenceService.transitionDuplicateCandidateCase', +)(function* transitionCase( + transaction: Parameters[0], + input: Parameters[1], ) { - const [candidateCase] = yield* transaction - .select() - .from(duplicateCandidateCases) + yield* lockTenantIdentityWrites(transaction, input.tenantId); + const candidateCase = yield* requireOpenCandidateCase(transaction, input); + const lifecycleState = resolvedCaseLifecycle(input.outcome); + const now = yield* DateTime.nowAsDate; + yield* transaction + .update(duplicateCandidateCases) + .set({ + lifecycleState, + resolutionActionInvocationId: input.actionInvocationId, + resolutionOutcome: input.outcome, + resolutionReason: input.reason, + resolvedAt: input.outcome === 'NEEDS_EVIDENCE' ? null : now, + revision: candidateCase.revision + 1, + updatedAt: now, + }) .where( and( eq(duplicateCandidateCases.tenantId, input.tenantId), eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), ), ) - .limit(1) - .for('update') .pipe(Effect.mapError(unavailable)); - if ( - candidateCase === undefined || - candidateCase.revision !== input.expectedRevision || - !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) - ) { - return yield* new DuplicateCandidateConflict({ - code: 'duplicate_candidate_conflict', - reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', - }); - } - return candidateCase; + return { + caseRef: makeDuplicateCandidateCaseRef(input.tenantId, input.candidateCaseId), + decisionRef: null, + lifecycleState, + outcome: input.outcome, + partyRef: null, + } as const; }); -type TransitionDuplicateCandidateCaseOperation = ( - transaction: Pick, - input: { - readonly actionInvocationId: string; - readonly candidateCaseId: string; - readonly expectedRevision: number; - readonly outcome: 'NEEDS_EVIDENCE' | 'DISMISSED_AS_NON_SUBJECT' | 'CONFIRMED_DUPLICATE_PARTIES'; - readonly reason: string; - readonly tenantId: string; - }, -) => Effect.Effect< - DuplicateCaseResolutionResult, - DuplicateCandidateConflict | PartyPersistenceUnavailableError ->; - -export const transitionDuplicateCandidateCase: TransitionDuplicateCandidateCaseOperation = - Effect.fn('PartyMatchingPersistenceService.transitionDuplicateCandidateCase')( - function* transitionCase( - transaction: Parameters[0], - input: Parameters[1], - ) { - yield* lockTenantIdentityWrites(transaction, input.tenantId); - const candidateCase = yield* requireOpenCandidateCase(transaction, input); - const lifecycleState = resolvedCaseLifecycle(input.outcome); - const now = yield* DateTime.nowAsDate; - yield* transaction - .update(duplicateCandidateCases) - .set({ - lifecycleState, - resolutionActionInvocationId: input.actionInvocationId, - resolutionOutcome: input.outcome, - resolutionReason: input.reason, - resolvedAt: input.outcome === 'NEEDS_EVIDENCE' ? null : now, - revision: candidateCase.revision + 1, - updatedAt: now, - }) - .where( - and( - eq(duplicateCandidateCases.tenantId, input.tenantId), - eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), - ), - ) - .pipe(Effect.mapError(unavailable)); - return { - caseRef: makeDuplicateCandidateCaseRef(input.tenantId, input.candidateCaseId), - decisionRef: null, - lifecycleState, - outcome: input.outcome, - partyRef: null, - } as const; - }, - ); - type ResolveDuplicateCandidateMatchOperation = ( transaction: Pick, input: { @@ -1078,8 +976,7 @@ export const resolveDuplicateCandidateMatch: ResolveDuplicateCandidateMatchOpera if (candidateCase.candidateSnapshot.intent === 'UNARCHIVE') { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', - reason: - 'Unarchive review must preserve the existing Party; Candidate matching cannot reassign its facts', + reason: 'Unarchive review must preserve the existing Party; Candidate matching cannot reassign its facts', }); } if ( @@ -1101,11 +998,7 @@ export const resolveDuplicateCandidateMatch: ResolveDuplicateCandidateMatchOpera }), ), ); - const selected = yield* requireCanonicalPartyWriteTarget( - transaction, - input.tenantId, - input.selectedPartyId, - ); + const selected = yield* requireCanonicalPartyWriteTarget(transaction, input.tenantId, input.selectedPartyId); const selectedPartyId = selected.canonicalPartyId; const [selectedParty] = yield* transaction .select() @@ -1164,11 +1057,7 @@ export const resolveDuplicateCandidateMatch: ResolveDuplicateCandidateMatchOpera }).pipe( Effect.tap((record) => Effect.sync(() => - collectNewIdentifierAcceptance( - addedOfficialIdentifierIds, - record, - input.actionInvocationId, - ), + collectNewIdentifierAcceptance(addedOfficialIdentifierIds, record, input.actionInvocationId), ), ), ), @@ -1243,8 +1132,7 @@ export const resolveDuplicateCandidateCreate: ResolveDuplicateCandidateCreateOpe if (candidateCase.candidateSnapshot.intent === 'UNARCHIVE') { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', - reason: - 'Unarchive review must preserve the existing Party; Candidate creation cannot replace it', + reason: 'Unarchive review must preserve the existing Party; Candidate creation cannot replace it', }); } if ( @@ -1302,72 +1190,66 @@ export const resolveDuplicateCandidateCreate: ResolveDuplicateCandidateCreateOpe } as const; }); -export const previewPartyMatch = Effect.fn('PartyMatchingPersistenceService.previewPartyMatch')( - function* preview( - transaction: Pick, - tenantId: string, - candidate: PartyCandidate, - ) { - const now = yield* DateTime.nowAsDate; - const strong = qualifyingCandidateClaims(candidate); - const resolvedPartyIds = yield* Effect.forEach( - strong, - (claim) => - transaction - .select({ partyId: partyIdentifierClaims.partyId }) - .from(partyIdentifierClaims) - .where( - and( - eq(partyIdentifierClaims.tenantId, tenantId), - eq(partyIdentifierClaims.identifierTypeKey, claim.identifierType), - eq(partyIdentifierClaims.namespace, claim.namespace), - eq(partyIdentifierClaims.normalizedValue, claim.normalizedValue), - ), - ) - .limit(1) - .pipe( - Effect.mapError(unavailable), - Effect.map(([row]) => row?.partyId), +export const previewPartyMatch = Effect.fn('PartyMatchingPersistenceService.previewPartyMatch')(function* preview( + transaction: Pick, + tenantId: string, + candidate: PartyCandidate, +) { + const now = yield* DateTime.nowAsDate; + const strong = qualifyingCandidateClaims(candidate); + const resolvedPartyIds = yield* Effect.forEach( + strong, + (claim) => + transaction + .select({ partyId: partyIdentifierClaims.partyId }) + .from(partyIdentifierClaims) + .where( + and( + eq(partyIdentifierClaims.tenantId, tenantId), + eq(partyIdentifierClaims.identifierTypeKey, claim.identifierType), + eq(partyIdentifierClaims.namespace, claim.namespace), + eq(partyIdentifierClaims.normalizedValue, claim.normalizedValue), ), - { concurrency: 1 }, - ); - const partyIds = resolvedPartyIds.filter((partyId) => partyId !== undefined); - const canonicalIds = yield* Effect.forEach( - partyIds, - (partyId) => canonicalPartyId(transaction, tenantId, partyId), - { concurrency: 1 }, - ); - const strongIds = [...new Set(canonicalIds)].toSorted(); - const ids = - strongIds.length > 0 - ? strongIds - : yield* findWeakCandidatePartyIds(transaction, tenantId, candidate, now); - let outcome = initialMatchOutcome(ids, strongIds); - if (outcome === 'MATCHED' && ids[0] !== undefined) { - const party = yield* findPartyRecord(transaction, tenantId, ids[0]); - if (Schema.is(PartyNotFoundSchema)(party)) { - return yield* unavailable(); - } - if ( - Option.isSome(party.value.archivedAt) || - (party.value.partyType !== 'UNRESOLVED' && - candidate.partyType !== 'UNRESOLVED' && - party.value.partyType !== candidate.partyType) - ) { - outcome = 'AMBIGUOUS'; - } + ) + .limit(1) + .pipe( + Effect.mapError(unavailable), + Effect.map(([row]) => row?.partyId), + ), + { concurrency: 1 }, + ); + const partyIds = resolvedPartyIds.filter((partyId) => partyId !== undefined); + const canonicalIds = yield* Effect.forEach(partyIds, (partyId) => canonicalPartyId(transaction, tenantId, partyId), { + concurrency: 1, + }); + const strongIds = [...new Set(canonicalIds)].toSorted(); + const ids = + strongIds.length > 0 ? strongIds : yield* findWeakCandidatePartyIds(transaction, tenantId, candidate, now); + let outcome = initialMatchOutcome(ids, strongIds); + if (outcome === 'MATCHED' && ids[0] !== undefined) { + const party = yield* findPartyRecord(transaction, tenantId, ids[0]); + if (Schema.is(PartyNotFoundSchema)(party)) { + return yield* unavailable(); } - return { - candidateParties: ids.map((id) => makePartyRef(tenantId, id)), - evidenceExplanation: ids.map((partyId) => ({ - kind: strongIds.length === 0 ? ('WEAK_EVIDENCE' as const) : ('EXACT_CLAIM' as const), - partyRef: makePartyRef(tenantId, partyId), - })), - matchRuleVersion: MATCH_RULE_VERSION, - outcome, - }; - }, -); + if ( + Option.isSome(party.value.archivedAt) || + (party.value.partyType !== 'UNRESOLVED' && + candidate.partyType !== 'UNRESOLVED' && + party.value.partyType !== candidate.partyType) + ) { + outcome = 'AMBIGUOUS'; + } + } + return { + candidateParties: ids.map((id) => makePartyRef(tenantId, id)), + evidenceExplanation: ids.map((partyId) => ({ + kind: strongIds.length === 0 ? ('WEAK_EVIDENCE' as const) : ('EXACT_CLAIM' as const), + partyRef: makePartyRef(tenantId, partyId), + })), + matchRuleVersion: MATCH_RULE_VERSION, + outcome, + }; +}); export const findMatchDecision = ( transaction: Pick, @@ -1395,9 +1277,7 @@ export const findMatchDecision = ( _tag: 'found', value: { caseRef: - row.candidateCaseId === null - ? null - : makeDuplicateCandidateCaseRef(tenantId, row.candidateCaseId), + row.candidateCaseId === null ? null : makeDuplicateCandidateCaseRef(tenantId, row.candidateCaseId), // SAFETY: Database CHECK constrains committed Create results. committedCreateOutcome: row.committedCreateOutcome as | 'CREATED' @@ -1407,7 +1287,9 @@ export const findMatchDecision = ( decidedAt: row.decidedAt.toISOString(), decisionRef: makePartyMatchDecisionRef(tenantId, row.matchDecisionId), evidenceEvaluation: row.evidenceEvaluation, - evidenceExplanation: row.evidenceExplanation.map((item) => ({ ...item })), + evidenceExplanation: row.evidenceExplanation.map((item) => ({ + ...item, + })), matchRuleVersion: row.matchRuleVersion, // SAFETY: Database CHECKs constrain operation and committed Create result. operation: row.operation as @@ -1425,67 +1307,59 @@ export const findMatchDecision = ( ), ); -export const findDuplicateCandidateCase = Effect.fn( - 'PartyMatchingPersistenceService.findDuplicateCandidateCase', -)(function* findCase( - transaction: Pick, - tenantId: string, - caseId: string, -) { - const [record] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.candidateCaseId, caseId), - ), - ) - .limit(1); - if (record === undefined) { - return { _tag: 'not_found' } as const; - } - const candidates = yield* transaction - .select({ partyId: duplicateCandidateCaseParties.partyId }) - .from(duplicateCandidateCaseParties) - .where( - and( - eq(duplicateCandidateCaseParties.tenantId, tenantId), - eq(duplicateCandidateCaseParties.candidateCaseId, caseId), - ), - ); - return { - _tag: 'found', - value: { - candidate: { - ...candidateDisplayName(record.candidateSnapshot.names), - evidenceRefs: record.candidateSnapshot.evidenceArtifactRefs ?? [], - officialIdentifiers: (record.candidateSnapshot.officialIdentifiers ?? []).map( - (identifier) => ({ +export const findDuplicateCandidateCase = Effect.fn('PartyMatchingPersistenceService.findDuplicateCandidateCase')( + function* findCase(transaction: Pick, tenantId: string, caseId: string) { + const [record] = yield* transaction + .select() + .from(duplicateCandidateCases) + .where(and(eq(duplicateCandidateCases.tenantId, tenantId), eq(duplicateCandidateCases.candidateCaseId, caseId))) + .limit(1); + if (record === undefined) { + return { _tag: 'not_found' } as const; + } + const candidates = yield* transaction + .select({ partyId: duplicateCandidateCaseParties.partyId }) + .from(duplicateCandidateCaseParties) + .where( + and( + eq(duplicateCandidateCaseParties.tenantId, tenantId), + eq(duplicateCandidateCaseParties.candidateCaseId, caseId), + ), + ); + return { + _tag: 'found', + value: { + candidate: { + ...candidateDisplayName(record.candidateSnapshot.names), + evidenceRefs: record.candidateSnapshot.evidenceArtifactRefs ?? [], + officialIdentifiers: (record.candidateSnapshot.officialIdentifiers ?? []).map((identifier) => ({ identifierType: identifier.identifierTypeKey, value: identifier.normalizedValue, verification: identifier.verificationState, - }), - ), - partyType: record.candidateSnapshot.partyType ?? 'UNRESOLVED', - provenance: record.candidateSnapshot.provenance, - subjectEvidence: record.candidateSnapshot.subjectEvidence ?? [], - validFrom: record.candidateSnapshot.validFrom, + })), + partyType: record.candidateSnapshot.partyType ?? 'UNRESOLVED', + provenance: record.candidateSnapshot.provenance, + subjectEvidence: record.candidateSnapshot.subjectEvidence ?? [], + validFrom: record.candidateSnapshot.validFrom, + }, + candidateParties: candidates.map(({ partyId }) => makePartyRef(tenantId, partyId)), + caseRef: makeDuplicateCandidateCaseRef(tenantId, caseId), + evaluatedEvidence: record.evaluatedEvidence.map((evidence) => ({ + ...evidence, + })), + // SAFETY: The owner-local case lifecycle CHECK admits exactly these states. + lifecycleState: record.lifecycleState as 'OPEN' | 'NEEDS_EVIDENCE' | 'RESOLVED' | 'DISMISSED', + matchRuleVersion: record.matchRuleVersion, + priorCaseRef: + record.priorCandidateCaseId === null + ? null + : makeDuplicateCandidateCaseRef(tenantId, record.priorCandidateCaseId), + resolutionOutcome: record.resolutionOutcome, + resolutionReason: record.resolutionReason, + resolvedAt: record.resolvedAt?.toISOString() ?? null, + revision: record.revision, }, - candidateParties: candidates.map(({ partyId }) => makePartyRef(tenantId, partyId)), - caseRef: makeDuplicateCandidateCaseRef(tenantId, caseId), - evaluatedEvidence: record.evaluatedEvidence.map((evidence) => ({ ...evidence })), - // SAFETY: The owner-local case lifecycle CHECK admits exactly these states. - lifecycleState: record.lifecycleState as 'OPEN' | 'NEEDS_EVIDENCE' | 'RESOLVED' | 'DISMISSED', - matchRuleVersion: record.matchRuleVersion, - priorCaseRef: - record.priorCandidateCaseId === null - ? null - : makeDuplicateCandidateCaseRef(tenantId, record.priorCandidateCaseId), - resolutionOutcome: record.resolutionOutcome, - resolutionReason: record.resolutionReason, - resolvedAt: record.resolvedAt?.toISOString() ?? null, - revision: record.revision, - }, - } as const; -}, Effect.mapError(unavailable)); + } as const; + }, + Effect.mapError(unavailable), +); diff --git a/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts b/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts index 489c33019..1bab0da6a 100644 --- a/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts @@ -2,6 +2,7 @@ /* eslint-disable anti-slop-effect/no-service-constructor-imports -- make*Ref helpers construct plain ResourceRef values, not Effect services. expires: 2026-12-31. */ import { and, eq } from 'drizzle-orm'; import { DateTime, Effect, Match, Schema } from 'effect'; + import type { AresAppliedEvidence } from '../../shared/domain/ares-application.ts'; import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; import type { @@ -11,18 +12,12 @@ import type { } from '../../shared/domain/identifier-contracts.ts'; import { qualifiesForExclusiveClaim } from '../../shared/domain/identifier-contracts.ts'; import type { PartyType } from '../../shared/domain/identity-contracts.ts'; -import { - PartyPersistenceUnavailable, - makePartyRef, -} from '../../shared/domain/identity-contracts.ts'; +import { PartyPersistenceUnavailable, makePartyRef } from '../../shared/domain/identity-contracts.ts'; import { makePartyOfficialIdentifierRef } from '../../shared/resources/party-official-identifier.ts'; import { parties, partyIdentifierClaims, partyOfficialIdentifiers } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; import { requireCanonicalPartyWriteTarget } from '../merge/party-alias-resolution.service.ts'; -import { - lockAndResolveClaims, - lockTenantIdentityWrites, -} from './party-identifier-claim.service.ts'; +import { lockAndResolveClaims, lockTenantIdentityWrites } from './party-identifier-claim.service.ts'; const unavailable = (cause?: unknown) => { const error = new PartyPersistenceUnavailable({ @@ -33,8 +28,7 @@ const unavailable = (cause?: unknown) => { return error; }; -const instantAsDate = (instant: string | DateTime.Utc): Date => - DateTime.toDateUtc(DateTime.makeUnsafe(instant)); +const instantAsDate = (instant: string | DateTime.Utc): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); export const PARTY_EXACT_CLAIM_RULE_VERSION = 'party-exact-claims.v1'; @@ -45,11 +39,7 @@ const endedOfficialIdentifierTransition = { export const lockOfficialIdentifierPartyRecord = Effect.fn( 'PartyOfficialIdentifierPersistenceService.lockOfficialIdentifierPartyRecord', -)(function* lockIdentifierParty( - transaction: Pick, - tenantId: string, - partyId: string, -) { +)(function* lockIdentifierParty(transaction: Pick, tenantId: string, partyId: string) { yield* lockTenantIdentityWrites(transaction, tenantId); const [party] = yield* transaction .select() @@ -62,59 +52,53 @@ export const lockOfficialIdentifierPartyRecord = Effect.fn( return { _tag: 'not_found' } as const; } yield* requireCanonicalPartyWriteTarget(transaction, tenantId, partyId); - return party.archivedAt === null - ? ({ _tag: 'found', value: party } as const) - : ({ _tag: 'conflict' } as const); + return party.archivedAt === null ? ({ _tag: 'found', value: party } as const) : ({ _tag: 'conflict' } as const); }); -const lockIdentifierWriteTarget = Effect.fn( - 'PartyOfficialIdentifierPersistenceService.lockIdentifierWriteTarget', -)(function* lockTarget( - transaction: Pick, - tenantId: string, - officialIdentifierId: string, -) { - yield* lockTenantIdentityWrites(transaction, tenantId); - // Resolve the owner without a row lock, then always lock Party before its assertion. - const [target] = yield* transaction - .select({ partyId: partyOfficialIdentifiers.partyId }) - .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.officialIdentifierId, officialIdentifierId), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (target === undefined) { - return { _tag: 'not_found' } as const; - } - const party = yield* lockOfficialIdentifierPartyRecord(transaction, tenantId, target.partyId); - const matchedParty = Match.value(party).pipe( - Match.tag('found', (result) => ({ matched: true, result }) as const), - Match.tag('conflict', 'not_found', (result) => ({ matched: false, result }) as const), - Match.exhaustive, - ); - if (!matchedParty.matched) { - return matchedParty.result; - } - const [current] = yield* transaction - .select() - .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.officialIdentifierId, officialIdentifierId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - return current === undefined - ? ({ _tag: 'not_found' } as const) - : ({ _tag: 'found', current, party: matchedParty.result.value } as const); -}); +const lockIdentifierWriteTarget = Effect.fn('PartyOfficialIdentifierPersistenceService.lockIdentifierWriteTarget')( + function* lockTarget(transaction: Pick, tenantId: string, officialIdentifierId: string) { + yield* lockTenantIdentityWrites(transaction, tenantId); + // Resolve the owner without a row lock, then always lock Party before its assertion. + const [target] = yield* transaction + .select({ partyId: partyOfficialIdentifiers.partyId }) + .from(partyOfficialIdentifiers) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.officialIdentifierId, officialIdentifierId), + ), + ) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (target === undefined) { + return { _tag: 'not_found' } as const; + } + const party = yield* lockOfficialIdentifierPartyRecord(transaction, tenantId, target.partyId); + const matchedParty = Match.value(party).pipe( + Match.tag('found', (result) => ({ matched: true, result }) as const), + Match.tag('conflict', 'not_found', (result) => ({ matched: false, result }) as const), + Match.exhaustive, + ); + if (!matchedParty.matched) { + return matchedParty.result; + } + const [current] = yield* transaction + .select() + .from(partyOfficialIdentifiers) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.officialIdentifierId, officialIdentifierId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + return current === undefined + ? ({ _tag: 'not_found' } as const) + : ({ _tag: 'found', current, party: matchedParty.result.value } as const); + }, +); const matchIdentifierWriteTarget = ( transaction: Pick, @@ -140,34 +124,37 @@ const verificationTargetChanged = ( current.validTo !== null || current.verificationState !== expectedVerification; -const resolveVerificationClaim = Effect.fn( - 'PartyOfficialIdentifierPersistenceService.resolveVerificationClaim', -)(function* resolveVerificationClaim( - transaction: Pick, - tenantId: string, - candidate: NormalizedOfficialIdentifier, - current: typeof partyOfficialIdentifiers.$inferSelect, - partyType: PartyType, - matchRuleVersion: string, -) { - const claimEligible = qualifiesForExclusiveClaim(candidate, partyType, matchRuleVersion); - const previouslyClaimEligible = qualifiesForExclusiveClaim( - // SAFETY: the database CHECK constrains verification to this contract. - { ...candidate, verification: current.verificationState as IdentifierVerification }, - partyType, - matchRuleVersion, - ); - if (!claimEligible && !previouslyClaimEligible) { - return { claimEligible, claimOwner: undefined, conflict: false }; - } - const [claim] = yield* lockAndResolveClaims(transaction, tenantId, [candidate]); - const claimOwner = claim?.partyId; - return { - claimEligible, - claimOwner, - conflict: claimEligible && claimOwner !== undefined && claimOwner !== current.partyId, - }; -}); +const resolveVerificationClaim = Effect.fn('PartyOfficialIdentifierPersistenceService.resolveVerificationClaim')( + function* resolveVerificationClaim( + transaction: Pick, + tenantId: string, + candidate: NormalizedOfficialIdentifier, + current: typeof partyOfficialIdentifiers.$inferSelect, + partyType: PartyType, + matchRuleVersion: string, + ) { + const claimEligible = qualifiesForExclusiveClaim(candidate, partyType, matchRuleVersion); + const previouslyClaimEligible = qualifiesForExclusiveClaim( + // SAFETY: the database CHECK constrains verification to this contract. + { + ...candidate, + verification: current.verificationState as IdentifierVerification, + }, + partyType, + matchRuleVersion, + ); + if (!claimEligible && !previouslyClaimEligible) { + return { claimEligible, claimOwner: undefined, conflict: false }; + } + const [claim] = yield* lockAndResolveClaims(transaction, tenantId, [candidate]); + const claimOwner = claim?.partyId; + return { + claimEligible, + claimOwner, + conflict: claimEligible && claimOwner !== undefined && claimOwner !== current.partyId, + }; + }, +); export const addOfficialIdentifierRecord = Effect.fn( 'PartyOfficialIdentifierPersistenceService.addOfficialIdentifierRecord', @@ -261,11 +248,7 @@ export const endOfficialIdentifierRecord = Effect.fn( officialIdentifierId: string, validTo: string, ) { - const matchedTarget = yield* matchIdentifierWriteTarget( - transaction, - tenantId, - officialIdentifierId, - ); + const matchedTarget = yield* matchIdentifierWriteTarget(transaction, tenantId, officialIdentifierId); if (!matchedTarget.matched) { return matchedTarget.result; } @@ -320,9 +303,7 @@ export const endOfficialIdentifierRecord = Effect.fn( ), ) .pipe(Effect.mapError(unavailable)); - return updated === undefined - ? yield* unavailable() - : ({ _tag: 'found', previous: current, value: updated } as const); + return updated === undefined ? yield* unavailable() : ({ _tag: 'found', previous: current, value: updated } as const); }); export const updateOfficialIdentifierVerificationRecord = Effect.fn( @@ -338,11 +319,7 @@ export const updateOfficialIdentifierVerificationRecord = Effect.fn( readonly verification: IdentifierVerification; }, ) { - const matchedTarget = yield* matchIdentifierWriteTarget( - transaction, - tenantId, - officialIdentifierId, - ); + const matchedTarget = yield* matchIdentifierWriteTarget(transaction, tenantId, officialIdentifierId); if (!matchedTarget.matched) { return matchedTarget.result; } @@ -419,9 +396,7 @@ export const updateOfficialIdentifierVerificationRecord = Effect.fn( return { _tag: 'found', previous: current, value: updated } as const; }); -const identifierDto = ( - row: typeof partyOfficialIdentifiers.$inferSelect, -): OfficialIdentifierAssertion => ({ +const identifierDto = (row: typeof partyOfficialIdentifiers.$inferSelect): OfficialIdentifierAssertion => ({ externalEvidence: row.externalEvidence, // SAFETY: the database CHECK permits only supported identifier types. identifierType: row.identifierTypeKey as 'ICO' | 'CZ_DIC', @@ -455,9 +430,7 @@ export const findOfficialIdentifierRecord = ( .pipe( Effect.mapError(unavailable), Effect.map(([row]) => - row === undefined - ? ({ _tag: 'not_found' } as const) - : ({ _tag: 'found', value: identifierDto(row) } as const), + row === undefined ? ({ _tag: 'not_found' } as const) : ({ _tag: 'found', value: identifierDto(row) } as const), ), ); export const listOfficialIdentifierHistory = ( @@ -468,12 +441,7 @@ export const listOfficialIdentifierHistory = ( transaction .select() .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.partyId, partyId), - ), - ) + .where(and(eq(partyOfficialIdentifiers.tenantId, tenantId), eq(partyOfficialIdentifiers.partyId, partyId))) .pipe( Effect.mapError(unavailable), Effect.map((rows) => rows.map(identifierDto)), diff --git a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts index bc2463480..41b747542 100644 --- a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts @@ -2,6 +2,7 @@ import { findPostgresFailure } from '@app/core-runtime'; import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm'; import { DateTime, Effect, Match, Option, Result, Schema } from 'effect'; + import type { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; import type { CreatePartyRelationshipPayload, @@ -34,10 +35,7 @@ import { import type { PartyRecord, PartyRelationshipRecord } from '../db/schema.ts'; import { parties, partyRelationships } from '../db/schema.ts'; import type { PartyTransaction } from '../db/types.ts'; -import { - requireCanonicalPartyWriteTarget, - resolvePartyAlias, -} from '../merge/party-alias-resolution.service.ts'; +import { requireCanonicalPartyWriteTarget, resolvePartyAlias } from '../merge/party-alias-resolution.service.ts'; type RelationshipScopedTransaction = Pick; const RELATIONSHIP_NOT_FOUND_REASON = 'The requested Party Relationship does not exist'; @@ -54,7 +52,10 @@ export type RelationshipMutationError = | PartyRelationshipRevisionConflict; export type RelationshipCreateResult = - | Readonly<{ readonly outcome: 'CREATED'; readonly relationship: PartyRelationshipDetail }> + | Readonly<{ + readonly outcome: 'CREATED'; + readonly relationship: PartyRelationshipDetail; + }> | Readonly<{ readonly outcome: 'REUSED_EXISTING'; readonly relationship: PartyRelationshipDetail; @@ -66,7 +67,10 @@ export type RelationshipChangeResult = readonly previous: PartyRelationshipDetail; readonly relationship: PartyRelationshipDetail; }> - | Readonly<{ readonly outcome: 'UNCHANGED'; readonly relationship: PartyRelationshipDetail }>; + | Readonly<{ + readonly outcome: 'UNCHANGED'; + readonly relationship: PartyRelationshipDetail; + }>; const unavailable = (cause?: unknown) => { const error = new PartyRelationshipPersistenceUnavailable({ @@ -94,11 +98,7 @@ const mutationFailure = (error: Failure): RelationshipMutationError => }) : unavailable(error); -const canonicalWriteTarget = ( - transaction: RelationshipScopedTransaction, - tenantId: string, - partyId: string, -) => +const canonicalWriteTarget = (transaction: RelationshipScopedTransaction, tenantId: string, partyId: string) => requireCanonicalPartyWriteTarget(transaction, tenantId, partyId).pipe( Effect.mapError((failure) => Match.value(failure).pipe( @@ -108,11 +108,7 @@ const canonicalWriteTarget = ( ), ); -const canonicalPartyId = ( - transaction: RelationshipScopedTransaction, - tenantId: string, - partyId: string, -) => +const canonicalPartyId = (transaction: RelationshipScopedTransaction, tenantId: string, partyId: string) => resolvePartyAlias(transaction, tenantId, partyId).pipe( Effect.mapError((failure) => unavailable(failure)), Effect.map((resolution) => resolution.canonicalPartyId), @@ -126,9 +122,7 @@ const sameOptionalInstant = ( left: Option.Option, right: Option.Option, ): boolean => - Option.isNone(left) - ? Option.isNone(right) - : Option.isSome(right) && DateTime.Equivalence(left.value, right.value); + Option.isNone(left) ? Option.isNone(right) : Option.isSome(right) && DateTime.Equivalence(left.value, right.value); const decodeAssertionState = (value: string) => Result.getOrThrow(Schema.decodeUnknownResult(PartyRelationshipAssertionStateSchema)(value)); const decodeRelationshipPartyType = (value: string) => @@ -179,10 +173,7 @@ const storedDetail = ( relationshipRef: partyRelationshipRef(row.tenantId, row.relationshipId), relationshipType: 'CONTACT_PERSON_OF', revision: row.revision, - state: - row.assertionState === 'ACTIVE' - ? classifyRelationshipValidity(validFrom, validTo, iso(now)) - : 'HISTORICAL', + state: row.assertionState === 'ACTIVE' ? classifyRelationshipValidity(validFrom, validTo, iso(now)) : 'HISTORICAL', to: { canonicalPartyRef: toCanonical, requestedAlias: toCanonicalId === row.toPartyId ? Option.none() : Option.some(toStored), @@ -207,8 +198,7 @@ const findEndpoints = ( .for('update') .pipe(Effect.mapError(unavailable)); -const endpointById = (rows: readonly PartyRecord[], partyId: string) => - rows.find((row) => row.partyId === partyId); +const endpointById = (rows: readonly PartyRecord[], partyId: string) => rows.find((row) => row.partyId === partyId); const validateEndpoint = ( row: PartyRecord | undefined, @@ -216,10 +206,7 @@ const validateEndpoint = ( expectedPartyType: 'ORGANIZATION' | 'PERSON', tenantId: string, resourceId: string, -): Effect.Effect< - PartyRecord, - PartyRelationshipEndpointNotFound | PartyRelationshipEndpointTypeMismatch -> => { +): Effect.Effect => { const ref = partyRef(tenantId, resourceId); if (row === undefined || row.archivedAt !== null) { return Effect.fail( @@ -275,20 +262,11 @@ const overlappingRows = ( .for('update') .pipe(Effect.mapError(unavailable)); -const loadLocked = ( - transaction: RelationshipScopedTransaction, - tenantId: string, - relationshipId: string, -) => +const loadLocked = (transaction: RelationshipScopedTransaction, tenantId: string, relationshipId: string) => transaction .select() .from(partyRelationships) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, relationshipId), - ), - ) + .where(and(eq(partyRelationships.tenantId, tenantId), eq(partyRelationships.relationshipId, relationshipId))) .limit(1) .for('update') .pipe(Effect.mapError(unavailable)); @@ -330,7 +308,7 @@ const resolveCreateDecision = (input: { PartyRelationshipOverlapConflict | PartyRelationshipPersistenceUnavailable > => Match.value(input.decision).pipe( - Match.tag('create', () => Effect.succeed(Option.none())), + Match.tag('create', () => Effect.succeedNone), Match.tag('overlap', (decision) => Effect.fail( new PartyRelationshipOverlapConflict({ @@ -341,17 +319,13 @@ const resolveCreateDecision = (input: { ), ), Match.tag('reuse', (decision) => { - const reused = input.overlapping.find( - (row) => row.relationshipId === decision.relationshipId, - ); + const reused = input.overlapping.find((row) => row.relationshipId === decision.relationshipId); return reused === undefined ? Effect.fail(unavailable()) - : Effect.succeed( - Option.some({ - outcome: 'REUSED_EXISTING' as const, - relationship: storedDetail(reused, input.now), - }), - ); + : Effect.succeedSome({ + outcome: 'REUSED_EXISTING' as const, + relationship: storedDetail(reused, input.now), + }); }), Match.exhaustive, ); @@ -368,7 +342,10 @@ const staleRelationshipRevision = (actualRevision: number, expectedRevision: num const invalidRelationshipInterval = (reason: string) => Effect.fail( - new PartyRelationshipInvalidInterval({ code: 'party_relationship_invalid_interval', reason }), + new PartyRelationshipInvalidInterval({ + code: 'party_relationship_invalid_interval', + reason, + }), ); const validateUpdateDecision = ( @@ -376,9 +353,7 @@ const validateUpdateDecision = ( payload: UpdatePartyRelationshipPayload, ): Effect.Effect< void, - | PartyRelationshipCorrectionRequired - | PartyRelationshipInvalidInterval - | PartyRelationshipRevisionConflict + PartyRelationshipCorrectionRequired | PartyRelationshipInvalidInterval | PartyRelationshipRevisionConflict > => Match.value(decision).pipe( Match.tag('update', () => Effect.void), @@ -386,14 +361,10 @@ const validateUpdateDecision = ( staleRelationshipRevision(conflict.actualRevision, payload.expectedRevision), ), Match.tag('invalid_interval', () => - invalidRelationshipInterval( - 'validTo must be later than validFrom for the exclusive [from,to) interval', - ), + invalidRelationshipInterval('validTo must be later than validFrom for the exclusive [from,to) interval'), ), Match.tag('end_required', () => - invalidRelationshipInterval( - 'Use End to establish an immediate or retrospective effective end', - ), + invalidRelationshipInterval('Use End to establish an immediate or retrospective effective end'), ), Match.tag('correction_required', (correction) => Effect.fail( @@ -412,9 +383,7 @@ const validateEndDecision = ( payload: EndPartyRelationshipPayload, ): Effect.Effect< 'CHANGE' | 'UNCHANGED', - | PartyRelationshipCorrectionRequired - | PartyRelationshipInvalidInterval - | PartyRelationshipRevisionConflict + PartyRelationshipCorrectionRequired | PartyRelationshipInvalidInterval | PartyRelationshipRevisionConflict > => Match.value(decision).pipe( Match.tag('attach_end_evidence', () => Effect.succeed('CHANGE' as const)), @@ -424,49 +393,38 @@ const validateEndDecision = ( staleRelationshipRevision(conflict.actualRevision, payload.expectedRevision), ), Match.tag('invalid_interval', () => - invalidRelationshipInterval( - 'The effective end must be later than the relationship validFrom', - ), - ), - Match.tag('update_required', () => - invalidRelationshipInterval('Use Update to change a still-future planned end'), + invalidRelationshipInterval('The effective end must be later than the relationship validFrom'), ), + Match.tag('update_required', () => invalidRelationshipInterval('Use Update to change a still-future planned end')), Match.tag('correction_required', (correction) => Effect.fail( new PartyRelationshipCorrectionRequired({ code: 'party_relationship_correction_required', fact: correction.fact, - reason: - 'Changing historical Party Relationship end evidence requires explicit correction', + reason: 'Changing historical Party Relationship end evidence requires explicit correction', }), ), ), Match.exhaustive, ); -const nextRelationshipValidity = ( - current: PartyRelationshipRecord, - payload: UpdatePartyRelationshipPayload, -) => { - const validFrom = - payload.validFrom === undefined ? current.validFrom : dateFromIso(payload.validFrom); +const nextRelationshipValidity = (current: PartyRelationshipRecord, payload: UpdatePartyRelationshipPayload) => { + const validFrom = payload.validFrom === undefined ? current.validFrom : dateFromIso(payload.validFrom); const requestedValidTo = payload.validTo === undefined ? null - : Option.match(payload.validTo, { onNone: () => null, onSome: dateFromIso }); + : Option.match(payload.validTo, { + onNone: () => null, + onSome: dateFromIso, + }); const validTo = payload.validTo === undefined ? current.validTo : requestedValidTo; return { validFrom, validTo }; }; -const isUnchangedUpdate = ( - current: PartyRelationshipRecord, - payload: UpdatePartyRelationshipPayload, -): boolean => +const isUnchangedUpdate = (current: PartyRelationshipRecord, payload: UpdatePartyRelationshipPayload): boolean => sameOptionalInstant( optionalIso(current.validFrom), - payload.validFrom === undefined - ? optionalIso(current.validFrom) - : Option.some(payload.validFrom), + payload.validFrom === undefined ? optionalIso(current.validFrom) : Option.some(payload.validFrom), ) && sameOptionalInstant( optionalIso(current.validTo), @@ -485,17 +443,13 @@ const ensureUpdateIsNotHistorical = ( new PartyRelationshipCorrectionRequired({ code: 'party_relationship_correction_required', fact: 'validTo', - reason: - 'Historical Party Relationship provenance or validity requires explicit correction', + reason: 'Historical Party Relationship provenance or validity requires explicit correction', }), ) : Effect.void; }; -const ensureNoRelationshipConflict = ( - tenantId: string, - conflict: PartyRelationshipRecord | undefined, -) => +const ensureNoRelationshipConflict = (tenantId: string, conflict: PartyRelationshipRecord | undefined) => conflict === undefined ? Effect.void : Effect.fail( @@ -563,20 +517,8 @@ export const createPartyRelationshipRecord = Effect.fn( if (toEndpoint !== undefined) { yield* canonicalWriteTarget(transaction, tenantId, payload.toPartyRef.resourceId); } - yield* validateEndpoint( - fromEndpoint, - 'from', - 'PERSON', - tenantId, - payload.fromPartyRef.resourceId, - ); - yield* validateEndpoint( - toEndpoint, - 'to', - 'ORGANIZATION', - tenantId, - payload.toPartyRef.resourceId, - ); + yield* validateEndpoint(fromEndpoint, 'from', 'PERSON', tenantId, payload.fromPartyRef.resourceId); + yield* validateEndpoint(toEndpoint, 'to', 'ORGANIZATION', tenantId, payload.toPartyRef.resourceId); const requestedFrom = Option.match(payload.validFrom, { onNone: () => null, onSome: dateFromIso, @@ -636,79 +578,82 @@ export const createPartyRelationshipRecord = Effect.fn( if (created === undefined) { return yield* unavailable(); } - return { outcome: 'CREATED', relationship: storedDetail(created, recordedAt) } as const; -}); - -const loadActiveRelationshipContext = Effect.fn( - 'PartyRelationshipPersistenceService.loadActiveRelationshipContext', -)(function* loadActiveRelationshipContextEffect( - transaction: RelationshipScopedTransaction, - tenantId: string, - payload: Pick, -) { - yield* ensureTrustedTenant(tenantId, payload.relationshipRef.tenantId); - const [current] = yield* loadLocked(transaction, tenantId, payload.relationshipRef.resourceId); - if (current === undefined) { - return yield* new PartyRelationshipNotFound({ - code: 'party_relationship_not_found', - reason: RELATIONSHIP_NOT_FOUND_REASON, - }); - } - if (current.assertionState !== 'ACTIVE') { - return yield* new PartyRelationshipNotFound({ - code: 'party_relationship_not_found', - reason: 'The requested active Party Relationship does not exist', - }); - } - const [fromCanonicalId, toCanonicalId] = yield* resolveCanonicalEndpointIds({ - fromPartyId: current.fromPartyId, - tenantId, - toPartyId: current.toPartyId, - transaction, - }); - const now = yield* DateTime.nowAsDate; - return { current, fromCanonicalId, toCanonicalId, now }; -}); - -const persistRelationshipChange = Effect.fn( - 'PartyRelationshipPersistenceService.persistRelationshipChange', -)(function* persistRelationshipChangeEffect( - transaction: RelationshipScopedTransaction, - tenantId: string, - current: PartyRelationshipRecord, - payload: Pick, - values: Partial, - now: Date, - fromCanonicalId: string, - toCanonicalId: string, -) { - const [updated] = yield* transaction - .update(partyRelationships) - .set(values) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, current.relationshipId), - eq(partyRelationships.revision, current.revision), - ), - ) - .returning() - .pipe(Effect.mapError(mutationFailure)); - if (updated === undefined) { - return yield* new PartyRelationshipRevisionConflict({ - actualRevision: current.revision + 1, - code: 'party_relationship_revision_conflict', - expectedRevision: payload.expectedRevision, - reason: 'The Party Relationship changed concurrently', - }); - } return { - outcome: 'CHANGED', - previous: storedDetail(current, now, fromCanonicalId, toCanonicalId), - relationship: storedDetail(updated, now, fromCanonicalId, toCanonicalId), + outcome: 'CREATED', + relationship: storedDetail(created, recordedAt), } as const; }); +const loadActiveRelationshipContext = Effect.fn('PartyRelationshipPersistenceService.loadActiveRelationshipContext')( + function* loadActiveRelationshipContextEffect( + transaction: RelationshipScopedTransaction, + tenantId: string, + payload: Pick, + ) { + yield* ensureTrustedTenant(tenantId, payload.relationshipRef.tenantId); + const [current] = yield* loadLocked(transaction, tenantId, payload.relationshipRef.resourceId); + if (current === undefined) { + return yield* new PartyRelationshipNotFound({ + code: 'party_relationship_not_found', + reason: RELATIONSHIP_NOT_FOUND_REASON, + }); + } + if (current.assertionState !== 'ACTIVE') { + return yield* new PartyRelationshipNotFound({ + code: 'party_relationship_not_found', + reason: 'The requested active Party Relationship does not exist', + }); + } + const [fromCanonicalId, toCanonicalId] = yield* resolveCanonicalEndpointIds({ + fromPartyId: current.fromPartyId, + tenantId, + toPartyId: current.toPartyId, + transaction, + }); + const now = yield* DateTime.nowAsDate; + return { current, fromCanonicalId, toCanonicalId, now }; + }, +); + +const persistRelationshipChange = Effect.fn('PartyRelationshipPersistenceService.persistRelationshipChange')( + function* persistRelationshipChangeEffect( + transaction: RelationshipScopedTransaction, + tenantId: string, + current: PartyRelationshipRecord, + payload: Pick, + values: Partial, + now: Date, + fromCanonicalId: string, + toCanonicalId: string, + ) { + const [updated] = yield* transaction + .update(partyRelationships) + .set(values) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, current.relationshipId), + eq(partyRelationships.revision, current.revision), + ), + ) + .returning() + .pipe(Effect.mapError(mutationFailure)); + if (updated === undefined) { + return yield* new PartyRelationshipRevisionConflict({ + actualRevision: current.revision + 1, + code: 'party_relationship_revision_conflict', + expectedRevision: payload.expectedRevision, + reason: 'The Party Relationship changed concurrently', + }); + } + return { + outcome: 'CHANGED', + previous: storedDetail(current, now, fromCanonicalId, toCanonicalId), + relationship: storedDetail(updated, now, fromCanonicalId, toCanonicalId), + } as const; + }, +); + export const updatePartyRelationshipRecord = Effect.fn( 'PartyRelationshipPersistenceService.updatePartyRelationshipRecord', )(function* updateRelationship( @@ -737,10 +682,7 @@ export const updatePartyRelationshipRecord = Effect.fn( iso(now), ); yield* validateUpdateDecision(decision, payload); - const { validFrom: nextValidFrom, validTo: nextValidTo } = nextRelationshipValidity( - current, - payload, - ); + const { validFrom: nextValidFrom, validTo: nextValidTo } = nextRelationshipValidity(current, payload); if (isUnchangedUpdate(current, payload)) { return { outcome: 'UNCHANGED', @@ -786,88 +728,79 @@ export const updatePartyRelationshipRecord = Effect.fn( ); }); -export const endPartyRelationshipRecord = Effect.fn( - 'PartyRelationshipPersistenceService.endPartyRelationshipRecord', -)(function* endRelationship( - transaction: RelationshipScopedTransaction, - tenantId: string, - principalId: string, - actionInvocationId: string, - payload: EndPartyRelationshipPayload, -) { - const { current, fromCanonicalId, toCanonicalId, now } = yield* loadActiveRelationshipContext( - transaction, - tenantId, - payload, - ); - const decision = decideRelationshipEnd( - { - endProvenanceMethod: current.endProvenanceMethod, - endProvenanceSource: current.endProvenanceSource, - endReason: current.endReason, - revision: current.revision, - validFrom: optionalIso(current.validFrom), - validTo: optionalIso(current.validTo), - }, - payload, - iso(now), - ); - const outcome = yield* validateEndDecision(decision, payload); - if (outcome === 'UNCHANGED') { - return { - outcome: 'UNCHANGED', - relationship: storedDetail(current, now, fromCanonicalId, toCanonicalId), - } as const; - } - const effectiveAt = dateFromIso(payload.effectiveAt); - return yield* persistRelationshipChange( - transaction, - tenantId, - current, - payload, - { - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: now, - endProvenanceMethod: payload.provenance.method, - endProvenanceSource: payload.provenance.source, - endReason: payload.reason ?? null, - revision: current.revision + 1, - validTo: effectiveAt, - }, - now, - fromCanonicalId, - toCanonicalId, - ); -}); - -export const findPartyRelationshipRecord = Effect.fn( - 'PartyRelationshipPersistenceService.findPartyRelationshipRecord', -)(function* findRelationship( - transaction: RelationshipScopedTransaction, - tenantId: string, - relationshipId: string, -) { - const [row] = yield* transaction - .select() - .from(partyRelationships) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, relationshipId), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); - if (row === undefined) { - return null; - } - const [fromCanonicalId, toCanonicalId] = yield* resolveCanonicalEndpointIds({ - fromPartyId: row.fromPartyId, - tenantId, - toPartyId: row.toPartyId, - transaction, - }); - const now = yield* DateTime.nowAsDate; - return storedDetail(row, now, fromCanonicalId, toCanonicalId); -}); +export const endPartyRelationshipRecord = Effect.fn('PartyRelationshipPersistenceService.endPartyRelationshipRecord')( + function* endRelationship( + transaction: RelationshipScopedTransaction, + tenantId: string, + principalId: string, + actionInvocationId: string, + payload: EndPartyRelationshipPayload, + ) { + const { current, fromCanonicalId, toCanonicalId, now } = yield* loadActiveRelationshipContext( + transaction, + tenantId, + payload, + ); + const decision = decideRelationshipEnd( + { + endProvenanceMethod: current.endProvenanceMethod, + endProvenanceSource: current.endProvenanceSource, + endReason: current.endReason, + revision: current.revision, + validFrom: optionalIso(current.validFrom), + validTo: optionalIso(current.validTo), + }, + payload, + iso(now), + ); + const outcome = yield* validateEndDecision(decision, payload); + if (outcome === 'UNCHANGED') { + return { + outcome: 'UNCHANGED', + relationship: storedDetail(current, now, fromCanonicalId, toCanonicalId), + } as const; + } + const effectiveAt = dateFromIso(payload.effectiveAt); + return yield* persistRelationshipChange( + transaction, + tenantId, + current, + payload, + { + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: now, + endProvenanceMethod: payload.provenance.method, + endProvenanceSource: payload.provenance.source, + endReason: payload.reason ?? null, + revision: current.revision + 1, + validTo: effectiveAt, + }, + now, + fromCanonicalId, + toCanonicalId, + ); + }, +); + +export const findPartyRelationshipRecord = Effect.fn('PartyRelationshipPersistenceService.findPartyRelationshipRecord')( + function* findRelationship(transaction: RelationshipScopedTransaction, tenantId: string, relationshipId: string) { + const [row] = yield* transaction + .select() + .from(partyRelationships) + .where(and(eq(partyRelationships.tenantId, tenantId), eq(partyRelationships.relationshipId, relationshipId))) + .limit(1) + .pipe(Effect.mapError(unavailable)); + if (row === undefined) { + return null; + } + const [fromCanonicalId, toCanonicalId] = yield* resolveCanonicalEndpointIds({ + fromPartyId: row.fromPartyId, + tenantId, + toPartyId: row.toPartyId, + transaction, + }); + const now = yield* DateTime.nowAsDate; + return storedDetail(row, now, fromCanonicalId, toCanonicalId); + }, +); diff --git a/app/verticals/party-registry/src/services/party-search-projection-source.service.ts b/app/verticals/party-registry/src/services/party-search-projection-source.service.ts index b69d4ae12..3cc29d0be 100644 --- a/app/verticals/party-registry/src/services/party-search-projection-source.service.ts +++ b/app/verticals/party-registry/src/services/party-search-projection-source.service.ts @@ -9,9 +9,10 @@ import { CoreSearchWorkerSnapshot } from '@app/core-runtime'; import type { SQL } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm'; import { Context, Effect, Layer, Option } from 'effect'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; + // eslint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Pure ResourceRef value constructor, not an Effect service constructor. import { makePartyRef } from '../../shared/domain/identity-contracts.ts'; +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import type { CounterpartyRef } from '../../shared/resources/counterparty.ts'; import { counterparties, @@ -35,9 +36,7 @@ import type { export class PartySearchProjectionSource extends Context.Service< PartySearchProjectionSource, { readonly load: PartySearchProjectionSourceService['load'] } ->()( - '@app/party-registry/services/party-search-projection-source.service/PartySearchProjectionSource', -) {} +>()('@app/party-registry/services/party-search-projection-source.service/PartySearchProjectionSource') {} const unavailable = (cause?: unknown) => { const error = new PartySearchProjectionUnavailable({ @@ -56,13 +55,7 @@ const counterpartyRef = (tenantId: string, resourceId: string): CounterpartyRef resourceType: 'party.registry.counterparty', tenantId, }); -const period = ({ - validFrom, - validTo, -}: { - readonly validFrom: Date; - readonly validTo: Date | null; -}) => +const period = ({ validFrom, validTo }: { readonly validFrom: Date; readonly validTo: Date | null }) => validTo === null ? { validFrom: validFrom.toISOString() } : { @@ -170,86 +163,87 @@ interface PartySearchSourceCounterpartyRecord { readonly roles: PartySearchSourceCounterparty['rolePeriods']; } -const readScopedCounterparties = Effect.fn( - 'PartySearchProjectionSourceService.readScopedCounterparties', -)(function* readScopedCounterpartyRows( - executor: CoreSearchSnapshotReadExecutor, - snapshot: CoreSearchWorkerSnapshotView, - target: PartySearchProjectionTarget, - familyIds: readonly string[], - legalEntityId: string, -) { - let targetPredicate: SQL | undefined; - if ('counterpartyId' in target) { - targetPredicate = eq(counterparties.counterpartyId, target.counterpartyId); - } else if ('partyId' in target) { - targetPredicate = inArray(counterparties.partyId, familyIds); - } - const rows = yield* executor - .select({ - counterpartyId: counterparties.counterpartyId, - legalEntityId: counterparties.legalEntityId, - partyId: counterparties.partyId, - tenantId: counterparties.tenantId, - }) - .from(counterparties) - .where( - and( - eq(counterparties.tenantId, snapshot.tenantId), - eq(counterparties.legalEntityId, legalEntityId), - targetPredicate, - ), - ); - const selected = rows.filter((row) => { - if (row.tenantId !== snapshot.tenantId || row.legalEntityId !== legalEntityId) { - return false; - } +const readScopedCounterparties = Effect.fn('PartySearchProjectionSourceService.readScopedCounterparties')( + function* readScopedCounterpartyRows( + executor: CoreSearchSnapshotReadExecutor, + snapshot: CoreSearchWorkerSnapshotView, + target: PartySearchProjectionTarget, + familyIds: readonly string[], + legalEntityId: string, + ) { + let targetPredicate: SQL | undefined; if ('counterpartyId' in target) { - return row.counterpartyId === target.counterpartyId; + targetPredicate = eq(counterparties.counterpartyId, target.counterpartyId); + } else if ('partyId' in target) { + targetPredicate = inArray(counterparties.partyId, familyIds); } - if ('partyId' in target) { - return familyIds.includes(row.partyId); + const rows = yield* executor + .select({ + counterpartyId: counterparties.counterpartyId, + legalEntityId: counterparties.legalEntityId, + partyId: counterparties.partyId, + tenantId: counterparties.tenantId, + }) + .from(counterparties) + .where( + and( + eq(counterparties.tenantId, snapshot.tenantId), + eq(counterparties.legalEntityId, legalEntityId), + targetPredicate, + ), + ); + const selected = rows.filter((row) => { + if (row.tenantId !== snapshot.tenantId || row.legalEntityId !== legalEntityId) { + return false; + } + if ('counterpartyId' in target) { + return row.counterpartyId === target.counterpartyId; + } + if ('partyId' in target) { + return familyIds.includes(row.partyId); + } + return true; + }); + if (selected.length === 0) { + return []; } - return true; - }); - if (selected.length === 0) { - return []; - } - const roles = yield* executor - .select({ - counterpartyId: counterpartyRolePeriods.counterpartyId, - legalEntityId: counterpartyRolePeriods.legalEntityId, - role: counterpartyRolePeriods.roleType, - state: counterpartyRolePeriods.state, - tenantId: counterpartyRolePeriods.tenantId, - validFrom: counterpartyRolePeriods.validFrom, - validTo: counterpartyRolePeriods.validTo, - }) - .from(counterpartyRolePeriods) - .where( - and( - eq(counterpartyRolePeriods.tenantId, snapshot.tenantId), - eq(counterpartyRolePeriods.legalEntityId, legalEntityId), - inArray( - counterpartyRolePeriods.counterpartyId, - selected.map((row) => row.counterpartyId), + const roles = yield* executor + .select({ + counterpartyId: counterpartyRolePeriods.counterpartyId, + legalEntityId: counterpartyRolePeriods.legalEntityId, + role: counterpartyRolePeriods.roleType, + state: counterpartyRolePeriods.state, + tenantId: counterpartyRolePeriods.tenantId, + validFrom: counterpartyRolePeriods.validFrom, + validTo: counterpartyRolePeriods.validTo, + }) + .from(counterpartyRolePeriods) + .where( + and( + eq(counterpartyRolePeriods.tenantId, snapshot.tenantId), + eq(counterpartyRolePeriods.legalEntityId, legalEntityId), + inArray( + counterpartyRolePeriods.counterpartyId, + selected.map((row) => row.counterpartyId), + ), + eq(counterpartyRolePeriods.state, 'ACTIVE'), ), - eq(counterpartyRolePeriods.state, 'ACTIVE'), + ); + return selected.map((row): PartySearchSourceCounterpartyRecord => ({ + ...row, + roles: roles.flatMap((role): PartySearchSourceCounterparty['rolePeriods'] => + role.counterpartyId === row.counterpartyId && + role.tenantId === snapshot.tenantId && + role.legalEntityId === legalEntityId && + role.state === 'ACTIVE' && + (role.role === 'CUSTOMER' || role.role === 'SUPPLIER') + ? [{ role: role.role, state: role.state, ...period(role) }] + : [], ), - ); - return selected.map((row): PartySearchSourceCounterpartyRecord => ({ - ...row, - roles: roles.flatMap((role): PartySearchSourceCounterparty['rolePeriods'] => - role.counterpartyId === row.counterpartyId && - role.tenantId === snapshot.tenantId && - role.legalEntityId === legalEntityId && - role.state === 'ACTIVE' && - (role.role === 'CUSTOMER' || role.role === 'SUPPLIER') - ? [{ role: role.role, state: role.state, ...period(role) }] - : [], - ), - })); -}, Effect.mapError(unavailable)); + })); + }, + Effect.mapError(unavailable), +); const readCounterparties = Effect.fn('PartySearchProjectionSourceService.readCounterparties')(( snapshot: CoreSearchWorkerSnapshotView, @@ -287,129 +281,128 @@ const wantedCanonicalIds = ( return wanted; }; -const readCanonicalProjection = Effect.fn( - 'PartySearchProjectionSourceService.readCanonicalProjection', -)(function* readCanonicalProjectionSnapshot( - snapshot: CoreSearchWorkerSnapshotView, - context: OutboxWorkerHandlerContext, - target: PartySearchProjectionTarget, -) { - if (snapshot.tenantId !== context.tenantId) { - return yield* unavailable(); - } - const { records, aliases } = yield* snapshot.tenant((executor) => - readIdentities(executor, snapshot.tenantId), - ); - if ( - records.some((row) => row.tenantId !== snapshot.tenantId) || - aliases.some((row) => row.tenantId !== snapshot.tenantId) +const readCanonicalProjection = Effect.fn('PartySearchProjectionSourceService.readCanonicalProjection')( + function* readCanonicalProjectionSnapshot( + snapshot: CoreSearchWorkerSnapshotView, + context: OutboxWorkerHandlerContext, + target: PartySearchProjectionTarget, ) { - return yield* unavailable(); - } - const resolver = makePartyAliasResolutionService({ - findAlias: (_tenantId, id) => - Effect.succeed(Option.fromNullishOr(aliases.find((alias) => alias.aliasPartyId === id))), - partyExists: (_tenantId, id) => Effect.succeed(records.some((row) => row.partyId === id)), - }); - const canonicalIdEntries = yield* Effect.forEach( - records, - (row) => - resolver.resolvePartyAlias(snapshot.tenantId, row.partyId).pipe( - Effect.mapError(unavailable), - Effect.map((resolved) => [row.partyId, resolved.canonicalPartyId] as const), + if (snapshot.tenantId !== context.tenantId) { + return yield* unavailable(); + } + const { records, aliases } = yield* snapshot.tenant((executor) => readIdentities(executor, snapshot.tenantId)); + if ( + records.some((row) => row.tenantId !== snapshot.tenantId) || + aliases.some((row) => row.tenantId !== snapshot.tenantId) + ) { + return yield* unavailable(); + } + const resolver = makePartyAliasResolutionService({ + findAlias: (_tenantId, id) => + Effect.succeed(Option.fromNullishOr(aliases.find((alias) => alias.aliasPartyId === id))), + partyExists: (_tenantId, id) => Effect.succeed(records.some((row) => row.partyId === id)), + }); + const canonicalIdEntries = yield* Effect.forEach( + records, + (row) => + resolver.resolvePartyAlias(snapshot.tenantId, row.partyId).pipe( + Effect.mapError(unavailable), + Effect.map((resolved) => [row.partyId, resolved.canonicalPartyId] as const), + ), + { concurrency: 1 }, + ); + const canonicalIds = new Map(canonicalIdEntries); + const wanted = wantedCanonicalIds(target, canonicalIds); + const initialFamily = records + .filter((row) => wanted.has(canonicalIds.get(row.partyId) ?? '')) + .map((row) => row.partyId); + const contexts = yield* readCounterparties(snapshot, target, initialFamily); + if (contexts.some((row) => !canonicalIds.has(row.partyId))) { + return yield* unavailable(); + } + const expandedWanted = new Set([ + ...wanted, + ...contexts.flatMap((row) => { + const canonicalId = canonicalIds.get(row.partyId); + return canonicalId === undefined ? [] : [canonicalId]; + }), + ]); + const family = records.filter((row) => expandedWanted.has(canonicalIds.get(row.partyId) ?? '')); + const facts = yield* snapshot.tenant((executor) => + readFacts( + executor, + snapshot.tenantId, + family.map((row) => row.partyId), ), - { concurrency: 1 }, - ); - const canonicalIds = new Map(canonicalIdEntries); - const wanted = wantedCanonicalIds(target, canonicalIds); - const initialFamily = records - .filter((row) => wanted.has(canonicalIds.get(row.partyId) ?? '')) - .map((row) => row.partyId); - const contexts = yield* readCounterparties(snapshot, target, initialFamily); - if (contexts.some((row) => !canonicalIds.has(row.partyId))) { - return yield* unavailable(); - } - const expandedWanted = new Set([ - ...wanted, - ...contexts.flatMap((row) => { - const canonicalId = canonicalIds.get(row.partyId); - return canonicalId === undefined ? [] : [canonicalId]; - }), - ]); - const family = records.filter((row) => expandedWanted.has(canonicalIds.get(row.partyId) ?? '')); - const facts = yield* snapshot.tenant((executor) => - readFacts( - executor, - snapshot.tenantId, - family.map((row) => row.partyId), - ), - ); - const identity = (row: (typeof records)[number]): PartySearchSourceIdentity => ({ - contacts: facts.contacts.flatMap((fact) => - fact.tenantId === snapshot.tenantId && - fact.partyId === row.partyId && - fact.state === 'ACTIVE' && - fact.isCurrent && - fact.privacy === 'PUBLIC' && - (fact.type === 'EMAIL' || fact.type === 'PHONE') && - fact.value !== null - ? [ - { - privacy: 'PUBLIC', - state: fact.state, - type: fact.type, - value: fact.value, - ...period(fact), - }, - ] - : [], - ), - displayName: row.displayName, - identifiers: facts.identifiers - .filter( - (fact) => - fact.tenantId === snapshot.tenantId && - fact.partyId === row.partyId && - fact.state === 'ACTIVE' && - fact.isCurrent, - ) - .map((fact) => ({ state: fact.state, value: fact.value, ...period(fact) })), - ref: makePartyRef(snapshot.tenantId, row.partyId), - }); - const removedRefs: PartySearchSourceSnapshot['removedRefs'][number][] = family - .filter((row) => canonicalIds.get(row.partyId) !== row.partyId) - .map((row) => makePartyRef(snapshot.tenantId, row.partyId)); - if ('partyId' in target && !canonicalIds.has(target.partyId)) { - removedRefs.push(makePartyRef(snapshot.tenantId, target.partyId)); - } - if ('counterpartyId' in target && contexts.length === 0) { - removedRefs.push(counterpartyRef(snapshot.tenantId, target.counterpartyId)); - } - return { - counterparties: contexts.map((row) => ({ - legalEntityId: row.legalEntityId, - partyRef: makePartyRef(snapshot.tenantId, canonicalIds.get(row.partyId) ?? row.partyId), - ref: counterpartyRef(snapshot.tenantId, row.counterpartyId), - rolePeriods: row.roles, - storedPartyRef: makePartyRef(snapshot.tenantId, row.partyId), - })), - parties: family - .filter((row) => canonicalIds.get(row.partyId) === row.partyId) - .map((row) => ({ - ...identity(row), - aliases: family - .filter( - (alias) => - alias.partyId !== row.partyId && canonicalIds.get(alias.partyId) === row.partyId, - ) - .map(identity), - archived: row.archivedAt !== null, + ); + const identity = (row: (typeof records)[number]): PartySearchSourceIdentity => ({ + contacts: facts.contacts.flatMap((fact) => + fact.tenantId === snapshot.tenantId && + fact.partyId === row.partyId && + fact.state === 'ACTIVE' && + fact.isCurrent && + fact.privacy === 'PUBLIC' && + (fact.type === 'EMAIL' || fact.type === 'PHONE') && + fact.value !== null + ? [ + { + privacy: 'PUBLIC', + state: fact.state, + type: fact.type, + value: fact.value, + ...period(fact), + }, + ] + : [], + ), + displayName: row.displayName, + identifiers: facts.identifiers + .filter( + (fact) => + fact.tenantId === snapshot.tenantId && + fact.partyId === row.partyId && + fact.state === 'ACTIVE' && + fact.isCurrent, + ) + .map((fact) => ({ + state: fact.state, + value: fact.value, + ...period(fact), + })), + ref: makePartyRef(snapshot.tenantId, row.partyId), + }); + const removedRefs: PartySearchSourceSnapshot['removedRefs'][number][] = family + .filter((row) => canonicalIds.get(row.partyId) !== row.partyId) + .map((row) => makePartyRef(snapshot.tenantId, row.partyId)); + if ('partyId' in target && !canonicalIds.has(target.partyId)) { + removedRefs.push(makePartyRef(snapshot.tenantId, target.partyId)); + } + if ('counterpartyId' in target && contexts.length === 0) { + removedRefs.push(counterpartyRef(snapshot.tenantId, target.counterpartyId)); + } + return { + counterparties: contexts.map((row) => ({ + legalEntityId: row.legalEntityId, + partyRef: makePartyRef(snapshot.tenantId, canonicalIds.get(row.partyId) ?? row.partyId), + ref: counterpartyRef(snapshot.tenantId, row.counterpartyId), + rolePeriods: row.roles, + storedPartyRef: makePartyRef(snapshot.tenantId, row.partyId), })), - projectionVersion: snapshot.projectionVersion, - removedRefs, - tenantId: snapshot.tenantId, - }; -}); + parties: family + .filter((row) => canonicalIds.get(row.partyId) === row.partyId) + .map((row) => ({ + ...identity(row), + aliases: family + .filter((alias) => alias.partyId !== row.partyId && canonicalIds.get(alias.partyId) === row.partyId) + .map(identity), + archived: row.archivedAt !== null, + })), + projectionVersion: snapshot.projectionVersion, + removedRefs, + tenantId: snapshot.tenantId, + }; + }, +); export const makePartySearchProjectionSource = ( ...[reader]: readonly [CoreSearchWorkerSnapshotService] diff --git a/app/verticals/party-registry/src/services/party-search-projection.service.ts b/app/verticals/party-registry/src/services/party-search-projection.service.ts index 0318bbbda..12454a820 100644 --- a/app/verticals/party-registry/src/services/party-search-projection.service.ts +++ b/app/verticals/party-registry/src/services/party-search-projection.service.ts @@ -1,5 +1,3 @@ -// @generated by OntOS Codesmith Action Service v1 -import { Context, DateTime, Effect, Layer, Option, Schema } from 'effect'; import { CoreSearchIngestion, CoreSearchProjectionDocumentSchema, @@ -13,9 +11,12 @@ import type { CoreSearchProjectionStoreService, OutboxWorkerHandlerContext, } from '@app/core-runtime'; -import type { PartyRef } from '../../shared/resources/party.ts'; -import type { CounterpartyRef } from '../../shared/resources/counterparty.ts'; +// @generated by OntOS Codesmith Action Service v1 +import { Context, DateTime, Effect, Layer, Option, Schema } from 'effect'; + import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import type { CounterpartyRef } from '../../shared/resources/counterparty.ts'; +import type { PartyRef } from '../../shared/resources/party.ts'; import { PartySearchProjectionSource } from './party-search-projection-source.service.ts'; export interface PartySearchSourceValue { @@ -102,11 +103,8 @@ const hasNonEmptyPeriod = (period: { readonly validFrom: string; readonly validT ); }; -const temporalValueKey = (value: { - readonly validFrom: string; - readonly validTo?: string; - readonly value: string; -}) => `${value.validFrom}\u0000${value.validTo ?? ''}\u0000${value.value}`; +const temporalValueKey = (value: { readonly validFrom: string; readonly validTo?: string; readonly value: string }) => + `${value.validFrom}\u0000${value.validTo ?? ''}\u0000${value.value}`; const activeEvidence = (identity: PartySearchSourceIdentity) => [...identity.identifiers, ...identity.contacts.filter((contact) => contact.privacy === 'PUBLIC')] @@ -127,71 +125,73 @@ const aliasEvidence = (party: PartySearchSourceParty, kind: 'resource' | 'subjec })); /** Owner semantics only; Core owns physical documents, replay, ordering and tombstones. */ -export const buildPartySearchDocuments = Effect.fn( - 'PartySearchProjectionService.buildPartySearchDocuments', -)(function* buildDocuments(snapshot: PartySearchSourceSnapshot) { - const common = { facets: [], metadata: [], projectionVersion: snapshot.projectionVersion }; - const partyDocuments = yield* Effect.forEach( - snapshot.parties, - (party) => { - if ( - party.ref.tenantId !== snapshot.tenantId || - party.aliases.some((alias) => alias.ref.tenantId !== snapshot.tenantId) - ) { - return Effect.fail(unavailable()); - } - return Schema.decodeUnknownEffect(CoreSearchProjectionDocumentSchema)({ - ...common, - aliases: aliasEvidence(party, 'resource'), - archived: party.archived, - ref: party.ref, - searchableText: party.displayName === null ? [] : [party.displayName], - temporalSearchableText: activeEvidence(party), - title: party.displayName ?? 'Unnamed Party', - }).pipe(Effect.mapError(unavailable)); - }, - { concurrency: 1 }, - ); - const counterpartyDocuments = yield* Effect.forEach( - snapshot.counterparties, - ( - counterparty, - ): Effect.Effect => { - const party = snapshot.parties.find( - (candidate) => candidate.ref.resourceId === counterparty.partyRef.resourceId, - ); - if ( - party === undefined || - counterparty.ref.tenantId !== snapshot.tenantId || - counterparty.partyRef.tenantId !== snapshot.tenantId || - counterparty.storedPartyRef.tenantId !== snapshot.tenantId - ) { - return Effect.fail(unavailable()); - } - return Schema.decodeUnknownEffect(CoreSearchProjectionDocumentSchema)({ - ...common, - aliases: aliasEvidence(party, 'subject'), - archived: party.archived, - ref: counterparty.ref, - searchableText: party.displayName === null ? [] : [party.displayName], - selectedLegalEntityId: counterparty.legalEntityId, - subjectRef: party.ref, - temporalFacets: counterparty.rolePeriods - .filter((period) => period.state === 'ACTIVE' && hasNonEmptyPeriod(period)) - .map(({ role, validFrom, validTo }) => - validTo === undefined - ? { key: 'current-role', validFrom, value: role } - : { key: 'current-role', validFrom, validTo, value: role }, - ) - .toSorted((left, right) => temporalValueKey(left).localeCompare(temporalValueKey(right))), - temporalSearchableText: activeEvidence(party), - title: party.displayName ?? 'Unnamed Party', - }).pipe(Effect.mapError(unavailable)); - }, - { concurrency: 1 }, - ); - return [...partyDocuments, ...counterpartyDocuments]; -}); +export const buildPartySearchDocuments = Effect.fn('PartySearchProjectionService.buildPartySearchDocuments')( + function* buildDocuments(snapshot: PartySearchSourceSnapshot) { + const common = { + facets: [], + metadata: [], + projectionVersion: snapshot.projectionVersion, + }; + const partyDocuments = yield* Effect.forEach( + snapshot.parties, + (party) => { + if ( + party.ref.tenantId !== snapshot.tenantId || + party.aliases.some((alias) => alias.ref.tenantId !== snapshot.tenantId) + ) { + return Effect.fail(unavailable()); + } + return Schema.decodeEffect(CoreSearchProjectionDocumentSchema)({ + ...common, + aliases: aliasEvidence(party, 'resource'), + archived: party.archived, + ref: party.ref, + searchableText: party.displayName === null ? [] : [party.displayName], + temporalSearchableText: activeEvidence(party), + title: party.displayName ?? 'Unnamed Party', + }).pipe(Effect.mapError(unavailable)); + }, + { concurrency: 1 }, + ); + const counterpartyDocuments = yield* Effect.forEach( + snapshot.counterparties, + (counterparty): Effect.Effect => { + const party = snapshot.parties.find( + (candidate) => candidate.ref.resourceId === counterparty.partyRef.resourceId, + ); + if ( + party === undefined || + counterparty.ref.tenantId !== snapshot.tenantId || + counterparty.partyRef.tenantId !== snapshot.tenantId || + counterparty.storedPartyRef.tenantId !== snapshot.tenantId + ) { + return Effect.fail(unavailable()); + } + return Schema.decodeEffect(CoreSearchProjectionDocumentSchema)({ + ...common, + aliases: aliasEvidence(party, 'subject'), + archived: party.archived, + ref: counterparty.ref, + searchableText: party.displayName === null ? [] : [party.displayName], + selectedLegalEntityId: counterparty.legalEntityId, + subjectRef: party.ref, + temporalFacets: counterparty.rolePeriods + .filter((period) => period.state === 'ACTIVE' && hasNonEmptyPeriod(period)) + .map(({ role, validFrom, validTo }) => + validTo === undefined + ? { key: 'current-role', validFrom, value: role } + : { key: 'current-role', validFrom, validTo, value: role }, + ) + .toSorted((left, right) => temporalValueKey(left).localeCompare(temporalValueKey(right))), + temporalSearchableText: activeEvidence(party), + title: party.displayName ?? 'Unnamed Party', + }).pipe(Effect.mapError(unavailable)); + }, + { concurrency: 1 }, + ); + return [...partyDocuments, ...counterpartyDocuments]; + }, +); export const makePartySearchProjector = ( ...[source, ingestion, store]: readonly [ @@ -205,10 +205,7 @@ export const makePartySearchProjector = ( target: PartySearchProjectionTarget, ) { const snapshot = yield* source.load(context, target); - if ( - snapshot.tenantId !== context.tenantId || - !/^[1-9][0-9]*$/u.test(snapshot.projectionVersion) - ) { + if (snapshot.tenantId !== context.tenantId || !/^[1-9][0-9]*$/u.test(snapshot.projectionVersion)) { return yield* unavailable(); } const documents = yield* buildPartySearchDocuments(snapshot); @@ -248,7 +245,7 @@ export const makePartySearchProjector = ( yield* Effect.forEach( snapshot.removedRefs, (ref) => - Schema.decodeUnknownEffect(CoreSearchProjectionMutationSchema)({ + Schema.decodeEffect(CoreSearchProjectionMutationSchema)({ kind: 'delete', projectionVersion: snapshot.projectionVersion, ref, diff --git a/app/verticals/party-registry/src/worker-host/layer.ts b/app/verticals/party-registry/src/worker-host/layer.ts index 54475cf3f..7d70d0589 100644 --- a/app/verticals/party-registry/src/worker-host/layer.ts +++ b/app/verticals/party-registry/src/worker-host/layer.ts @@ -1,22 +1,23 @@ -// @generated by scaffold:outbox-worker worker-host -// @ontos-outbox-worker-host-owner party.registry -import { Layer } from 'effect'; import { CoreSearchIngestionLive, CoreSearchProjectionStoreLive, CoreSearchWorkerSnapshotLive, } from '@app/core-runtime'; -import { OutboxWorkerInfrastructureLive } from '@app/core-runtime/outbox/worker'; import type { CoreSearchIngestion, - CoreSearchProjectionStoreService, + CoreSearchProjectionStore, CoreSearchWorkerSnapshot, OutboxRuntime, } from '@app/core-runtime'; -import { PartySearchProjectorLive } from '../services/party-search-projection.service.ts'; -import type { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { OutboxWorkerInfrastructureLive } from '@app/core-runtime/outbox/worker'; +// @generated by scaffold:outbox-worker worker-host +// @ontos-outbox-worker-host-owner party.registry +import { Layer } from 'effect'; + import { PartySearchProjectionSourceLive } from '../services/party-search-projection-source.service.ts'; import type { PartySearchProjectionSource } from '../services/party-search-projection-source.service.ts'; +import { PartySearchProjectorLive } from '../services/party-search-projection.service.ts'; +import type { PartySearchProjector } from '../services/party-search-projection.service.ts'; export { CorePersistenceLive as outboxWorkerCorePersistenceLive, @@ -34,12 +35,12 @@ type OutboxWorkerHandlerLayers = Readonly<{ projector: Layer.Layer< PartySearchProjector, never, - CoreSearchIngestion | CoreSearchProjectionStoreService | PartySearchProjectionSource + CoreSearchIngestion | CoreSearchProjectionStore | PartySearchProjectionSource >; projectionSource: Layer.Layer; - searchIngestion: Layer.Layer; + searchIngestion: Layer.Layer; searchProjectionStore: Layer.Layer< - CoreSearchProjectionStoreService, + CoreSearchProjectionStore, never, Layer.Services >; @@ -64,6 +65,6 @@ export const outboxWorkerLayer: Layer.Layer< never, | Layer.Services | CoreSearchIngestion - | CoreSearchProjectionStoreService + | CoreSearchProjectionStore | PartySearchProjectionSource > = Layer.merge(outboxWorkerInfrastructureLayer, outboxWorkerHandlerLayers.projector); diff --git a/app/verticals/party-registry/src/workers/party-search-worker.ts b/app/verticals/party-registry/src/workers/party-search-worker.ts index 19cfd2911..e9126dcb1 100644 --- a/app/verticals/party-registry/src/workers/party-search-worker.ts +++ b/app/verticals/party-registry/src/workers/party-search-worker.ts @@ -2,6 +2,7 @@ import { defineOutboxWorker } from '@app/core-runtime'; import type { OutboxWorkerDescriptor, OutboxWorkerHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; import type { Schema } from 'effect'; + import { PartySearchProjector } from '../services/party-search-projection.service.ts'; import type { PartySearchProjectionTarget } from '../services/party-search-projection.service.ts'; diff --git a/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts index 714883116..54c34a684 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-added.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectContactPointAddedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts index 06c18e1a4..cd84065b6 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-ended.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectContactPointEndedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts index b1e2cf26c..3c1b349ff 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-updated.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-updated-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectContactPointUpdatedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts index 0c5ccc534..4a988a622 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-created.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectCounterpartyCreatedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ @@ -26,6 +27,8 @@ export const { worker: projectCounterpartyCreatedToSearchWorker } = definePartyS }, { spanName: 'handleProjectCounterpartyCreatedToSearch', - target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + target: (payload) => ({ + counterpartyId: payload.counterpartyRef.resourceId, + }), }, ); diff --git a/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts index cb8b9379a..c30d1dfd2 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-role-added.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectCounterpartyRoleAddedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ @@ -26,6 +27,8 @@ export const { worker: projectCounterpartyRoleAddedToSearchWorker } = definePart }, { spanName: 'handleProjectCounterpartyRoleAddedToSearch', - target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + target: (payload) => ({ + counterpartyId: payload.counterpartyRef.resourceId, + }), }, ); diff --git a/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts index 8cacda8a5..3df90ec8d 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-role-ended.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectCounterpartyRoleEndedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ @@ -26,6 +27,8 @@ export const { worker: projectCounterpartyRoleEndedToSearchWorker } = definePart }, { spanName: 'handleProjectCounterpartyRoleEndedToSearch', - target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + target: (payload) => ({ + counterpartyId: payload.counterpartyRef.resourceId, + }), }, ); diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts index 74647fe8b..1991adf8b 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-added.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-added-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectOfficialIdentifierAddedToSearchWorker, handle: handleProjectOfficialIdentifierAddedToSearch, diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts index ff4df6ff5..ec7a32b9e 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-ended.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-ended-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectOfficialIdentifierEndedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts index 57859befd..92f151cb7 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-updated.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectOfficialIdentifierUpdatedToSearchWorker, handle: handleProjectOfficialIdentifierUpdatedToSearch, diff --git a/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts index 6b5fde983..a66df80f2 100644 --- a/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-archived.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-archived-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectPartyArchivedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts index 803fb1316..9e1f18144 100644 --- a/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-created.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-created-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectPartyCreatedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts index 6696f51a5..7da8dc112 100644 --- a/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-fact-corrected.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectPartyFactCorrectedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts index 285c9f340..88de4a372 100644 --- a/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-unarchived.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectPartyUnarchivedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts index 469160285..002898f60 100644 --- a/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts @@ -4,13 +4,14 @@ // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-updated.v1 import { defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-updated-v1'; +import { definePartySearchWorker } from './party-search-worker.ts'; + export const { worker: projectPartyUpdatedToSearchWorker } = definePartySearchWorker( { entrypoint: defineTenantModuleEntrypoint({ diff --git a/app/verticals/party-registry/src/workers/rebuild-search.worker.ts b/app/verticals/party-registry/src/workers/rebuild-search.worker.ts index d594519b0..def2e8b15 100644 --- a/app/verticals/party-registry/src/workers/rebuild-search.worker.ts +++ b/app/verticals/party-registry/src/workers/rebuild-search.worker.ts @@ -1,23 +1,21 @@ -// @generated by OntOS Codesmith Outbox Worker v1 -// @ontos-outbox-worker-key party.registry.rebuild-search -// @ontos-outbox-worker-owner party.registry -// @ontos-outbox-worker-producer party.registry -// @ontos-outbox-worker-topic party.registry.search-rebuild-requested.v1 -import { Effect } from 'effect'; import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-search-rebuild-requested-v1'; +// @generated by OntOS Codesmith Outbox Worker v1 +// @ontos-outbox-worker-key party.registry.rebuild-search +// @ontos-outbox-worker-owner party.registry +// @ontos-outbox-worker-producer party.registry +// @ontos-outbox-worker-topic party.registry.search-rebuild-requested.v1 +import { Effect } from 'effect'; + +import { PartySearchProjector } from '../services/party-search-projection.service.ts'; export const handleRebuildSearch = Effect.fn('RebuildSearchWorker.handleRebuildSearch')( - function* rebuildCommittedSearch( - _payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, - ) { + function* rebuildCommittedSearch(_payload: typeof OutboxPayloadSchema.Type, context: OutboxWorkerHandlerContext) { const projector = yield* PartySearchProjector; yield* projector.project(context, { rebuild: true }); }, diff --git a/app/verticals/party-registry/tests/components/contacts-page.test.tsx b/app/verticals/party-registry/tests/components/contacts-page.test.tsx index 8e28f6b4a..41a3b1898 100644 --- a/app/verticals/party-registry/tests/components/contacts-page.test.tsx +++ b/app/verticals/party-registry/tests/components/contacts-page.test.tsx @@ -1,5 +1,6 @@ -import { afterEach, expect, rstest, test } from 'effect-rstest'; import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, expect, rstest, test } from 'effect-rstest'; + import csCatalog from '../../locales/cs/party-registry.json'; import enCatalog from '../../locales/en/party-registry.json'; import ContactsPage from '../../src/routes/[lang]/contacts/page.tsx'; @@ -47,8 +48,6 @@ test.each([ render(); expect(screen.getByRole('heading', { level: 1, name: title })).toBeTruthy(); - expect( - screen.getByText(catalogs[language]['party-registry'].pages.contacts.description), - ).toBeTruthy(); + expect(screen.getByText(catalogs[language]['party-registry'].pages.contacts.description)).toBeTruthy(); expect(screen.queryByRole('link')).toBeNull(); }); diff --git a/app/verticals/party-registry/tests/integration/ares-governed.test.ts b/app/verticals/party-registry/tests/integration/ares-governed.test.ts index feb03e408..b9e348ece 100644 --- a/app/verticals/party-registry/tests/integration/ares-governed.test.ts +++ b/app/verticals/party-registry/tests/integration/ares-governed.test.ts @@ -1,26 +1,15 @@ -import { assert, expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import { DatabaseConfig, loadDatabaseConnectionPair } from '@app/core-runtime'; import { makeLiveOperationFixture } from '@app/core-runtime/testing/actions'; - import { HttpApi, HttpApiBuilder, HttpRouter, HttpServer } from '@modern-js/plugin-bff/effect-edge'; import { eq } from 'drizzle-orm'; -import { - ConfigProvider, - Context, - DateTime, - Effect, - Layer, - Match, - Option, - Redacted, - Schema, - Predicate, -} from 'effect'; +import { ConfigProvider, Context, DateTime, Effect, Layer, Match, Option, Redacted, Schema, Predicate } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; import { FetchHttpClient, HttpClient, HttpClientResponse } from 'effect/unstable/http'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; -import { randomUUID } from 'node:crypto'; import { Pool } from 'pg'; + import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; import { aresLookupReadApiLive } from '../../api/ares-lookup-read-server.ts'; import { ActionPrincipalVerifierLive } from '../../api/auth/action-principal.ts'; @@ -33,14 +22,8 @@ import { partyContactPointsReadApiLive } from '../../api/party-contact-points-re import { partyDetailReadApiLive } from '../../api/party-detail-read-server.ts'; import { partyOfficialIdentifierHistoryReadApiLive } from '../../api/party-official-identifier-history-read-server.ts'; import { partyRegistryApi } from '../../shared/api.ts'; -import { - deriveAresEvidenceApplication, - makeAresAppliedEvidence, -} from '../../shared/domain/ares-application.ts'; -import { - AresSubjectEvidenceSchema, - AresSubjectLookupIcoSchema, -} from '../../shared/domain/ares-evidence.ts'; +import { deriveAresEvidenceApplication, makeAresAppliedEvidence } from '../../shared/domain/ares-application.ts'; +import { AresSubjectEvidenceSchema, AresSubjectLookupIcoSchema } from '../../shared/domain/ares-evidence.ts'; import { IdentityCorrectionCommandSchema } from '../../shared/domain/correction-contracts.ts'; import { partySubjectKeyFromString } from '../../shared/domain/identity-contracts.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; @@ -97,7 +80,7 @@ const rawSubject = { }, }; const emptyRequestContext = Context.makeUnsafe(new Map()); -const lookupIco = Schema.decodeUnknownSync(AresSubjectLookupIcoSchema)('27074358'); +const lookupIco = Schema.decodeSync(AresSubjectLookupIcoSchema)('27074358'); const endPool = (pool: Pool) => Effect.promise(() => pool.end()); it.live( @@ -144,24 +127,17 @@ it.live( .setJti(randomUUID()) .sign(privateKey), ); - const authorization = () => - sign(fixture.manager).pipe(Effect.map((token) => `Bearer ${token}`)); + const authorization = () => sign(fixture.manager).pipe(Effect.map((token) => `Bearer ${token}`)); const gateway = makeOperationGateway(() => - sign(fixture.manager).pipe( - Effect.map((signedToken) => ({ expiresAt: 0, token: signedToken })), - ), + sign(fixture.manager).pipe(Effect.map((signedToken) => ({ expiresAt: 0, token: signedToken }))), ); let providerRequests = 0; const provider = HttpClient.make((request, url) => { - expect(url.href).toBe( - 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/27074358', - ); + expect(url.href).toBe('https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/27074358'); providerRequests += 1; return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(rawSubject))); }); - const upstream = AresSubjectServiceLive.pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, provider)), - ); + const upstream = AresSubjectServiceLive.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, provider))); const redemption = GatewayAssertionRedemptionLive.pipe( Layer.provide(GatewayAssertionRedemptionDatabaseLive), Layer.provide(Layer.succeed(DatabaseConfig, connections.runtime)), @@ -205,8 +181,7 @@ it.live( ), (resource) => Effect.promise(resource.dispose.bind(resource)).pipe(Effect.orDie), ); - const inMemoryFetch: typeof fetch = (input, init) => - app.handler(new Request(input, init), emptyRequestContext); + const inMemoryFetch: typeof fetch = (input, init) => app.handler(new Request(input, init), emptyRequestContext); const runHttpEffect = (effect: Effect.Effect) => effect.pipe(Effect.provideService(FetchHttpClient.Fetch, inMemoryFetch)); const baseUrl = 'https://party.ontos.test'; @@ -255,17 +230,10 @@ it.live( }); const lookup = Effect.fn('AresGovernedTest.lookup')(function* lookupEffect() { return yield* runHttpEffect( - executeAresLookupWithAuthorization( - { ico: lookupIco }, - yield* authorization(), - randomUUID(), - { baseUrl }, - ), + executeAresLookupWithAuthorization({ ico: lookupIco }, yield* authorization(), randomUUID(), { baseUrl }), ); }); - const detail = Effect.fn('AresGovernedTest.detail')(function* detailEffect( - partyRef: PartyRef, - ) { + const detail = Effect.fn('AresGovernedTest.detail')(function* detailEffect(partyRef: PartyRef) { return yield* runHttpEffect( executePartyDetailWithAuthorization( { partyRef, includeFactHistory: true }, @@ -286,10 +254,7 @@ it.live( .select() .from(partyIdentifierClaims) .where(eq(partyIdentifierClaims.tenantId, fixture.tenantId)), - contacts: admin - .select() - .from(partyContactPoints) - .where(eq(partyContactPoints.tenantId, fixture.tenantId)), + contacts: admin.select().from(partyContactPoints).where(eq(partyContactPoints.tenantId, fixture.tenantId)), core: fixture.evidence(), identifiers: admin .select() @@ -303,12 +268,7 @@ it.live( const replayAuthorization = yield* authorization(); const replayLookup = () => runHttpEffect( - executeAresLookupWithAuthorization( - { ico: lookupIco }, - replayAuthorization, - randomUUID(), - { baseUrl }, - ), + executeAresLookupWithAuthorization({ ico: lookupIco }, replayAuthorization, randomUUID(), { baseUrl }), ); yield* replayLookup(); const providerRequestsBeforeReplay = providerRequests; @@ -345,7 +305,11 @@ it.live( idempotencyKey: randomUUID(), payload: { partyRef: target, - identifier: { identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }, + identifier: { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, validFrom: observation.observedAt, provenanceMethod: 'ARES_USER_CONFIRMED', provenanceSource: 'ARES', @@ -371,7 +335,10 @@ it.live( { preferred: false, purpose: 'REGISTERED', - registryContext: { jurisdiction: 'CZ', registryKey: 'ARES' }, + registryContext: { + jurisdiction: 'CZ', + registryKey: 'ARES', + }, }, ], }, @@ -389,23 +356,14 @@ it.live( const request = requestFor(partyRef); const beforeUnconfirmed = yield* state(); const unconfirmed = yield* runHttpEffect( - applyAresObservation({ ...request, userConfirmed: false }, { gateway, baseUrl }).pipe( - Effect.result, - ), - ); - assert.isOk( - 'failure' in unconfirmed && - Predicate.isTagged(unconfirmed.failure, 'AresApplySelectionInvalid'), + applyAresObservation({ ...request, userConfirmed: false }, { gateway, baseUrl }).pipe(Effect.result), ); + assert.isOk('failure' in unconfirmed && Predicate.isTagged(unconfirmed.failure, 'AresApplySelectionInvalid')); const afterUnconfirmed = yield* state(); - expect(afterUnconfirmed.core.invocations.length).toBe( - beforeUnconfirmed.core.invocations.length, - ); + expect(afterUnconfirmed.core.invocations.length).toBe(beforeUnconfirmed.core.invocations.length); const applied = yield* runHttpEffect(applyAresObservation(request, { gateway, baseUrl })); - const appliedMessage = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( - applied, - ); + const appliedMessage = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(applied); assert.isOk(Predicate.isTagged(applied, 'AresApplyCompleted'), appliedMessage); expect(applied.completed.length).toBe(3); @@ -424,10 +382,9 @@ it.live( expect(contactEvidence.observedAt).toBe(encodedObservation.observedAt); expect(contactEvidence.providerChangedOn).toBe(encodedObservation.providerChangedOn); expect(contactEvidence.providerRecordRef).toBe(encodedObservation.providerRecordRef); - expect( - persisted.assertions.find((item) => item.factKind === 'DISPLAY_NAME')?.externalEvidence - ?.decidedAt, - ).toBe(encodedObservation.servedAt); + expect(persisted.assertions.find((item) => item.factKind === 'DISPLAY_NAME')?.externalEvidence?.decidedAt).toBe( + encodedObservation.servedAt, + ); expect(persisted.core.events.length).toBe(4); expect(persisted.core.outbox.length).toBe(4); assert.isOk(persisted.core.invocations.every((item) => item.status === 'succeeded')); @@ -440,24 +397,18 @@ it.live( const afterReplay = yield* state(); expect(afterReplay.core.events.length).toBe(persisted.core.events.length); const deniedGateway = makeOperationGateway(() => - sign(fixture.denied).pipe( - Effect.map((signedToken) => ({ expiresAt: 0, token: signedToken })), - ), + sign(fixture.denied).pipe(Effect.map((signedToken) => ({ expiresAt: 0, token: signedToken }))), ); const denied = yield* runHttpEffect( applyAresObservation(request, { gateway: deniedGateway, baseUrl }).pipe(Effect.result), ); - assert.isOk( - 'failure' in denied && Predicate.isTagged(denied.failure, 'AresLookupForbiddenProblem'), - ); + assert.isOk('failure' in denied && Predicate.isTagged(denied.failure, 'AresLookupForbiddenProblem')); const afterDenied = yield* state(); expect(afterDenied.core.invocations.length).toBe(persisted.core.invocations.length); const collisionParty = yield* create(); - const collision = yield* runHttpEffect( - applyAresObservation(requestFor(collisionParty), { gateway, baseUrl }), - ); + const collision = yield* runHttpEffect(applyAresObservation(requestFor(collisionParty), { gateway, baseUrl })); const collisionOutcome = Match.value(collision).pipe( Match.tag('AresApplyPartiallyCompleted', (outcome) => outcome), Match.orElse(() => assert.fail(`Expected partial completion, received ${collision._tag}`)), @@ -469,9 +420,7 @@ it.live( expect(afterCollision.claims.length).toBe(1); expect(afterCollision.contacts.length).toBe(1); const collisionDetail = yield* detail(collisionParty); - expect(Option.getOrUndefined(collisionDetail.party.displayName)).toBe( - rawSubject.obchodniJmeno, - ); + expect(Option.getOrUndefined(collisionDetail.party.displayName)).toBe(rawSubject.obchodniJmeno); const erroneousParty = yield* create(); const logical = deriveAresEvidenceApplication({ @@ -507,12 +456,10 @@ it.live( ), ); const erroneous = yield* detail(erroneousParty); - const assertion = erroneous.currentFactAssertions.find( - (item) => item.factKind === 'DISPLAY_NAME', - ); + const assertion = erroneous.currentFactAssertions.find((item) => item.factKind === 'DISPLAY_NAME'); assert.isOk(assertion); - const correctionPayload = yield* Schema.decodeUnknownEffect(IdentityCorrectionCommandSchema)({ + const correctionPayload = yield* Schema.decodeEffect(IdentityCorrectionCommandSchema)({ partyId: erroneousParty.resourceId, factKind: 'DISPLAY_NAME' as const, targetAssertionId: assertion.assertionId, @@ -552,16 +499,12 @@ it.live( expect(reviewOutcome.correctionCandidates[0]?.targetAssertionId).toBe(assertion.assertionId); const afterReview = yield* state(); expect(afterReview.core.invocations.length).toBe(beforeReview.core.invocations.length); - yield* runHttpEffect( - correctPartyFactWithAuthorization(correctionPayload, yield* authorization(), options()), - ); + yield* runHttpEffect(correctPartyFactWithAuthorization(correctionPayload, yield* authorization(), options())); const corrected = yield* detail(erroneousParty); expect(Option.getOrUndefined(corrected.party.displayName)).toBe(rawSubject.obchodniJmeno); const correctedState = yield* state(); assert.isOk( - correctedState.assertions.some( - (item) => item.assertionId === assertion.assertionId && item.state !== 'ACTIVE', - ), + correctedState.assertions.some((item) => item.assertionId === assertion.assertionId && item.state !== 'ACTIVE'), ); assert.isOk(providerRequests >= 1); diff --git a/app/verticals/party-registry/tests/integration/database-boundary.test.ts b/app/verticals/party-registry/tests/integration/database-boundary.test.ts index 97364dc05..0c5b956d0 100644 --- a/app/verticals/party-registry/tests/integration/database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/database-boundary.test.ts @@ -1,11 +1,9 @@ -import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; -import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; -import { assert, expect, it } from 'effect-rstest'; - +import { and, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'; import { DateTime, Effect, Schema } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; -import { and, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'; import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { RuleKeySchema } from '../../shared/domain/matching-contracts.ts'; import { counterparties, @@ -28,6 +26,7 @@ import { partyRelationships, } from '../../src/db/schema.ts'; import type { PartyTransaction } from '../../src/db/types.ts'; +import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; const tenantA = 'a1000000-0000-4000-8000-000000000001'; const tenantB = 'a1000000-0000-4000-8000-000000000002'; @@ -91,19 +90,16 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact ) => runtime.transaction((transaction) => Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantId}, true)`, - 'objects', - ); + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantId}, true)`, 'objects'); return yield* operation(transaction); }), ); yield* Effect.addFinalizer(() => cleanup().pipe(Effect.orDie)); - const runtimeRole = yield* runtime.execute<{ rolbypassrls: boolean; rolsuper: boolean }>( - sql`select rolbypassrls, rolsuper from pg_roles where rolname = current_user`, - 'objects', - ); + const runtimeRole = yield* runtime.execute<{ + rolbypassrls: boolean; + rolsuper: boolean; + }>(sql`select rolbypassrls, rolsuper from pg_roles where rolname = current_user`, 'objects'); expect(runtimeRole).toEqual([{ rolbypassrls: false, rolsuper: false }]); yield* cleanup(); yield* admin.insert(parties).values([ @@ -141,11 +137,7 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact yield* withTenant(tenantA, (transaction) => transaction.select({ partyId: parties.partyId }).from(parties).orderBy(parties.partyId), ), - ).toEqual([ - { partyId: partyOrganizationA }, - { partyId: partyOrganizationA2 }, - { partyId: partyPersonA }, - ]); + ).toEqual([{ partyId: partyOrganizationA }, { partyId: partyOrganizationA2 }, { partyId: partyPersonA }]); const identifierValues = (tenantId: string, partyId: string, identifierId: string) => ({ acceptedByActionInvocationId: actionA, acceptedByPrincipalId: principalA, @@ -194,9 +186,7 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact .from(partyOfficialIdentifiers) .where(eq(partyOfficialIdentifiers.officialIdentifierId, identifierA)); expect(persistedExternalEvidence?.externalEvidence).toEqual(externalEvidence); - expect(persistedExternalEvidence?.validFrom.toISOString()).not.toBe( - externalEvidence.observedAt, - ); + expect(persistedExternalEvidence?.validFrom.toISOString()).not.toBe(externalEvidence.observedAt); expect( hasPostgreSqlCode('23514')( yield* Effect.flip( @@ -333,7 +323,11 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact } as const; yield* admin.insert(partyContactPointPurposes).values([ { ...purposeEvidence, contactPointId: addressA, purposeKey: 'BILLING' }, - { ...purposeEvidence, contactPointId: addressA, purposeKey: 'DELIVERY' }, + { + ...purposeEvidence, + contactPointId: addressA, + purposeKey: 'DELIVERY', + }, ]); expect( hasPostgreSqlCode('23505')( @@ -496,16 +490,12 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact ); return relationships.length; }); - expect( - yield* effectiveRelationshipCount( - DateTime.toDateUtc(DateTime.makeUnsafe('2026-06-01T00:00:00.000Z')), - ), - ).toBe(1); - expect( - yield* effectiveRelationshipCount( - DateTime.toDateUtc(DateTime.makeUnsafe('2027-01-01T00:00:00.000Z')), - ), - ).toBe(1); + expect(yield* effectiveRelationshipCount(DateTime.toDateUtc(DateTime.makeUnsafe('2026-06-01T00:00:00.000Z')))).toBe( + 1, + ); + expect(yield* effectiveRelationshipCount(DateTime.toDateUtc(DateTime.makeUnsafe('2027-01-01T00:00:00.000Z')))).toBe( + 1, + ); const [unknownStart] = yield* admin .insert(partyRelationships) .values({ @@ -525,7 +515,9 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact yield* admin .update(partyRelationships) - .set({ validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2028-01-01T00:00:00.000Z')) }) + .set({ + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2028-01-01T00:00:00.000Z')), + }) .where(eq(partyRelationships.relationshipId, unknownStart.relationshipId)); expect( hasPostgreSqlCode('23514')( @@ -721,10 +713,7 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact eq(counterpartyRolePeriods.counterpartyId, counterpartyA), eq(counterpartyRolePeriods.state, 'ACTIVE'), lte(counterpartyRolePeriods.validFrom, effectiveAt), - or( - isNull(counterpartyRolePeriods.validTo), - gt(counterpartyRolePeriods.validTo, effectiveAt), - ), + or(isNull(counterpartyRolePeriods.validTo), gt(counterpartyRolePeriods.validTo, effectiveAt)), ), ); expect(effectiveRoles.length).toBe(1); @@ -771,9 +760,7 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact validTo: role.validTo, })), ); - expect( - yield* withTenant(tenantA, (transaction) => transaction.select().from(counterparties)), - ).toEqual([]); + expect(yield* withTenant(tenantA, (transaction) => transaction.select().from(counterparties))).toEqual([]); expect(yield* runtime.select().from(counterpartyAdminReadModels)).toEqual([]); const tenantCounterpartyModels = yield* withTenant(tenantA, (transaction) => transaction.select().from(counterpartyAdminReadModels), @@ -783,26 +770,14 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact transaction.select().from(counterpartyRoleAdminReadModels), ); expect(tenantRoleModels.length).toBe(roleSources.length); - expect( - yield* withTenant(tenantB, (transaction) => - transaction.select().from(counterpartyAdminReadModels), - ), - ).toEqual([]); - expect( - yield* withTenant(tenantA, (transaction) => - transaction.select().from(counterpartyRolePeriods), - ), - ).toEqual([]); + expect(yield* withTenant(tenantB, (transaction) => transaction.select().from(counterpartyAdminReadModels))).toEqual( + [], + ); + expect(yield* withTenant(tenantA, (transaction) => transaction.select().from(counterpartyRolePeriods))).toEqual([]); const scopedCounterparties = yield* runtime.transaction((transaction) => Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, - 'objects', - ); - yield* transaction.execute( - sql`select set_config('ontos.legal_entity_id', ${legalEntityA}, true)`, - 'objects', - ); + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, 'objects'); + yield* transaction.execute(sql`select set_config('ontos.legal_entity_id', ${legalEntityA}, true)`, 'objects'); return yield* transaction.select().from(counterparties); }), ); @@ -960,10 +935,7 @@ it.live('enforces Party owner invariants, tenant isolation, and independent fact survivorPartyId: partyOrganizationA, tenantId: tenantA, }); - const [merge] = yield* admin - .select({ mergeId: partyMerges.mergeId }) - .from(partyMerges) - .limit(1); + const [merge] = yield* admin.select({ mergeId: partyMerges.mergeId }).from(partyMerges).limit(1); assert.isOk(merge); expect( diff --git a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts index ddae71d88..3990e8e08 100644 --- a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts @@ -1,138 +1,124 @@ -import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; -import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; - import { eq, inArray, sql } from 'drizzle-orm'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { contactsRelations, organizationEngagementProfiles, personEngagementProfiles, } from '../../src/db/engagement-schema.ts'; +import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; const tenantA = 'c1000000-0000-4000-8000-000000000001'; const tenantB = 'c1000000-0000-4000-8000-000000000002'; const fixtureTenants = [tenantA, tenantB] as const; -it.live( - 'enforces tenant isolation and canonical-reference uniqueness without cross-vertical FKs', - () => - Effect.gen(function* testEffect1() { - const { admin, runtime } = yield* openBoundaryDatabases( - (pool) => makeTestDatabaseFromPool(pool, contactsRelations), - 1, +it.live('enforces tenant isolation and canonical-reference uniqueness without cross-vertical FKs', () => + Effect.gen(function* testEffect1() { + const { admin, runtime } = yield* openBoundaryDatabases( + (pool) => makeTestDatabaseFromPool(pool, contactsRelations), + 1, + ); + const cleanup = () => + purgeFixtureRows( + [personEngagementProfiles, organizationEngagementProfiles].map((table) => + admin.delete(table).where(inArray(table.tenantId, fixtureTenants)), + ), ); - const cleanup = () => - purgeFixtureRows( - [personEngagementProfiles, organizationEngagementProfiles].map((table) => - admin.delete(table).where(inArray(table.tenantId, fixtureTenants)), - ), - ); - yield* Effect.addFinalizer(() => cleanup().pipe(Effect.orDie)); - yield* cleanup(); - expect(yield* runtime.select().from(organizationEngagementProfiles)).toEqual([]); - expect( - hasPostgreSqlCode('42501')( - yield* Effect.flip( - runtime.insert(organizationEngagementProfiles).values({ - counterpartyResourceId: 'counterparty-a', - partyResourceId: 'party-a', - tenantId: tenantA, - }), - ), - ), - ).toBe(true); - yield* runtime.transaction((transaction) => - Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, - 'objects', - ); - yield* transaction.insert(organizationEngagementProfiles).values({ + yield* Effect.addFinalizer(() => cleanup().pipe(Effect.orDie)); + yield* cleanup(); + expect(yield* runtime.select().from(organizationEngagementProfiles)).toEqual([]); + expect( + hasPostgreSqlCode('42501')( + yield* Effect.flip( + runtime.insert(organizationEngagementProfiles).values({ counterpartyResourceId: 'counterparty-a', partyResourceId: 'party-a', tenantId: tenantA, - }); - yield* transaction.insert(personEngagementProfiles).values({ - counterpartyResourceId: 'counterparty-a', - partyResourceId: 'person-a', + }), + ), + ), + ).toBe(true); + yield* runtime.transaction((transaction) => + Effect.gen(function* transactionTestBody() { + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, 'objects'); + yield* transaction.insert(organizationEngagementProfiles).values({ + counterpartyResourceId: 'counterparty-a', + partyResourceId: 'party-a', + tenantId: tenantA, + }); + yield* transaction.insert(personEngagementProfiles).values({ + counterpartyResourceId: 'counterparty-a', + partyResourceId: 'person-a', + tenantId: tenantA, + }); + const [prospect] = yield* transaction + .insert(organizationEngagementProfiles) + .values({ + partyResourceId: 'prospect-a', tenantId: tenantA, - }); - const [prospect] = yield* transaction - .insert(organizationEngagementProfiles) - .values({ - partyResourceId: 'prospect-a', - tenantId: tenantA, - }) - .returning(); - expect(prospect?.counterpartyResourceId).toBe(null); - const [unresolvedPerson] = yield* transaction - .insert(personEngagementProfiles) - .values({ - partyResourceId: 'unresolved-person-a', - tenantId: tenantA, - }) - .returning(); - expect(unresolvedPerson?.counterpartyResourceId).toBe(null); - }), - ); - expect( - hasPostgreSqlCode('23505')( - yield* Effect.flip( - runtime.transaction((transaction) => - Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, - 'objects', - ); - yield* transaction.insert(personEngagementProfiles).values({ - partyResourceId: 'unresolved-person-a', - tenantId: tenantA, - }); - }), - ), + }) + .returning(); + expect(prospect?.counterpartyResourceId).toBe(null); + const [unresolvedPerson] = yield* transaction + .insert(personEngagementProfiles) + .values({ + partyResourceId: 'unresolved-person-a', + tenantId: tenantA, + }) + .returning(); + expect(unresolvedPerson?.counterpartyResourceId).toBe(null); + }), + ); + expect( + hasPostgreSqlCode('23505')( + yield* Effect.flip( + runtime.transaction((transaction) => + Effect.gen(function* transactionTestBody() { + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, 'objects'); + yield* transaction.insert(personEngagementProfiles).values({ + partyResourceId: 'unresolved-person-a', + tenantId: tenantA, + }); + }), ), ), - ).toBe(true); - expect( - hasPostgreSqlCode('23505')( - yield* Effect.flip( - runtime.transaction((transaction) => - Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, - 'objects', - ); - yield* transaction.insert(organizationEngagementProfiles).values({ - counterpartyResourceId: 'counterparty-a', - partyResourceId: 'party-b', - tenantId: tenantA, - }); - }), - ), + ), + ).toBe(true); + expect( + hasPostgreSqlCode('23505')( + yield* Effect.flip( + runtime.transaction((transaction) => + Effect.gen(function* transactionTestBody() { + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantA}, true)`, 'objects'); + yield* transaction.insert(organizationEngagementProfiles).values({ + counterpartyResourceId: 'counterparty-a', + partyResourceId: 'party-b', + tenantId: tenantA, + }); + }), ), ), - ).toBe(true); - yield* runtime.transaction((transaction) => - Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantB}, true)`, - 'objects', - ); - yield* transaction.insert(organizationEngagementProfiles).values({ - counterpartyResourceId: 'counterparty-a', - partyResourceId: 'party-a', - tenantId: tenantB, - }); - expect( - yield* transaction - .select() - .from(organizationEngagementProfiles) - .where(eq(organizationEngagementProfiles.tenantId, tenantA)), - ).toEqual([]); - }), - ); - }), + ), + ).toBe(true); + yield* runtime.transaction((transaction) => + Effect.gen(function* transactionTestBody() { + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantB}, true)`, 'objects'); + yield* transaction.insert(organizationEngagementProfiles).values({ + counterpartyResourceId: 'counterparty-a', + partyResourceId: 'party-a', + tenantId: tenantB, + }); + expect( + yield* transaction + .select() + .from(organizationEngagementProfiles) + .where(eq(organizationEngagementProfiles.tenantId, tenantA)), + ).toEqual([]); + }), + ); + }), ); diff --git a/app/verticals/party-registry/tests/integration/governed-identity.test.ts b/app/verticals/party-registry/tests/integration/governed-identity.test.ts index f449b4795..d1d25b72f 100644 --- a/app/verticals/party-registry/tests/integration/governed-identity.test.ts +++ b/app/verticals/party-registry/tests/integration/governed-identity.test.ts @@ -1,7 +1,8 @@ -import { assert, expect, it } from 'effect-rstest'; +import { randomUUID } from 'node:crypto'; import type { TrustedPrincipalContext } from '@app/core-runtime'; import { + CoreSearchProjectionStoreLive, CoreSearchQueryRuntimeLive, loadDatabaseConnectionPair, ReadRuntime, @@ -9,11 +10,11 @@ import { runAction, } from '@app/core-runtime'; import { makeLiveOperationFixture } from '@app/core-runtime/testing/actions'; - import { and, eq } from 'drizzle-orm'; import { Effect, Exit, Layer, Redacted, Predicate } from 'effect'; -import { randomUUID } from 'node:crypto'; +import { assert, expect, it } from 'effect-rstest'; import { Pool } from 'pg'; + import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; import type { PartyCandidateSchema } from '../../shared/domain/identity-contracts.ts'; import { committedCreateResult } from '../../shared/domain/matching-contracts.ts'; @@ -40,16 +41,10 @@ import { partyOfficialIdentifiers, partyRelations, } from '../../src/db/schema.ts'; -import { - partiesRead, - PartySearchProjectionGatewayLive, -} from '../../src/search/parties.provider.ts'; +import { partiesRead, PartySearchProjectionGatewayLive } from '../../src/search/parties.provider.ts'; type EncodedPartyCandidate = typeof PartyCandidateSchema.Encoded; -const candidate = ( - ico: string, - extra: Partial = {}, -): EncodedPartyCandidate => ({ +const candidate = (ico: string, extra: Partial = {}): EncodedPartyCandidate => ({ partyType: 'ORGANIZATION', officialIdentifiers: [{ identifierType: 'ICO', value: ico, verification: 'VERIFIED' }], subjectEvidence: [ @@ -141,18 +136,9 @@ it.live( const [partyRows, assertions, claims, decisions, cases, core] = yield* Effect.all( [ admin.select().from(parties).where(eq(parties.tenantId, fixture.tenantId)), - admin - .select() - .from(partyFactAssertions) - .where(eq(partyFactAssertions.tenantId, fixture.tenantId)), - admin - .select() - .from(partyIdentifierClaims) - .where(eq(partyIdentifierClaims.tenantId, fixture.tenantId)), - admin - .select() - .from(partyMatchDecisions) - .where(eq(partyMatchDecisions.tenantId, fixture.tenantId)), + admin.select().from(partyFactAssertions).where(eq(partyFactAssertions.tenantId, fixture.tenantId)), + admin.select().from(partyIdentifierClaims).where(eq(partyIdentifierClaims.tenantId, fixture.tenantId)), + admin.select().from(partyMatchDecisions).where(eq(partyMatchDecisions.tenantId, fixture.tenantId)), admin .select() .from(duplicateCandidateCases) @@ -168,10 +154,7 @@ it.live( const concurrent = yield* Effect.all([run(create(exact)), run(create(exact))], { concurrency: 2, }); - expect(concurrent.map((result) => result.outcome).toSorted()).toEqual([ - 'CREATED', - 'MATCHED_EXISTING', - ]); + expect(concurrent.map((result) => result.outcome).toSorted()).toEqual(['CREATED', 'MATCHED_EXISTING']); const created = concurrent.find((result) => result.outcome === 'CREATED'); assert.isOk(created && created.outcome === 'CREATED'); @@ -184,9 +167,7 @@ it.live( expect(state.core.events.length).toBe(1); expect(state.core.outbox.length).toBe(1); expect(state.core.audits.length).toBe(2); - assert.isOk( - state.core.invocations.every((invocation) => invocation.status === 'succeeded'), - ); + assert.isOk(state.core.invocations.every((invocation) => invocation.status === 'succeeded')); assert.isOk(state.assertions[0]?.evidenceEvaluation?.subjectEligible); @@ -195,7 +176,11 @@ it.live( candidate('27074358', { officialIdentifiers: [ ...exact.officialIdentifiers, - { identifierType: 'CZ_DIC', value: 'CZ27074358', verification: 'VERIFIED' }, + { + identifierType: 'CZ_DIC', + value: 'CZ27074358', + verification: 'VERIFIED', + }, ], }), ), @@ -211,8 +196,16 @@ it.live( expect(second.outcome).toBe('CREATED'); const split = candidate('26168685', { officialIdentifiers: [ - { identifierType: 'ICO', value: '26168685', verification: 'VERIFIED' }, - { identifierType: 'CZ_DIC', value: 'CZ27074358', verification: 'VERIFIED' }, + { + identifierType: 'ICO', + value: '26168685', + verification: 'VERIFIED', + }, + { + identifierType: 'CZ_DIC', + value: 'CZ27074358', + verification: 'VERIFIED', + }, ], }); const ambiguity = yield* run(create(split)); @@ -223,86 +216,73 @@ it.live( state = yield* snapshot(); expect(state.partyRows.length).toBe(2); expect(state.cases.length).toBe(1); - expect( - state.decisions.filter((decision) => decision.committedCreateOutcome === 'AMBIGUOUS') - .length, - ).toBe(2); + expect(state.decisions.filter((decision) => decision.committedCreateOutcome === 'AMBIGUOUS').length).toBe(2); // Every Create outcome survives actual lost commit acknowledgement, followed by a new governed Read. const recoveryCandidates = [candidate('45274649'), exact, split]; yield* Effect.forEach( recoveryCandidates, - Effect.fn('GovernedIdentityTest.verifyCommitRecovery')( - function* verifyCommitRecoveryEffect(value) { - const key = randomUUID(); - fixture.faultNextTransaction('lost-ack'); - assert.isOk( - Predicate.isTagged( - yield* run(create(value, key).pipe(Effect.flip)), - 'ActionCommitIndeterminate', - ), - ); - - const before = yield* snapshot(); - const invocation = before.core.invocations.find((row) => row.idempotencyKey === key); - assert.isOk(invocation); - - assert.isOk( - Predicate.isTagged( - yield* run( - resolveActionCommit({ - invocationId: invocation.actionInvocationId, - principal: fixture.manager, - }).pipe(Effect.flip), - ), - 'ActionAlreadyCommitted', - ), - ); - - const recovered = yield* run( - ReadRuntime.pipe( - Effect.flatMap((runtime) => - runtime.runRead({ - registration: partyMatchDecisionRead, - input: { actionInvocationId: invocation.actionInvocationId }, - principal: fixture.manager, - transport: { correlationId: randomUUID() }, - }), - ), - ), - ); - const original = before.decisions.find( - (row) => row.actionInvocationId === invocation.actionInvocationId, - ); - assert.isOk(original); - - const recoveredResult = committedCreateResult(recovered); - assert.isOk(recoveredResult); - - expect(recoveredResult.outcome).toBe(original.committedCreateOutcome); - expect(recoveredResult.decisionRef.resourceId).toBe(original.matchDecisionId); - expect(recovered.partyRef?.resourceId ?? null).toBe(original.partyId); - expect(recovered.caseRef?.resourceId ?? null).toBe(original.candidateCaseId); - assert.isOk( - Predicate.isTagged( - yield* run(create(value, key).pipe(Effect.flip)), - 'ActionAlreadyCommitted', + Effect.fn('GovernedIdentityTest.verifyCommitRecovery')(function* verifyCommitRecoveryEffect(value) { + const key = randomUUID(); + fixture.faultNextTransaction('lost-ack'); + assert.isOk( + Predicate.isTagged(yield* run(create(value, key).pipe(Effect.flip)), 'ActionCommitIndeterminate'), + ); + + const before = yield* snapshot(); + const invocation = before.core.invocations.find((row) => row.idempotencyKey === key); + assert.isOk(invocation); + + assert.isOk( + Predicate.isTagged( + yield* run( + resolveActionCommit({ + invocationId: invocation.actionInvocationId, + principal: fixture.manager, + }).pipe(Effect.flip), ), - ); - - const after = yield* snapshot(); - expect(after.partyRows).toEqual(before.partyRows); - expect(after.decisions).toEqual(before.decisions); - expect(after.core.events).toEqual(before.core.events); - expect(after.core.outbox).toEqual(before.core.outbox); - assert.isOk( - Predicate.isTagged( - yield* run(readPartyDetail(partyRef, fixture.denied).pipe(Effect.flip)), - 'ReadPermissionDenied', + 'ActionAlreadyCommitted', + ), + ); + + const recovered = yield* run( + ReadRuntime.pipe( + Effect.flatMap((runtime) => + runtime.runRead({ + registration: partyMatchDecisionRead, + input: { + actionInvocationId: invocation.actionInvocationId, + }, + principal: fixture.manager, + transport: { correlationId: randomUUID() }, + }), ), - ); - }, - ), + ), + ); + const original = before.decisions.find((row) => row.actionInvocationId === invocation.actionInvocationId); + assert.isOk(original); + + const recoveredResult = committedCreateResult(recovered); + assert.isOk(recoveredResult); + + expect(recoveredResult.outcome).toBe(original.committedCreateOutcome); + expect(recoveredResult.decisionRef.resourceId).toBe(original.matchDecisionId); + expect(recovered.partyRef?.resourceId ?? null).toBe(original.partyId); + expect(recovered.caseRef?.resourceId ?? null).toBe(original.candidateCaseId); + assert.isOk(Predicate.isTagged(yield* run(create(value, key).pipe(Effect.flip)), 'ActionAlreadyCommitted')); + + const after = yield* snapshot(); + expect(after.partyRows).toEqual(before.partyRows); + expect(after.decisions).toEqual(before.decisions); + expect(after.core.events).toEqual(before.core.events); + expect(after.core.outbox).toEqual(before.core.outbox); + assert.isOk( + Predicate.isTagged( + yield* run(readPartyDetail(partyRef, fixture.denied).pipe(Effect.flip)), + 'ReadPermissionDenied', + ), + ); + }), { concurrency: 1, discard: true }, ); const beforeDenied = yield* snapshot(); @@ -329,9 +309,7 @@ it.live( expect(rolledBack.core.events).toEqual(beforeDenied.core.events); expect(rolledBack.core.outbox).toEqual(beforeDenied.core.outbox); - const independent = yield* create(exact, randomUUID(), other.manager).pipe( - Effect.provideContext(otherContext), - ); + const independent = yield* create(exact, randomUUID(), other.manager).pipe(Effect.provideContext(otherContext)); assert.isOk(independent.outcome === 'CREATED'); expect(independent.partyRef.resourceId).not.toBe(partyRef.resourceId); @@ -344,11 +322,7 @@ it.live( assert.isOk( Predicate.isTagged( - yield* run( - create(candidate('00006947'), randomUUID(), fixture.legalEntityOnly).pipe( - Effect.flip, - ), - ), + yield* run(create(candidate('00006947'), randomUUID(), fixture.legalEntityOnly).pipe(Effect.flip)), 'ActionPermissionDenied', ), ); @@ -362,10 +336,9 @@ it.live( const searchLayer = PartySearchProjectionGatewayLive.pipe( Layer.provide(CoreSearchQueryRuntimeLive), + Layer.provide(CoreSearchProjectionStoreLive), ); - const searchContext = yield* Layer.build(searchLayer).pipe( - Effect.provideContext(fixtureContext), - ); + const searchContext = yield* Layer.build(searchLayer).pipe(Effect.provideContext(fixtureContext)); const deniedSearch = ReadRuntime.pipe( Effect.flatMap((runtime) => runtime.runRead({ @@ -377,9 +350,7 @@ it.live( ), Effect.provideContext(searchContext), ); - assert.isOk( - Predicate.isTagged(yield* run(deniedSearch.pipe(Effect.flip)), 'ReadPermissionDenied'), - ); + assert.isOk(Predicate.isTagged(yield* run(deniedSearch.pipe(Effect.flip)), 'ReadPermissionDenied')); assert.isOk( Predicate.isTagged( @@ -455,11 +426,7 @@ it.live( transport: transport(), }).pipe(Effect.flip), ); - yield* fixture.grantResourceAccess( - counterpartyRef, - fixture.legalEntityOnly.principalId, - 'writer', - ); + yield* fixture.grantResourceAccess(counterpartyRef, fixture.legalEntityOnly.principalId, 'writer'); const role = (roleType: 'CUSTOMER' | 'SUPPLIER') => run( runAction({ @@ -482,7 +449,10 @@ it.live( payload: { counterpartyRef, rolePeriodRef: customer.rolePeriodRef, - provenance: { ...provenance, method: 'SIGNED_TERMINATION_AGREEMENT' }, + provenance: { + ...provenance, + method: 'SIGNED_TERMINATION_AGREEMENT', + }, validTo: '2021-01-01T00:00:00.000Z', }, principal: fixture.legalEntityOnly, @@ -490,9 +460,7 @@ it.live( }), ); const counterpartyAfterRoleEnd = yield* readCounterparty(); - expect(counterpartyAfterRoleEnd.currentRoles.map((item) => item.roleType)).toEqual([ - 'SUPPLIER', - ]); + expect(counterpartyAfterRoleEnd.currentRoles.map((item) => item.roleType)).toEqual(['SUPPLIER']); const person = yield* run( create( @@ -547,9 +515,7 @@ it.live( // Domain relationships never provision access to Party records. assert.isOk( Predicate.isTagged( - yield* run( - readPartyDetail(reviewedPerson.partyRef, fixture.legalEntityOnly).pipe(Effect.flip), - ), + yield* run(readPartyDetail(reviewedPerson.partyRef, fixture.legalEntityOnly).pipe(Effect.flip)), 'ReadPermissionDenied', ), ); @@ -638,9 +604,7 @@ it.live( transport: transport(), }), ); - assert.isOk( - collision.outcome === 'BLOCKED' && collision.reasonCode === 'EXACT_CLAIM_CONFLICT', - ); + assert.isOk(collision.outcome === 'BLOCKED' && collision.reasonCode === 'EXACT_CLAIM_CONFLICT'); const current = yield* run(readPartyDetail(partyRef, fixture.manager)); const archived = yield* run( @@ -674,9 +638,7 @@ it.live( }), ); expect(unarchive.outcome).toBe('BLOCKED'); - assert.isOk( - unarchive.outcome === 'BLOCKED' && unarchive.reasonCode === 'OPEN_DUPLICATE_CASE', - ); + assert.isOk(unarchive.outcome === 'BLOCKED' && unarchive.reasonCode === 'OPEN_DUPLICATE_CASE'); }), ), ); diff --git a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts index 8f517f511..8ae3257b9 100644 --- a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts +++ b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts @@ -1,9 +1,9 @@ -import { openBoundaryDatabases } from '../support/database-boundary.ts'; -import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; -import { expect, it } from 'effect-rstest'; import { eq, sql } from 'drizzle-orm'; import { DateTime, Effect, Option } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { normalizeOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; import { partySubjectKeyFromString } from '../../shared/domain/identity-contracts.ts'; import { @@ -17,126 +17,115 @@ import { partyRelations, } from '../../src/db/schema.ts'; import type { PartyTransaction } from '../../src/db/types.ts'; -import { - lockAndResolveClaims, - lockTenantIdentityWrites, -} from '../../src/services/party-identifier-claim.service.ts'; +import { lockAndResolveClaims, lockTenantIdentityWrites } from '../../src/services/party-identifier-claim.service.ts'; import { createOrMatchParty } from '../../src/services/party-matching-persistence.service.ts'; import { addOfficialIdentifierRecord } from '../../src/services/party-official-identifier-persistence.service.ts'; +import { openBoundaryDatabases } from '../support/database-boundary.ts'; const tenantId = 'bc100000-0000-4000-8000-000000000001'; const principalId = 'bc200000-0000-4000-8000-000000000001'; -it.live( - 'real PostgreSQL identity locks serialize concurrent exact creates and repeated identifier acceptance', - () => - Effect.gen(function* identityConcurrencyTest() { - const { admin, runtime } = yield* openBoundaryDatabases( - (pool) => makeTestDatabaseFromPool(pool, partyRelations), - 2, - ); - const cleanup = purgeFixtureRows( - [ - partyMatchDecisions, - duplicateCandidateCaseParties, - duplicateCandidateCases, - partyIdentifierClaims, - partyOfficialIdentifiers, - partyFactAssertions, - parties, - ].map((table) => admin.delete(table).where(eq(table.tenantId, tenantId))), +it.live('real PostgreSQL identity locks serialize concurrent exact creates and repeated identifier acceptance', () => + Effect.gen(function* identityConcurrencyTest() { + const { admin, runtime } = yield* openBoundaryDatabases( + (pool) => makeTestDatabaseFromPool(pool, partyRelations), + 2, + ); + const cleanup = purgeFixtureRows( + [ + partyMatchDecisions, + duplicateCandidateCaseParties, + duplicateCandidateCases, + partyIdentifierClaims, + partyOfficialIdentifiers, + partyFactAssertions, + parties, + ].map((table) => admin.delete(table).where(eq(table.tenantId, tenantId))), + ); + const scoped = (operation: (transaction: PartyTransaction) => Effect.Effect) => + runtime.transaction((transaction) => + Effect.gen(function* transactionTestBody() { + yield* transaction.execute(sql`select set_config('ontos.tenant_id', ${tenantId}, true)`, 'objects'); + return yield* operation(transaction); + }), ); - const scoped = ( - operation: (transaction: PartyTransaction) => Effect.Effect, - ) => - runtime.transaction((transaction) => - Effect.gen(function* transactionTestBody() { - yield* transaction.execute( - sql`select set_config('ontos.tenant_id', ${tenantId}, true)`, - 'objects', - ); - return yield* operation(transaction); + yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); + const candidate = { + evidenceRefs: ['evidence-artifact:identity-concurrency:registry'], + officialIdentifiers: [ + { + identifierType: 'ICO' as const, + value: '27074358', + verification: 'VERIFIED' as const, + }, + ], + partyType: 'ORGANIZATION' as const, + provenance: { method: 'REGISTRY', source: 'identity-concurrency-test' }, + subjectEvidence: [ + { + basis: 'REVIEWED_DOCUMENT' as const, + evidenceRef: 'record/42', + kind: 'ACTOR_ATTESTATION' as const, + observedSubject: 'ORGANIZATION' as const, + statement: 'Reviewed this external organization', + subjectKey: partySubjectKeyFromString('one-subject'), + }, + ], + validFrom: DateTime.makeUnsafe('2020-01-01T00:00:00.000Z'), + }; + const results = yield* Effect.forEach( + ['bc300000-0000-4000-8000-000000000001', 'bc300000-0000-4000-8000-000000000002'], + (actionInvocationId) => + scoped((transaction) => + createOrMatchParty(transaction, { + actionInvocationId, + candidate, + principalId, + tenantId, }), - ); - yield* Effect.acquireRelease(cleanup, () => cleanup.pipe(Effect.orDie)); - const candidate = { - evidenceRefs: ['evidence-artifact:identity-concurrency:registry'], - officialIdentifiers: [ - { identifierType: 'ICO' as const, value: '27074358', verification: 'VERIFIED' as const }, - ], - partyType: 'ORGANIZATION' as const, - provenance: { method: 'REGISTRY', source: 'identity-concurrency-test' }, - subjectEvidence: [ - { - basis: 'REVIEWED_DOCUMENT' as const, - evidenceRef: 'record/42', - kind: 'ACTOR_ATTESTATION' as const, - observedSubject: 'ORGANIZATION' as const, - statement: 'Reviewed this external organization', - subjectKey: partySubjectKeyFromString('one-subject'), - }, - ], - validFrom: DateTime.makeUnsafe('2020-01-01T00:00:00.000Z'), - }; - const results = yield* Effect.all( - ['bc300000-0000-4000-8000-000000000001', 'bc300000-0000-4000-8000-000000000002'].map( - (actionInvocationId) => - scoped((transaction) => - createOrMatchParty(transaction, { - actionInvocationId, - candidate, - principalId, - tenantId, - }), - ), ), - { concurrency: 'unbounded' }, + { concurrency: 'unbounded' }, + ); + expect(results.map((result) => result.outcome).toSorted()).toEqual(['CREATED', 'MATCHED_EXISTING']); + const created = Option.getOrThrow(Option.fromNullishOr(results.find((result) => result.outcome === 'CREATED'))); + expect(created.outcome).toBe('CREATED'); + const partyId = created.partyRef.resourceId; + const canonical = yield* admin.select().from(parties).where(eq(parties.tenantId, tenantId)); + expect(canonical).toHaveLength(1); + expect(canonical[0]?.currentDisplayName).toBe(null); + const nameAssertions = yield* admin + .select() + .from(partyFactAssertions) + .where(eq(partyFactAssertions.tenantId, tenantId)); + expect(nameAssertions.some((row) => row.factKind === 'DISPLAY_NAME')).toBe(false); + const identifier = normalizeOfficialIdentifier({ + identifierType: 'CZ_DIC', + value: 'CZ27074358', + verification: 'VERIFIED', + }); + const add = (actionInvocationId: string) => + scoped((transaction) => + Effect.gen(function* acceptIdentifier() { + yield* lockTenantIdentityWrites(transaction, tenantId); + yield* lockAndResolveClaims(transaction, tenantId, [identifier]); + return yield* addOfficialIdentifierRecord(transaction, tenantId, partyId, identifier, { + actionInvocationId, + matchRuleVersion: 'party-exact-claims.v1', + partyType: 'ORGANIZATION', + principalId, + provenanceMethod: 'REGISTRY', + provenanceSource: 'identity-concurrency-test', + validFrom: candidate.validFrom, + }); + }), ); - expect(results.map((result) => result.outcome).toSorted()).toEqual([ - 'CREATED', - 'MATCHED_EXISTING', - ]); - const created = Option.getOrThrow( - Option.fromNullishOr(results.find((result) => result.outcome === 'CREATED')), - ); - expect(created.outcome).toBe('CREATED'); - const partyId = created.partyRef.resourceId; - const canonical = yield* admin.select().from(parties).where(eq(parties.tenantId, tenantId)); - expect(canonical).toHaveLength(1); - expect(canonical[0]?.currentDisplayName).toBe(null); - const nameAssertions = yield* admin - .select() - .from(partyFactAssertions) - .where(eq(partyFactAssertions.tenantId, tenantId)); - expect(nameAssertions.some((row) => row.factKind === 'DISPLAY_NAME')).toBe(false); - const identifier = normalizeOfficialIdentifier({ - identifierType: 'CZ_DIC', - value: 'CZ27074358', - verification: 'VERIFIED', - }); - const add = (actionInvocationId: string) => - scoped((transaction) => - Effect.gen(function* acceptIdentifier() { - yield* lockTenantIdentityWrites(transaction, tenantId); - yield* lockAndResolveClaims(transaction, tenantId, [identifier]); - return yield* addOfficialIdentifierRecord(transaction, tenantId, partyId, identifier, { - actionInvocationId, - matchRuleVersion: 'party-exact-claims.v1', - partyType: 'ORGANIZATION', - principalId, - provenanceMethod: 'REGISTRY', - provenanceSource: 'identity-concurrency-test', - validFrom: candidate.validFrom, - }); - }), - ); - const first = yield* add('bc300000-0000-4000-8000-000000000003'); - const repeated = yield* add('bc300000-0000-4000-8000-000000000004'); - expect(repeated.officialIdentifierId).toBe(first.officialIdentifierId); - const claims = yield* admin - .select() - .from(partyIdentifierClaims) - .where(eq(partyIdentifierClaims.tenantId, tenantId)); - expect(claims).toHaveLength(2); - }), + const first = yield* add('bc300000-0000-4000-8000-000000000003'); + const repeated = yield* add('bc300000-0000-4000-8000-000000000004'); + expect(repeated.officialIdentifierId).toBe(first.officialIdentifierId); + const claims = yield* admin + .select() + .from(partyIdentifierClaims) + .where(eq(partyIdentifierClaims.tenantId, tenantId)); + expect(claims).toHaveLength(2); + }), ); diff --git a/app/verticals/party-registry/tests/support/command-assertion-fetch.ts b/app/verticals/party-registry/tests/support/command-assertion-fetch.ts index defda385e..59cbc1577 100644 --- a/app/verticals/party-registry/tests/support/command-assertion-fetch.ts +++ b/app/verticals/party-registry/tests/support/command-assertion-fetch.ts @@ -1,7 +1,4 @@ -export const makeCommandAssertionFetch = ( - ownerResponse: (request: Request) => Response, - tokenPrefix: string, -) => { +export const makeCommandAssertionFetch = (ownerResponse: (request: Request) => Response, tokenPrefix: string) => { const requests: Request[] = []; let assertions = 0; const fakeFetch: typeof fetch = (input, init) => { diff --git a/app/verticals/party-registry/tests/unit/api-integration-ares-application.test.ts b/app/verticals/party-registry/tests/unit/api-integration-ares-application.test.ts index d4f158f54..632884899 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-ares-application.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-ares-application.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect, Layer, Match, Option, Result, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; + import { AresAppliedEvidenceSchema, makeAresAppliedEvidence, @@ -8,10 +9,7 @@ import { } from '../../shared/domain/ares-application.ts'; import type { AresAppliedEvidence } from '../../shared/domain/ares-application.ts'; import { AresSubjectEvidenceSchema } from '../../shared/domain/ares-evidence.ts'; -import { - AssertionIdSchema, - TargetAssertionIdSchema, -} from '../../shared/domain/correction-contracts.ts'; +import { AssertionIdSchema, TargetAssertionIdSchema } from '../../shared/domain/correction-contracts.ts'; import { PartyIdSchema } from '../../shared/domain/identity-contracts.ts'; import { AresApplySelectionInvalid, @@ -37,7 +35,7 @@ const actionValidFrom = DateTime.makeUnsafe('2026-09-03T09:59:00.000Z'); const partyCreatedAt = DateTime.makeUnsafe('2026-09-01T10:00:00.000Z'); const partyUpdatedAt = DateTime.makeUnsafe('2026-09-03T10:00:00.000Z'); const currentAssertionId = Result.getOrThrow( - Schema.decodeUnknownResult(AssertionIdSchema)('30000000-0000-4000-8000-000000000001'), + Schema.decodeResult(AssertionIdSchema)('30000000-0000-4000-8000-000000000001'), ); const application = { decidedAt: confirmedAt, @@ -90,9 +88,7 @@ const application = { outcome: 'APPLY_ENRICHMENT' as const, userConfirmed: true, }; -const decodedObservation = Result.getOrThrow( - Schema.decodeUnknownResult(AresSubjectEvidenceSchema)(application.evidence), -); +const decodedObservation = Result.getOrThrow(Schema.decodeResult(AresSubjectEvidenceSchema)(application.evidence)); const request: AresApplyRequest = { correlationId: 'ares-test-correlation', observation: application.evidence, @@ -133,10 +129,7 @@ const request: AresApplyRequest = { class TestFailure extends Schema.TaggedError()('TestFailure', { action: Schema.String, }) {} -const makeInvoker = ( - calls: string[], - failAction?: string, -): PartyRegistryStandardActionInvoker => { +const makeInvoker = (calls: string[], failAction?: string): PartyRegistryStandardActionInvoker => { const complete = (action: string, value: Value) => { calls.push(action); return failAction === action ? Effect.fail(new TestFailure({ action })) : Effect.succeed(value); @@ -165,9 +158,7 @@ const makeInvoker = ( }), }; }; -const gateway = makeOperationGateway(() => - Effect.succeed({ expiresAt: 1_788_430_000, token: 'signed-gateway-token' }), -); +const gateway = makeOperationGateway(() => Effect.succeed({ expiresAt: 1_788_430_000, token: 'signed-gateway-token' })); const makeReads = (displayName: string | null = null): AresApplyReads => ({ contactPoints: () => Effect.succeed({ items: [] }), identifiers: () => Effect.succeed({ items: [] }), @@ -211,10 +202,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica Match.orElse(() => false), ), ).toBe(true); - expect(outcome.completed.map(({ route }) => route)).toEqual([ - 'PARTY_UPDATE', - 'IDENTIFIER_ADD', - ]); + expect(outcome.completed.map(({ route }) => route)).toEqual(['PARTY_UPDATE', 'IDENTIFIER_ADD']); }), ); aresIt.effect('propagates bounded evidence and independent command delivery keys', () => @@ -247,10 +235,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica gateway, reads: makeReads(), }); - expect(recorded.map(({ idempotencyKey }) => idempotencyKey)).toEqual([ - 'ares-name-1', - 'ares-ico-1', - ]); + expect(recorded.map(({ idempotencyKey }) => idempotencyKey)).toEqual(['ares-name-1', 'ares-ico-1']); expect( recorded.every(({ evidenceRef }) => evidenceRef === undefined ? false : evidenceRef.includes('ares:12345678'), @@ -299,7 +284,11 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica ), ).toBe(true); expect(outcome.skipped).toEqual([ - { fact: 'BUSINESS_NAME', reason: 'ALREADY_SATISFIED', route: 'PARTY_UPDATE' }, + { + fact: 'BUSINESS_NAME', + reason: 'ALREADY_SATISFIED', + route: 'PARTY_UPDATE', + }, ]); expect(outcome.completed.map(({ route }) => route)).toEqual(['IDENTIFIER_ADD']); }), @@ -311,7 +300,10 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica ...request, selections: request.selections.map((selection) => selection.route === 'PARTY_UPDATE' - ? { ...selection, payload: { ...selection.payload, expectedRevision: 2 } } + ? { + ...selection, + payload: { ...selection.payload, expectedRevision: 2 }, + } : selection, ), }; @@ -332,7 +324,10 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica gateway, reads: makeReads(), }), - applyAresObservation(request, makeInvoker(calls), { gateway, reads: changedReads }), + applyAresObservation(request, makeInvoker(calls), { + gateway, + reads: changedReads, + }), ], { concurrency: 'unbounded' }, ); @@ -351,109 +346,108 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica expect(calls).toEqual([]); }), ); - aresIt.effect( - 'rejects unconfirmed or observation-mismatched selections before invoking an Action', - () => - Effect.gen(function* rejectsUnconfirmedOrObservationmismatchedSelections() { - const calls: string[] = []; - const invalidRequests: readonly AresApplyRequest[] = [ - { - ...request, - userConfirmed: false, - }, - { - correlationId: request.correlationId, - observation: request.observation, - partyRef, - selections: [ - { - fact: 'BUSINESS_NAME', - idempotencyKey: 'ares-invalid-name-1', - payload: { - displayName: 'Injected name', - expectedRevision: 1, - partyRef, - provenanceMethod: 'ARES_USER_CONFIRMED', - provenanceSource: 'ares:12345678', - validFrom: actionValidFrom, - }, - route: 'PARTY_UPDATE', - }, - ], - userConfirmed: true, - }, - ]; - const results = yield* Effect.all( - invalidRequests.map((invalidRequest) => - applyAresObservation(invalidRequest, makeInvoker(calls), { - gateway, - reads: makeReads(), - }).pipe(Effect.result), - ), - { concurrency: 'unbounded' }, - ); - for (const result of results) { - expect('failure' in result).toBe(true); - if ('failure' in result) { - expect(Schema.is(AresApplySelectionInvalid)(result.failure)).toBe(true); - } - } - expect(calls).toEqual([]); - }), - ); - aresIt.effect( - 'does not accept a different street number as the observed registered address', - () => - Effect.gen(function* doesNotAcceptADifferent() { - const calls: string[] = []; - const invalidRequest: AresApplyRequest = { + aresIt.effect('rejects unconfirmed or observation-mismatched selections before invoking an Action', () => + Effect.gen(function* rejectsUnconfirmedOrObservationmismatchedSelections() { + const calls: string[] = []; + const invalidRequests: readonly AresApplyRequest[] = [ + { + ...request, + userConfirmed: false, + }, + { correlationId: request.correlationId, observation: request.observation, partyRef, selections: [ { - fact: 'REGISTERED_ADDRESS', - idempotencyKey: 'ares-address-1', + fact: 'BUSINESS_NAME', + idempotencyKey: 'ares-invalid-name-1', payload: { - contactPoint: { - address: { - addressLine1: 'Main 100', - city: 'Prague', - countryCode: 'CZ', - postalCode: '11000', - }, - purposes: [ - { - preferred: false, - purpose: 'REGISTERED', - registryContext: { jurisdiction: 'CZ', registryKey: 'ARES' }, - }, - ], - type: 'ADDRESS', - }, + displayName: 'Injected name', + expectedRevision: 1, partyRef, - privacyClassification: 'PUBLIC', - provenance: { - authoritative: true, - evidenceReference: 'ares:12345678', - method: 'PROVIDER_OBSERVATION', - source: 'EXTERNAL_EVIDENCE', - }, - validFrom: decodedObservation.observedAt, - verification: { state: 'UNVERIFIED' }, + provenanceMethod: 'ARES_USER_CONFIRMED', + provenanceSource: 'ares:12345678', + validFrom: actionValidFrom, }, - route: 'CONTACT_POINT_ADD', + route: 'PARTY_UPDATE', }, ], userConfirmed: true, - }; - const result = yield* applyAresObservation(invalidRequest, makeInvoker(calls), { - gateway, - reads: makeReads(), - }).pipe(Effect.result); + }, + ]; + const results = yield* Effect.forEach( + invalidRequests, + (invalidRequest) => + applyAresObservation(invalidRequest, makeInvoker(calls), { + gateway, + reads: makeReads(), + }).pipe(Effect.result), + { concurrency: 'unbounded' }, + ); + for (const result of results) { expect('failure' in result).toBe(true); - expect(calls).toEqual([]); - }), + if ('failure' in result) { + expect(Schema.is(AresApplySelectionInvalid)(result.failure)).toBe(true); + } + } + expect(calls).toEqual([]); + }), + ); + aresIt.effect('does not accept a different street number as the observed registered address', () => + Effect.gen(function* doesNotAcceptADifferent() { + const calls: string[] = []; + const invalidRequest: AresApplyRequest = { + correlationId: request.correlationId, + observation: request.observation, + partyRef, + selections: [ + { + fact: 'REGISTERED_ADDRESS', + idempotencyKey: 'ares-address-1', + payload: { + contactPoint: { + address: { + addressLine1: 'Main 100', + city: 'Prague', + countryCode: 'CZ', + postalCode: '11000', + }, + purposes: [ + { + preferred: false, + purpose: 'REGISTERED', + registryContext: { + jurisdiction: 'CZ', + registryKey: 'ARES', + }, + }, + ], + type: 'ADDRESS', + }, + partyRef, + privacyClassification: 'PUBLIC', + provenance: { + authoritative: true, + evidenceReference: 'ares:12345678', + method: 'PROVIDER_OBSERVATION', + source: 'EXTERNAL_EVIDENCE', + }, + validFrom: decodedObservation.observedAt, + verification: { state: 'UNVERIFIED' }, + }, + route: 'CONTACT_POINT_ADD', + }, + ], + userConfirmed: true, + }; + const result = yield* applyAresObservation(invalidRequest, makeInvoker(calls), { + gateway, + reads: makeReads(), + }).pipe(Effect.result); + expect('failure' in result).toBe(true); + expect(calls).toEqual([]); + }), ); const historicalEvidence = (fact: 'BUSINESS_NAME' | 'ICO'): AresAppliedEvidence => { const result = deriveAresEvidenceApplication({ @@ -481,167 +475,148 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica evidenceRefs: ['review:ares'], evidenceSource: 'MANUAL_REVIEW', factKind: 'DISPLAY_NAME', - partyId: Result.getOrThrow(Schema.decodeUnknownResult(PartyIdSchema)(partyRef.resourceId)), + partyId: Result.getOrThrow(Schema.decodeResult(PartyIdSchema)(partyRef.resourceId)), policyVersion: 'party-correction.v1', provenance: { method: 'REVIEW', source: 'ARES' }, reasonCode: 'WRONG_IDENTITY_VALUE', replacementValue: 'Example s.r.o.', targetAssertionId: Result.getOrThrow( - Schema.decodeUnknownResult(TargetAssertionIdSchema)('30000000-0000-4000-8000-000000000001'), + Schema.decodeResult(TargetAssertionIdSchema)('30000000-0000-4000-8000-000000000001'), ), }, route: 'PARTY_CORRECTION', }; - aresIt.effect( - 'review-authorized assertion context returns explicit Correction handoff without a write', - () => - Effect.gen(function* reviewauthorizedAssertionContextReturnsExplicit() { - const calls: string[] = []; - const reads = makeReads('Wrong name'); - let reviewed = false; - const result = yield* applyAresObservation( - { ...request, selections: [correctionSelection] }, - makeInvoker(calls), - { - gateway, - reads: { - ...reads, - party: (payload, ...args) => { - reviewed = payload.includeFactHistory === true; - return reads.party(payload, ...args).pipe( - Effect.map((detail) => ({ - ...detail, - currentFactAssertions: [ - { - assertionId: currentAssertionId, - externalEvidence: Option.some(historicalEvidence('BUSINESS_NAME')), - factKind: 'DISPLAY_NAME' as const, - isCurrent: true, - partyRef, - recordedAt: confirmedInstant, - retractsAssertionId: Option.none(), - state: 'ACTIVE' as const, - supersedesAssertionId: Option.none(), - validFrom: confirmedInstant, - validTo: Option.none(), - value: 'Wrong name', - }, - ], - })), - ); - }, - }, - }, - ); - expect(reviewed).toBe(true); - const deferred = Match.value(result).pipe( - Match.tag('AresApplyDeferred', (value) => value), - Match.orElse(() => expect.unreachable('Expected a deferred ARES application')), - ); - expect(deferred.application.outcome).toBe('CORRECTION_CANDIDATE'); - expect(deferred.correctionCandidates[0]?.targetAssertionId).toBe( - '30000000-0000-4000-8000-000000000001', - ); - expect(deferred.correctionCandidates[0]?.observedValue).toBe('Example s.r.o.'); - expect(calls).toEqual([]); - }), - ); - aresIt.effect( - 'governed identifier history supports ICO correction suspicion without claiming the identifier', - () => - Effect.gen(function* governedIdentifierHistorySupportsIco() { - const calls: string[] = []; - const selection = Option.getOrThrow(Option.fromNullishOr(request.selections[1])); - expect(selection).toBeDefined(); - const encodedEvidence = yield* Schema.encodeEffect(AresAppliedEvidenceSchema)( - historicalEvidence('ICO'), - ); - const outcome = yield* applyAresObservation( - { ...request, selections: [selection] }, - makeInvoker(calls), - { - gateway, - reads: { - ...makeReads(), - identifiers: () => - Effect.succeed({ - items: [ + aresIt.effect('review-authorized assertion context returns explicit Correction handoff without a write', () => + Effect.gen(function* reviewauthorizedAssertionContextReturnsExplicit() { + const calls: string[] = []; + const reads = makeReads('Wrong name'); + let reviewed = false; + const result = yield* applyAresObservation( + { ...request, selections: [correctionSelection] }, + makeInvoker(calls), + { + gateway, + reads: { + ...reads, + party: (payload, ...args) => { + reviewed = payload.includeFactHistory === true; + return reads.party(payload, ...args).pipe( + Effect.map((detail) => ({ + ...detail, + currentFactAssertions: [ { - externalEvidence: encodedEvidence, - identifierType: 'ICO' as const, - namespace: 'CZ:ICO', - normalizedValue: '87654321', - officialIdentifierRef: { - moduleId: 'party.registry' as const, - resourceId: '40000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.party-official-identifier' as const, - tenantId: partyRef.tenantId, - }, + assertionId: currentAssertionId, + externalEvidence: Option.some(historicalEvidence('BUSINESS_NAME')), + factKind: 'DISPLAY_NAME' as const, + isCurrent: true, partyRef, - recordedAt: application.decidedAt, + recordedAt: confirmedInstant, + retractsAssertionId: Option.none(), state: 'ACTIVE' as const, - validFrom: application.decidedAt, - validTo: null, - verification: 'VERIFIED' as const, + supersedesAssertionId: Option.none(), + validFrom: confirmedInstant, + validTo: Option.none(), + value: 'Wrong name', }, ], - }), + })), + ); }, }, - ); - const deferred = Match.value(outcome).pipe( - Match.tag('AresApplyDeferred', (value) => value), - Match.orElse(() => expect.unreachable('Expected a deferred ARES application')), - ); - expect(deferred.application.outcome).toBe('CORRECTION_CANDIDATE'); - expect(deferred.correctionCandidates[0]?.fact).toBe('ICO'); - expect(calls).toEqual([]); - }), + }, + ); + expect(reviewed).toBe(true); + const deferred = Match.value(result).pipe( + Match.tag('AresApplyDeferred', (value) => value), + Match.orElse(() => expect.unreachable('Expected a deferred ARES application')), + ); + expect(deferred.application.outcome).toBe('CORRECTION_CANDIDATE'); + expect(deferred.correctionCandidates[0]?.targetAssertionId).toBe('30000000-0000-4000-8000-000000000001'); + expect(deferred.correctionCandidates[0]?.observedValue).toBe('Example s.r.o.'); + expect(calls).toEqual([]); + }), ); - aresIt.effect( - 'every governed read and selected Action receives fresh audience-scoped authorization', - () => - Effect.gen(function* everyGovernedReadAndSelected() { - const tokens: string[] = []; - const calls: string[] = []; - const delegate = makeReads(); - const issued = makeOperationGateway(() => { - const token = `token-${tokens.length + 1}`; - tokens.push(token); - return Effect.succeed({ expiresAt: 1_788_430_000, token }); - }); - const authorized: string[] = []; - const reads: AresApplyReads = { - contactPoints: (payload, authorization, ...rest) => { - authorized.push(authorization); - return delegate.contactPoints(payload, authorization, ...rest); - }, - identifiers: (payload, authorization, ...rest) => { - authorized.push(authorization); - return delegate.identifiers(payload, authorization, ...rest); - }, - observation: (payload, authorization, ...rest) => { - authorized.push(authorization); - return delegate.observation(payload, authorization, ...rest); - }, - party: (payload, authorization, ...rest) => { - authorized.push(authorization); - expect(payload.includeFactHistory).toBe(undefined); - return delegate.party(payload, authorization, ...rest); - }, - }; - yield* applyAresObservation(request, makeInvoker(calls), { gateway: issued, reads }); - expect(authorized).toEqual([ - 'Bearer token-1', - 'Bearer token-2', - 'Bearer token-3', - 'Bearer token-4', - ]); - expect(calls).toEqual([ - 'update-party|Bearer token-5', - 'add-party-official-identifier|Bearer token-6', - ]); - }), + aresIt.effect('governed identifier history supports ICO correction suspicion without claiming the identifier', () => + Effect.gen(function* governedIdentifierHistorySupportsIco() { + const calls: string[] = []; + const selection = Option.getOrThrow(Option.fromNullishOr(request.selections[1])); + expect(selection).toBeDefined(); + const encodedEvidence = yield* Schema.encodeEffect(AresAppliedEvidenceSchema)(historicalEvidence('ICO')); + const outcome = yield* applyAresObservation({ ...request, selections: [selection] }, makeInvoker(calls), { + gateway, + reads: { + ...makeReads(), + identifiers: () => + Effect.succeed({ + items: [ + { + externalEvidence: encodedEvidence, + identifierType: 'ICO' as const, + namespace: 'CZ:ICO', + normalizedValue: '87654321', + officialIdentifierRef: { + moduleId: 'party.registry' as const, + resourceId: '40000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.party-official-identifier' as const, + tenantId: partyRef.tenantId, + }, + partyRef, + recordedAt: application.decidedAt, + state: 'ACTIVE' as const, + validFrom: application.decidedAt, + validTo: null, + verification: 'VERIFIED' as const, + }, + ], + }), + }, + }); + const deferred = Match.value(outcome).pipe( + Match.tag('AresApplyDeferred', (value) => value), + Match.orElse(() => expect.unreachable('Expected a deferred ARES application')), + ); + expect(deferred.application.outcome).toBe('CORRECTION_CANDIDATE'); + expect(deferred.correctionCandidates[0]?.fact).toBe('ICO'); + expect(calls).toEqual([]); + }), + ); + aresIt.effect('every governed read and selected Action receives fresh audience-scoped authorization', () => + Effect.gen(function* everyGovernedReadAndSelected() { + const tokens: string[] = []; + const calls: string[] = []; + const delegate = makeReads(); + const issued = makeOperationGateway(() => { + const token = `token-${tokens.length + 1}`; + tokens.push(token); + return Effect.succeed({ expiresAt: 1_788_430_000, token }); + }); + const authorized: string[] = []; + const reads: AresApplyReads = { + contactPoints: (payload, authorization, ...rest) => { + authorized.push(authorization); + return delegate.contactPoints(payload, authorization, ...rest); + }, + identifiers: (payload, authorization, ...rest) => { + authorized.push(authorization); + return delegate.identifiers(payload, authorization, ...rest); + }, + observation: (payload, authorization, ...rest) => { + authorized.push(authorization); + return delegate.observation(payload, authorization, ...rest); + }, + party: (payload, authorization, ...rest) => { + authorized.push(authorization); + expect(payload.includeFactHistory).toBe(undefined); + return delegate.party(payload, authorization, ...rest); + }, + }; + yield* applyAresObservation(request, makeInvoker(calls), { + gateway: issued, + reads, + }); + expect(authorized).toEqual(['Bearer token-1', 'Bearer token-2', 'Bearer token-3', 'Bearer token-4']); + expect(calls).toEqual(['update-party|Bearer token-5', 'add-party-official-identifier|Bearer token-6']); + }), ); aresIt.effect('read denial fails before writes and preserves its declared error', () => Effect.gen(function* readDenialFailsBeforeWrites() { @@ -677,8 +652,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica ...detail, party: { ...detail.party, - archivedAt: - kind === 'ARCHIVED' ? Option.some(confirmedInstant) : Option.none(), + archivedAt: kind === 'ARCHIVED' ? Option.some(confirmedInstant) : Option.none(), }, resolution: { ...detail.resolution, @@ -695,9 +669,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica if ('success' in result) { Match.value(result.success).pipe( Match.tag('AresApplyDeferred', () => null), - Match.orElse(() => - expect.unreachable('Expected an archived Party to defer ARES application'), - ), + Match.orElse(() => expect.unreachable('Expected an archived Party to defer ARES application')), ); } } @@ -733,141 +705,126 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica expect(calls).toEqual([]); }), ); - aresIt.effect( - 'retry preserves exact command payload and reports required standard recovery', - () => - Effect.gen(function* retryPreservesExactCommandPayload() { - const payloads: unknown[] = []; - const calls: string[] = []; - const delegate = makeInvoker(calls, 'update-party|Bearer signed-gateway-token'); - const invoker: PartyRegistryStandardActionInvoker = { - ...delegate, - updateParty: (payload, auth, options) => { - payloads.push({ options, payload }); - return delegate.updateParty(payload, auth, options); - }, - }; - for (let retry = 0; retry < 2; retry += 1) { - const result = yield* applyAresObservation(request, invoker, { - gateway, - reads: makeReads(), - }); - const partial = Match.value(result).pipe( - Match.tag('AresApplyPartiallyCompleted', (value) => value), - Match.orElse(() => - expect.unreachable('Expected a partially completed ARES application'), - ), - ); - expect(partial.failed.idempotencyKey).toBe('ares-name-1'); - expect(partial.failed.recovery).toBe('RESOLVE_STANDARD_ACTION_BEFORE_RETRY'); - } - expect(payloads[0]).toEqual(payloads[1]); - expect(calls).toEqual([ - 'update-party|Bearer signed-gateway-token', - 'update-party|Bearer signed-gateway-token', - ]); - }), - ); - aresIt.effect( - 'failed second Action stops the following supported address and retains prior commit receipt', - () => - Effect.gen(function* failedSecondActionStopsThe() { - const calls: string[] = []; - const address: AresApplyRequest['selections'][number] = { - fact: 'REGISTERED_ADDRESS', - idempotencyKey: 'ares-address-1', - payload: { - contactPoint: { - address: { - addressLine1: 'Main 10', - city: 'Prague', - countryCode: 'CZ', - postalCode: '11000', - }, - purposes: [ - { - preferred: false, - purpose: 'REGISTERED', - registryContext: { jurisdiction: 'CZ', registryKey: 'ARES' }, - }, - ], - type: 'ADDRESS', - }, - partyRef, - privacyClassification: 'PUBLIC', - provenance: { - authoritative: true, - evidenceReference: 'ares:12345678', - method: 'PROVIDER_OBSERVATION', - source: 'EXTERNAL_EVIDENCE', - }, - validFrom: decodedObservation.observedAt, - verification: { state: 'UNVERIFIED' }, - }, - route: 'CONTACT_POINT_ADD', - }; - const delegate = makeInvoker( - calls, - 'add-party-official-identifier|Bearer signed-gateway-token', + aresIt.effect('retry preserves exact command payload and reports required standard recovery', () => + Effect.gen(function* retryPreservesExactCommandPayload() { + const payloads: unknown[] = []; + const calls: string[] = []; + const delegate = makeInvoker(calls, 'update-party|Bearer signed-gateway-token'); + const invoker: PartyRegistryStandardActionInvoker = { + ...delegate, + updateParty: (payload, auth, options) => { + payloads.push({ options, payload }); + return delegate.updateParty(payload, auth, options); + }, + }; + for (let retry = 0; retry < 2; retry += 1) { + const result = yield* applyAresObservation(request, invoker, { + gateway, + reads: makeReads(), + }); + const partial = Match.value(result).pipe( + Match.tag('AresApplyPartiallyCompleted', (value) => value), + Match.orElse(() => expect.unreachable('Expected a partially completed ARES application')), ); - const outcome = yield* applyAresObservation( - { ...request, selections: [...request.selections, address] }, - { - ...delegate, - addContactPoint: () => { - calls.push('unexpected-address'); - return Effect.fail(new TestFailure({ action: 'address' })); + expect(partial.failed.idempotencyKey).toBe('ares-name-1'); + expect(partial.failed.recovery).toBe('RESOLVE_STANDARD_ACTION_BEFORE_RETRY'); + } + expect(payloads[0]).toEqual(payloads[1]); + expect(calls).toEqual(['update-party|Bearer signed-gateway-token', 'update-party|Bearer signed-gateway-token']); + }), + ); + aresIt.effect('failed second Action stops the following supported address and retains prior commit receipt', () => + Effect.gen(function* failedSecondActionStopsThe() { + const calls: string[] = []; + const address: AresApplyRequest['selections'][number] = { + fact: 'REGISTERED_ADDRESS', + idempotencyKey: 'ares-address-1', + payload: { + contactPoint: { + address: { + addressLine1: 'Main 10', + city: 'Prague', + countryCode: 'CZ', + postalCode: '11000', }, + purposes: [ + { + preferred: false, + purpose: 'REGISTERED', + registryContext: { + jurisdiction: 'CZ', + registryKey: 'ARES', + }, + }, + ], + type: 'ADDRESS', }, - { gateway, reads: makeReads() }, - ); - expect( - Match.value(outcome).pipe( - Match.tag('AresApplyPartiallyCompleted', () => true), - Match.orElse(() => false), - ), - ).toBe(true); - expect(outcome.completed.length).toBe(1); - expect(calls).toEqual([ - 'update-party|Bearer signed-gateway-token', - 'add-party-official-identifier|Bearer signed-gateway-token', - ]); - }), - ); - aresIt.effect( - 'stale refreshed evidence and missing canonical target cannot execute enrichment', - () => - Effect.gen(function* staleRefreshedEvidenceAndMissing() { - const calls: string[] = []; - const stale = yield* applyAresObservation(request, makeInvoker(calls), { - gateway, - reads: { - ...makeReads(), - observation: () => - Effect.succeed({ - ...decodedObservation, - observedAt: DateTime.makeUnsafe('2026-09-03T09:59:00.000Z'), - servedAt: DateTime.makeUnsafe('2026-09-03T10:00:00.000Z'), - }), + partyRef, + privacyClassification: 'PUBLIC', + provenance: { + authoritative: true, + evidenceReference: 'ares:12345678', + method: 'PROVIDER_OBSERVATION', + source: 'EXTERNAL_EVIDENCE', }, - }); - expect( - Match.value(stale).pipe( - Match.tag('AresApplyDeferred', () => true), - Match.orElse(() => false), - ), - ).toBe(true); - const absent = yield* applyAresObservation( - { ...request, partyRef: null }, - makeInvoker(calls), - { - gateway, - reads: makeReads(), + validFrom: decodedObservation.observedAt, + verification: { state: 'UNVERIFIED' }, + }, + route: 'CONTACT_POINT_ADD', + }; + const delegate = makeInvoker(calls, 'add-party-official-identifier|Bearer signed-gateway-token'); + const outcome = yield* applyAresObservation( + { ...request, selections: [...request.selections, address] }, + { + ...delegate, + addContactPoint: () => { + calls.push('unexpected-address'); + return Effect.fail(new TestFailure({ action: 'address' })); }, - ).pipe(Effect.result); - expect('failure' in absent).toBe(true); - expect(calls).toEqual([]); - }), + }, + { gateway, reads: makeReads() }, + ); + expect( + Match.value(outcome).pipe( + Match.tag('AresApplyPartiallyCompleted', () => true), + Match.orElse(() => false), + ), + ).toBe(true); + expect(outcome.completed.length).toBe(1); + expect(calls).toEqual([ + 'update-party|Bearer signed-gateway-token', + 'add-party-official-identifier|Bearer signed-gateway-token', + ]); + }), + ); + aresIt.effect('stale refreshed evidence and missing canonical target cannot execute enrichment', () => + Effect.gen(function* staleRefreshedEvidenceAndMissing() { + const calls: string[] = []; + const stale = yield* applyAresObservation(request, makeInvoker(calls), { + gateway, + reads: { + ...makeReads(), + observation: () => + Effect.succeed({ + ...decodedObservation, + observedAt: DateTime.makeUnsafe('2026-09-03T09:59:00.000Z'), + servedAt: DateTime.makeUnsafe('2026-09-03T10:00:00.000Z'), + }), + }, + }); + expect( + Match.value(stale).pipe( + Match.tag('AresApplyDeferred', () => true), + Match.orElse(() => false), + ), + ).toBe(true); + const absent = yield* applyAresObservation({ ...request, partyRef: null }, makeInvoker(calls), { + gateway, + reads: makeReads(), + }).pipe(Effect.result); + expect('failure' in absent).toBe(true); + expect(calls).toEqual([]); + }), ); aresIt.effect('fresh identical refresh cannot revive an expired original confirmation', () => Effect.gen(function* freshIdenticalRefreshCannotRevive() { @@ -886,9 +843,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica ); const deferred = Match.value(outcome).pipe( Match.tag('AresApplyDeferred', (value) => value), - Match.orElse(() => - expect.unreachable('Expected an expired confirmation to defer ARES application'), - ), + Match.orElse(() => expect.unreachable('Expected an expired confirmation to defer ARES application')), ); expect(deferred.application.factDecisions[0]?.reasonCode).toBe('observation_not_fresh'); expect(deferred.correctionCandidates).toEqual([]); @@ -908,9 +863,7 @@ it.layer(Layer.effectDiscard(TestClock.setTime(confirmedAtEpoch)))('ARES applica ); const deferred = Match.value(outcome).pipe( Match.tag('AresApplyDeferred', (value) => value), - Match.orElse(() => - expect.unreachable('Expected the correction selection to defer ARES application'), - ), + Match.orElse(() => expect.unreachable('Expected the correction selection to defer ARES application')), ); expect(deferred.application.outcome).toBe('NEEDS_CONFIRMATION'); expect(deferred.correctionCandidates).toEqual([]); diff --git a/app/verticals/party-registry/tests/unit/api-integration-client-url.test.ts b/app/verticals/party-registry/tests/unit/api-integration-client-url.test.ts index 598340839..a72ef2b75 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-client-url.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-client-url.test.ts @@ -1,14 +1,13 @@ -import { expect, it } from 'effect-rstest'; - import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; +import { AresSubjectLookupIcoSchema } from '../../shared/domain/ares-evidence.ts'; import { executeAresLookupWithAuthorization } from '../../src/api/ares-lookup-client.ts'; import { loadPartiesClientWithAuthorization } from '../../src/api/parties-search-client.ts'; import { executePartyDetailWithAuthorization } from '../../src/api/party-detail-client.ts'; -import { AresSubjectLookupIcoSchema } from '../../shared/domain/ares-evidence.ts'; -const ico = Schema.decodeUnknownSync(AresSubjectLookupIcoSchema)('12345678'); +const ico = Schema.decodeSync(AresSubjectLookupIcoSchema)('12345678'); it.effect('targets the mounted owner BFF prefix and supports a separate owner deployment', () => Effect.gen(function* testProgram1() { diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts index 361d0d936..983722d78 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts @@ -1,16 +1,18 @@ -import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; -import { expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; -import { - requestSearchRebuild, - requestSearchRebuildWithAuthorization, -} from '../../src/api/party-command-client.ts'; + +import { requestSearchRebuild, requestSearchRebuildWithAuthorization } from '../../src/api/party-command-client.ts'; +import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; it.effect('fresh assertions and command metadata reach the independent owner deployment', () => Effect.gen(function* testProgram1() { const { requests, assertions, fakeFetch } = makeCommandAssertionFetch( - () => Response.json({ requestId: '10000000-0000-4000-8000-000000000001', status: 'QUEUED' }), + () => + Response.json({ + requestId: '10000000-0000-4000-8000-000000000001', + status: 'QUEUED', + }), 'token', ); const options = { @@ -21,22 +23,17 @@ it.effect('fresh assertions and command metadata reach the independent owner dep traceId: 'command-trace', }; const invoke = () => - requestSearchRebuild({}, options).pipe( - Effect.provideService(FetchHttpClient.Fetch, fakeFetch), - ); + requestSearchRebuild({}, options).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); const first = yield* invoke(); const second = yield* invoke(); expect(first.status).toBe('QUEUED'); expect(second.status).toBe('QUEUED'); expect(assertions()).toBe(2); - const commands = requests.filter( - (request) => new URL(request.url).hostname === 'party.example', - ); + const commands = requests.filter((request) => new URL(request.url).hostname === 'party.example'); expect(commands.map((request) => request.url)).toEqual( Array.from( { length: 2 }, - () => - 'https://party.example/party-registry-api/party-registry/actions/request-search-rebuild', + () => 'https://party.example/party-registry-api/party-registry/actions/request-search-rebuild', ), ); expect(commands.map((request) => request.headers.get('authorization'))).toEqual([ @@ -57,7 +54,10 @@ it.effect('the browser default uses the relative mounted BFF prefix', () => const fakeFetch: typeof fetch = (input) => { urls.push(String(input)); return Promise.resolve( - Response.json({ requestId: '10000000-0000-4000-8000-000000000001', status: 'QUEUED' }), + Response.json({ + requestId: '10000000-0000-4000-8000-000000000001', + status: 'QUEUED', + }), ); }; const location = Object.getOwnPropertyDescriptor(globalThis, 'location'); @@ -78,8 +78,6 @@ it.effect('the browser default uses the relative mounted BFF prefix', () => correlationId: 'relative', idempotencyKey: 'rebuild-1', }).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); - expect(urls).toEqual([ - 'https://shell.example/party-registry-api/party-registry/actions/request-search-rebuild', - ]); + expect(urls).toEqual(['https://shell.example/party-registry-api/party-registry/actions/request-search-rebuild']); }), ); diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-contract.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-contract.test.ts index 9cec10ed3..0cae074fe 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-contract.test.ts @@ -1,7 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Inspect source files through the Node filesystem boundary; expires: 2026-12-31. +import { fileURLToPath } from 'node:url'; + +import { NodeFileSystem } from '@effect/platform-node'; +import { FileSystem, Effect, Schema, Struct } from 'effect'; import { expect, it } from 'effect-rstest'; -import { readFile, readdir } from 'node:fs/promises'; -import { Effect, Schema, Struct } from 'effect'; + import { partyRegistryCommandsApi, PartyCommandAliasWriteRejectedProblemSchema, @@ -15,76 +17,74 @@ const ref = (id: string) => ({ tenantId: '10000000-0000-4000-8000-000000000001', }); -it.effect('every generated Action has its own statically named command endpoint', () => - Effect.gen(function* testProgram1() { - const files = yield* Effect.promise(() => - readdir(new URL('../../src/actions/', import.meta.url)), - ); - const actions = files - .filter((file) => file.endsWith('.action.ts')) - .map((file) => file.replace('.action.ts', '')) - .filter((slug) => !slug.includes('engagement')); - const endpoints = Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints); - expect(endpoints.length).toBe(actions.length); - expect(endpoints.map((endpoint) => endpoint.path).toSorted()).toEqual( - actions.map((slug) => `/party-registry/actions/${slug}`).toSorted(), - ); - for (const slug of actions) { - const name = slug - .split('-') - .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) - .join(''); - expect(Object.hasOwn(partyRegistryCommandsApi.groups.partyCommands.endpoints, name)).toBe( - true, +it.layer(NodeFileSystem.layer)('api-integration-command-contract', (suite) => { + suite.effect('every generated Action has its own statically named command endpoint', () => + Effect.gen(function* testProgram1() { + const files = yield* FileSystem.FileSystem.use((fs) => + fs.readDirectory(fileURLToPath(new URL('../../src/actions/', import.meta.url))), ); - } - }), -); + const actions = files + .filter((file) => file.endsWith('.action.ts')) + .map((file) => file.replace('.action.ts', '')) + .filter((slug) => !slug.includes('engagement')); + const endpoints = Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints); + expect(endpoints.length).toBe(actions.length); + expect(endpoints.map((endpoint) => endpoint.path).toSorted()).toEqual( + actions.map((slug) => `/party-registry/actions/${slug}`).toSorted(), + ); + for (const slug of actions) { + const name = slug + .split('-') + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(''); + expect(Object.hasOwn(partyRegistryCommandsApi.groups.partyCommands.endpoints, name)).toBe(true); + } + }), + ); -it.effect( - 'missing idempotency reaches the declared 428 while malformed supplied values fail decoding', - () => + suite.effect('missing idempotency reaches the declared 428 while malformed supplied values fail decoding', () => Effect.gen(function* decodeContract1() { - expect(yield* Schema.decodeUnknownEffect(PartyCommandHeadersSchema)({})).toEqual({}); + expect(yield* Schema.decodeEffect(PartyCommandHeadersSchema)({})).toEqual({}); expect(() => - Schema.decodeUnknownSync(PartyCommandHeadersSchema)({ 'idempotency-key': '' }), + Schema.decodeSync(PartyCommandHeadersSchema)({ + 'idempotency-key': '', + }), ).toThrow(); }), -); + ); -it.effect('alias conflict preserves both canonical and submitted references', () => - Effect.gen(function* decodeContract2() { - const input = { - _tag: 'PartyCommandAliasWriteRejectedProblem', - aliasPartyRef: ref('10000000-0000-4000-8000-000000000002'), - canonicalPartyRef: ref('10000000-0000-4000-8000-000000000003'), - code: 'party_alias_write_rejected', - detail: 'Retry with the canonical Party.', - status: 409, - title: 'Canonical Party required', - type: 'urn:ontos:party:alias-write-rejected', - }; - const decodedProblem = yield* Schema.decodeUnknownEffect( - PartyCommandAliasWriteRejectedProblemSchema, - )(input); - expect(Schema.is(PartyCommandAliasWriteRejectedProblemSchema)(decodedProblem)).toBe(true); - expect(Struct.omit(decodedProblem, ['_tag'])).toEqual(Struct.omit(input, ['_tag'])); - }), -); + suite.effect('alias conflict preserves both canonical and submitted references', () => + Effect.gen(function* decodeContract2() { + const input = { + _tag: 'PartyCommandAliasWriteRejectedProblem', + aliasPartyRef: ref('10000000-0000-4000-8000-000000000002'), + canonicalPartyRef: ref('10000000-0000-4000-8000-000000000003'), + code: 'party_alias_write_rejected', + detail: 'Retry with the canonical Party.', + status: 409, + title: 'Canonical Party required', + type: 'urn:ontos:party:alias-write-rejected', + }; + const decodedProblem = yield* Schema.decodeUnknownEffect(PartyCommandAliasWriteRejectedProblemSchema)(input); + expect(Schema.is(PartyCommandAliasWriteRejectedProblemSchema)(decodedProblem)).toBe(true); + expect(Struct.omit(decodedProblem, ['_tag'])).toEqual(Struct.omit(input, ['_tag'])); + }), + ); -it.effect('public commands and clients never import Action runtime implementations', () => - Effect.gen(function* testProgram2() { - const sources = yield* Effect.promise(() => - Promise.all( - ['../../shared/command-api.ts', '../../src/api/party-command-client.ts'].map((path) => - readFile(new URL(path, import.meta.url), 'utf-8'), + suite.effect('public commands and clients never import Action runtime implementations', () => + Effect.gen(function* testProgram2() { + const sources = yield* FileSystem.FileSystem.use((fs) => + Effect.forEach( + ['../../shared/command-api.ts', '../../src/api/party-command-client.ts'], + (path) => fs.readFileString(fileURLToPath(new URL(path, import.meta.url))), + { concurrency: 'unbounded' }, ), - ), - ); - for (const source of sources) { - expect(source).not.toMatch( - /from\s+['"][^'"]*src\/actions|from\s+['"]\.\.\/actions|\.action\.ts|Schema\.(?:Unknown|Any)\b/u, ); - } - }), -); + for (const source of sources) { + expect(source).not.toMatch( + /from\s+['"][^'"]*src\/actions|from\s+['"]\.\.\/actions|\.action\.ts|Schema\.(?:Unknown|Any)\b/u, + ); + } + }), + ); +}); diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts index 71983770a..7f6277d3c 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts @@ -1,7 +1,7 @@ -import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; -import { expect, it } from 'effect-rstest'; import { Effect, Match, Result, Schema, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; + import { PartyCommandCommitIndeterminateProblemSchema, PartyCommandConflictProblemSchema, @@ -17,10 +17,9 @@ import { resolvePartyCommandCommit, recoverPartyCreate, } from '../../src/api/party-command-client.ts'; +import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; -const invocationId = Schema.decodeUnknownSync(ActionInvocationIdSchema)( - '10000000-0000-4000-8000-000000000001', -); +const invocationId = Schema.decodeSync(ActionInvocationIdSchema)('10000000-0000-4000-8000-000000000001'); const recoveryProblems = [ { @@ -62,9 +61,7 @@ for (const { name, decode, is, problem } of recoveryProblems) { const decoded = yield* decode(problem); expect(is(decoded)).toBe(true); expect(Struct.omit(decoded, ['_tag'])).toEqual(Struct.omit(problem, ['_tag'])); - for (const endpoint of Object.values( - partyRegistryCommandsApi.groups.partyCommands.endpoints, - )) { + for (const endpoint of Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints)) { expect([...endpoint.error].some((schema) => Schema.is(schema)(problem))).toBe(true); } const invalid: Effect.Effect = decode({ @@ -78,7 +75,9 @@ for (const { name, decode, is, problem } of recoveryProblems) { it('recovery rejects an invalid invocation handle', () => { expect(() => - Schema.decodeUnknownSync(ResolvePartyCommandCommitPayloadSchema)({ invocationId: 'invalid' }), + Schema.decodeSync(ResolvePartyCommandCommitPayloadSchema)({ + invocationId: 'invalid', + }), ).toThrow(); }); @@ -143,117 +142,111 @@ for (const { name, is, problem } of [ ); } -it.effect( - 'recovery acquires a fresh assertion without submitting an idempotency key or re-running a command', - () => - Effect.gen(function* testProgram3() { - const { requests, assertions, fakeFetch } = makeCommandAssertionFetch( - () => - Response.json({ - _tag: 'PartyCommandCommitResolution', - invocationId, - retryCommand: false, - state: 'COMMITTED', - }), - 'fresh', - ); - const result = yield* resolvePartyCommandCommit( - { invocationId }, - { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'recovery', - gateway: { baseUrl: 'https://shell.example/shell-super-app-api' }, - traceId: 'trace', - }, - ).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); - expect(result.state).toBe('COMMITTED'); - expect(assertions()).toBe(1); - expect(requests.length).toBe(2); - const [, request] = requests; - expect(Boolean(request)).toBe(true); - if (request === undefined) { - throw new Error('Expected truthy value'); - } - expect(request.url).toBe( - 'https://party.example/party-registry-api/party-registry/action-commits/resolve', - ); - expect(request.headers.get('authorization')).toBe('Bearer fresh-1'); - expect(request.headers.get('idempotency-key')).toBe(null); - expect(request.headers.get('x-correlation-id')).toBe('recovery'); - expect(request.headers.get('x-trace-id')).toBe('trace'); - expect(yield* Effect.promise(() => request.json())).toEqual({ invocationId }); - }), +it.effect('recovery acquires a fresh assertion without submitting an idempotency key or re-running a command', () => + Effect.gen(function* testProgram3() { + const { requests, assertions, fakeFetch } = makeCommandAssertionFetch( + () => + Response.json({ + _tag: 'PartyCommandCommitResolution', + invocationId, + retryCommand: false, + state: 'COMMITTED', + }), + 'fresh', + ); + const result = yield* resolvePartyCommandCommit( + { invocationId }, + { + baseUrl: 'https://party.example/party-registry-api', + correlationId: 'recovery', + gateway: { baseUrl: 'https://shell.example/shell-super-app-api' }, + traceId: 'trace', + }, + ).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); + expect(result.state).toBe('COMMITTED'); + expect(assertions()).toBe(1); + expect(requests.length).toBe(2); + const [, request] = requests; + expect(Boolean(request)).toBe(true); + if (request === undefined) { + throw new Error('Expected truthy value'); + } + expect(request.url).toBe('https://party.example/party-registry-api/party-registry/action-commits/resolve'); + expect(request.headers.get('authorization')).toBe('Bearer fresh-1'); + expect(request.headers.get('idempotency-key')).toBe(null); + expect(request.headers.get('x-correlation-id')).toBe('recovery'); + expect(request.headers.get('x-trace-id')).toBe('trace'); + expect(yield* Effect.promise(() => request.json())).toEqual({ + invocationId, + }); + }), ); -it.effect( - 'Create recovery resolves commit and returns exact original operation result with fresh read authority', - () => - Effect.all( - (['CREATED', 'MATCHED_EXISTING', 'AMBIGUOUS'] as const).map((outcome) => - Effect.gen(function* testProgram5() { - const partyRef = { - moduleId: 'party.registry', - resourceId: invocationId, - resourceType: 'party.registry.party', - tenantId: invocationId, - }; - const decisionRef = { - ...partyRef, - resourceType: 'party.registry.party-match-decision', - }; - const caseRef = { - ...partyRef, - resourceType: 'party.registry.duplicate-candidate-case', - }; - const { requests, assertions, fakeFetch } = makeCommandAssertionFetch((request) => { - if (request.url.endsWith('/resolve')) { - return Response.json({ - _tag: 'PartyCommandCommitResolution', - invocationId, - retryCommand: false, - state: 'COMMITTED', - }); - } +it.effect('Create recovery resolves commit and returns exact original operation result with fresh read authority', () => + Effect.all( + (['CREATED', 'MATCHED_EXISTING', 'AMBIGUOUS'] as const).map((outcome) => + Effect.gen(function* testProgram5() { + const partyRef = { + moduleId: 'party.registry', + resourceId: invocationId, + resourceType: 'party.registry.party', + tenantId: invocationId, + }; + const decisionRef = { + ...partyRef, + resourceType: 'party.registry.party-match-decision', + }; + const caseRef = { + ...partyRef, + resourceType: 'party.registry.duplicate-candidate-case', + }; + const { requests, assertions, fakeFetch } = makeCommandAssertionFetch((request) => { + if (request.url.endsWith('/resolve')) { return Response.json({ - caseRef: outcome === 'AMBIGUOUS' ? caseRef : null, - committedCreateOutcome: outcome, - decidedAt: '2026-09-04T00:00:00Z', - decisionRef, - evidenceExplanation: [], - matchRuleVersion: 'party-exact-claims.v1', - operation: 'CREATE', - outcome: outcome === 'MATCHED_EXISTING' ? 'MATCHED' : outcome, - partyRef: outcome === 'AMBIGUOUS' ? null : partyRef, + _tag: 'PartyCommandCommitResolution', + invocationId, + retryCommand: false, + state: 'COMMITTED', }); - }, 'fresh'); - const recovered = yield* recoverPartyCreate( - { invocationId }, - { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'recover', - gateway: { baseUrl: 'https://shell.example/shell-super-app-api' }, - }, - ).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); - const recoveredResult = Match.value(recovered).pipe( - Match.tag('PartyCreateRecovered', ({ result }) => result), - Match.tag('PartyCreateRecoveryPending', ({ resolution }) => - (() => { - throw new Error(`Expected committed recovery, received ${resolution.state}`); - })(), - ), - Match.exhaustive, - ); - expect(recoveredResult.outcome).toBe(outcome); - expect(assertions()).toBe(2); - expect(requests.length).toBe(4); - expect( - requests.every( - (request) => - !request.url.includes('/commands/') && - request.headers.get('idempotency-key') === null, - ), - ).toBe(true); - }), - ), + } + return Response.json({ + caseRef: outcome === 'AMBIGUOUS' ? caseRef : null, + committedCreateOutcome: outcome, + decidedAt: '2026-09-04T00:00:00Z', + decisionRef, + evidenceExplanation: [], + matchRuleVersion: 'party-exact-claims.v1', + operation: 'CREATE', + outcome: outcome === 'MATCHED_EXISTING' ? 'MATCHED' : outcome, + partyRef: outcome === 'AMBIGUOUS' ? null : partyRef, + }); + }, 'fresh'); + const recovered = yield* recoverPartyCreate( + { invocationId }, + { + baseUrl: 'https://party.example/party-registry-api', + correlationId: 'recover', + gateway: { baseUrl: 'https://shell.example/shell-super-app-api' }, + }, + ).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); + const recoveredResult = Match.value(recovered).pipe( + Match.tag('PartyCreateRecovered', ({ result }) => result), + Match.tag('PartyCreateRecoveryPending', ({ resolution }) => + (() => { + throw new Error(`Expected committed recovery, received ${resolution.state}`); + })(), + ), + Match.exhaustive, + ); + expect(recoveredResult.outcome).toBe(outcome); + expect(assertions()).toBe(2); + expect(requests.length).toBe(4); + expect( + requests.every( + (request) => !request.url.includes('/commands/') && request.headers.get('idempotency-key') === null, + ), + ).toBe(true); + }), ), + ), ); diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts index 3972c4347..5b1415535 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts @@ -1,8 +1,5 @@ -import { assert, expect, it } from 'effect-rstest'; import { randomUUID } from 'node:crypto'; -import { ConfigProvider, Context, Effect, Layer, Logger, Schema, Predicate, Struct } from 'effect'; - import { ActionHandlerExecutionError, ActionIdempotencyKeyRequired, @@ -38,7 +35,6 @@ import { ReadRuntime, TrustedPrincipalContextSchema, } from '@app/core-runtime'; - import type { ActionCoreError, ActionRuntimeService, @@ -46,48 +42,32 @@ import type { ReadCoreError, ReadRuntimeService, } from '@app/core-runtime'; - -import { HttpApi, HttpApiBuilder, HttpRouter, HttpServer } from '@modern-js/plugin-bff/effect-edge'; - import { bindActionTestServices, makeActionTestHarness } from '@app/core-runtime/testing/actions'; - +import { HttpApi, HttpApiBuilder, HttpRouter, HttpServer } from '@modern-js/plugin-bff/effect-edge'; +import { ConfigProvider, Context, Effect, Layer, Logger, Schema, Predicate, Struct } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { ActionPrincipalVerifierLive } from '../../api/auth/action-principal.ts'; +import { organizationEngagementMutationsLive } from '../../api/engagement-profile-server.ts'; +import { partyRegistryCommandRecoveryLive, partyRegistryCommandsLive } from '../../api/party-command-server.ts'; +import { partyMatchDecisionReadApiLive } from '../../api/party-match-decision-read-server.ts'; import { partyRegistryApi } from '../../shared/api.ts'; - +import { PartyMatchDecisionRequestSchema } from '../../shared/apis/party-match-decision.ts'; import { PartyCommandInvalidRequestProblemSchema, ResolvePartyCommandCommitResultSchema, } from '../../shared/command-api.ts'; - -import { - partyRegistryCommandRecoveryLive, - partyRegistryCommandsLive, -} from '../../api/party-command-server.ts'; - -import { organizationEngagementMutationsLive } from '../../api/engagement-profile-server.ts'; - -import { ActionPrincipalVerifierLive } from '../../api/auth/action-principal.ts'; - -import { archivePartyAction } from '../../src/actions/archive-party.action.ts'; - -import { createPartyAction } from '../../src/actions/create-party.action.ts'; - import { PartyEvidenceInsufficient, PartyPersistenceUnavailable, PartySchema, } from '../../shared/domain/identity-contracts.ts'; - -import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; - -import { partyMatchDecisionReadApiLive } from '../../api/party-match-decision-read-server.ts'; - -import { PartyMatchDecisionRequestSchema } from '../../shared/apis/party-match-decision.ts'; - import { RuleKeySchema } from '../../shared/domain/matching-contracts.ts'; - import type { PartyMatchDecisionRecordSchema } from '../../shared/domain/matching-contracts.ts'; +import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; +import { archivePartyAction } from '../../src/actions/archive-party.action.ts'; +import { createPartyAction } from '../../src/actions/create-party.action.ts'; const principal = { authBindingId: 'a1000000-0000-4000-8000-000000000001', @@ -109,9 +89,13 @@ const otherPartyRef = { resourceId: 'a4000000-0000-4000-8000-000000000002', } as const; -const archivePayload = { expectedRevision: 1, partyRef, reason: 'No longer active' }; +const archivePayload = { + expectedRevision: 1, + partyRef, + reason: 'No longer active', +}; -const archivedParty = Schema.decodeUnknownSync(PartySchema)({ +const archivedParty = Schema.decodeSync(PartySchema)({ archivedAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z', displayName: 'Example organization', @@ -250,7 +234,11 @@ const makeAssertion = ( }; const token = yield* Effect.promise(() => new SignJWT({ principal, ver: 1 }) - .setProtectedHeader({ alg: 'EdDSA', kid: 'party-command-test', typ: 'JWT' }) + .setProtectedHeader({ + alg: 'EdDSA', + kid: 'party-command-test', + typ: 'JWT', + }) .setIssuer(options.tokenIssuer ?? issuer) .setAudience(audience) .setSubject(principal.principalId) @@ -262,7 +250,11 @@ const makeAssertion = ( const otherPrincipal = { ...principal, principalId: randomUUID() }; const otherToken = yield* Effect.promise(() => new SignJWT({ principal: otherPrincipal, ver: 1 }) - .setProtectedHeader({ alg: 'EdDSA', kid: 'party-command-test', typ: 'JWT' }) + .setProtectedHeader({ + alg: 'EdDSA', + kid: 'party-command-test', + typ: 'JWT', + }) .setIssuer(issuer) .setAudience(audience) .setSubject(otherPrincipal.principalId) @@ -274,9 +266,7 @@ const makeAssertion = ( return { environment: { ONTOS_GATEWAY_ISSUER: issuer, - ONTOS_GATEWAY_PUBLIC_JWKS: yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ + ONTOS_GATEWAY_PUBLIC_JWKS: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ keys: [publicJwk], }), }, @@ -285,7 +275,9 @@ const makeAssertion = ( }; }); -const nonPersistingRedemption: GatewayAssertionRedemption = { consume: () => Effect.void }; +const nonPersistingRedemption: GatewayAssertionRedemption = { + consume: () => Effect.void, +}; const ProblemTagSchema = Schema.Struct({ _tag: Schema.String }); @@ -300,7 +292,10 @@ const mounted = ( const resolvedReadRuntime = readRuntime ?? { runRead: () => Effect.fail( - new ReadHandlerNotFound({ code: 'read_handler_not_found', reason: 'No fixture decision' }), + new ReadHandlerNotFound({ + code: 'read_handler_not_found', + reason: 'No fixture decision', + }), ), }; // Same API identity and production group: only unrelated read routes are omitted. @@ -347,9 +342,7 @@ const mountedOrganizationEngagement = ( environment: Readonly>, actionRuntime: ActionRuntimeService, ) => { - const api = HttpApi.make('PartyRegistryApi').add( - partyRegistryApi.groups.organizationEngagementMutations, - ); + const api = HttpApi.make('PartyRegistryApi').add(partyRegistryApi.groups.organizationEngagementMutations); const actionLayer = Layer.succeed(ActionRuntime, actionRuntime); const redemptionLayer = Layer.succeed(GatewayAssertionRedemptionService, nonPersistingRedemption); const handlers = organizationEngagementMutationsLive.pipe( @@ -378,9 +371,7 @@ const mountApp = ( observedLogs?: string[], ) => Effect.acquireRelease( - Effect.sync(() => - mounted(harness, environment, readRuntime, redemption, actionRuntime, observedLogs), - ), + Effect.sync(() => mounted(harness, environment, readRuntime, redemption, actionRuntime, observedLogs)), (app) => Effect.promise(() => app.dispose()).pipe(Effect.orDie), ); @@ -408,10 +399,8 @@ const emptyRequestContext = Context.makeUnsafe(new Map()); const handle = (app: ReturnType, request: Request) => Effect.promise(() => app.handler(request, emptyRequestContext)); -const forEachSequential = ( - items: Iterable, - run: (item: Item) => Effect.Effect, -) => Effect.forEach(items, run, { discard: true }); +const forEachSequential = (items: Iterable, run: (item: Item) => Effect.Effect) => + Effect.forEach(items, run, { discard: true }); const recoveryRequest = (invocationId: string, token?: string) => { const headers = new Headers({ @@ -428,6 +417,23 @@ const recoveryRequest = (invocationId: string, token?: string) => { }); }; +const expectCommitResolution = Effect.fn('Test.expectCommitResolution')(function* expectCommitResolution( + app: ReturnType, + invocationId: string, + token: string, + state: 'OPEN' | 'COMMITTED', +) { + const resolution = yield* handle(app, recoveryRequest(invocationId, token)); + expect(resolution.status).toBe(200); + const resolutionBody = yield* Effect.promise(() => resolution.json()); + expect(Schema.is(ResolvePartyCommandCommitResultSchema)(resolutionBody)).toBe(true); + expect(Struct.omit(resolutionBody, ['_tag'])).toEqual({ + invocationId, + retryCommand: false, + state, + }); +}); + const decisionRequest = ( actionInvocationId: string, token?: string, @@ -498,39 +504,33 @@ it.live( .map((endpoint) => endpoint.path) .toSorted(), ).toEqual(actionSlugs.map((slug) => `/party-registry/actions/${slug}`).toSorted()); - yield* forEachSequential( - Object.values(partyRegistryApi.groups.partyCommands.endpoints), - (endpoint) => - Effect.gen(function* rejectInvalidEndpointRequest() { - const response = yield* handle( - app, - new Request(`https://party.ontos.test${endpoint.path}`, { - method: 'POST', - body: '{}', - headers: { - 'content-type': 'application/json', - 'x-correlation-id': 'mounted-command-test', - }, - }), - ); - expect(response.status === 400 || response.status === 401).toBe(true); - if (!(response.status === 400 || response.status === 401)) { - throw new Error(`${endpoint.path}: ${response.status}`); - } - expect(response.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); - const body = yield* Effect.promise(() => response.json()); - expect( - Predicate.isTagged( - body, - response.status === 400 - ? 'PartyCommandInvalidRequestProblem' - : 'PartyCommandAuthenticationProblem', - ), - ).toBe(true); - expect(body.status).toBe(response.status); - }), + yield* forEachSequential(Object.values(partyRegistryApi.groups.partyCommands.endpoints), (endpoint) => + Effect.gen(function* rejectInvalidEndpointRequest() { + const response = yield* handle( + app, + new Request(`https://party.ontos.test${endpoint.path}`, { + method: 'POST', + body: '{}', + headers: { + 'content-type': 'application/json', + 'x-correlation-id': 'mounted-command-test', + }, + }), + ); + expect(response.status === 400 || response.status === 401).toBe(true); + if (!(response.status === 400 || response.status === 401)) { + throw new Error(`${endpoint.path}: ${response.status}`); + } + expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + const body = yield* Effect.promise(() => response.json()); + expect( + Predicate.isTagged( + body, + response.status === 400 ? 'PartyCommandInvalidRequestProblem' : 'PartyCommandAuthenticationProblem', + ), + ).toBe(true); + expect(body.status).toBe(response.status); + }), ); const malformed = yield* handle( app, @@ -592,9 +592,7 @@ it.live( expect(Predicate.isTagged(body, 'PartyCommandAuthenticationProblem')).toBe(true); expect(body.status).toBe(401); expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes( - assertion.token, - ), + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes(assertion.token), ).toBe(false); expect(harness.snapshot().invocations.length).toBe(0); }), @@ -602,307 +600,301 @@ it.live( }), ); -it.live( - 'missing and malformed verification configuration are retryable and never reach the lifecycle', - () => - Effect.gen(function* rejectInvalidVerificationConfiguration() { - const assertion = yield* makeAssertion(); - yield* forEachSequential( - [ - {}, - { ...assertion.environment, ONTOS_GATEWAY_ISSUER: 'not-an-absolute-http-url' }, - { ...assertion.environment, ONTOS_GATEWAY_PUBLIC_JWKS: '{malformed' }, - ], - (environment) => - Effect.gen(function* rejectInvalidConfigurationCase() { - const harness = yield* makeActionTestHarness(); - const app = yield* mountApp(harness, environment); - const response = yield* handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'configuration-test', - }), - ); - expect(response.status).toBe(503); - expect(response.headers.get('www-authenticate')).toBe(null); - expect(response.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); - const body = yield* Effect.promise(() => response.json()); - expect(Predicate.isTagged(body, 'PartyCommandUnavailableProblem')).toBe(true); - expect(body.retryable).toBe(true); - expect(harness.snapshot().invocations.length).toBe(0); - }), - ); - }), -); - -it.live( - 'redemption storage outages return safe retryable problems before Action and Read lifecycles', - () => - Effect.gen(function* reportRedemptionOutages() { - const assertion = yield* makeAssertion(); - const harness = yield* makeActionTestHarness(); - const { readRuntime, readCount } = makeMissingDecisionReadRuntime(); - const app = yield* mountApp(harness, assertion.environment, readRuntime, { - consume: () => - Effect.fail( - new GatewayAssertionRedemptionUnavailableError({ - reason: 'private redemption storage diagnostic', - }), - ), - }); - const before = harness.snapshot(); - yield* forEachSequential( - [ - { - request: commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'redemption-unavailable', +it.live('missing and malformed verification configuration are retryable and never reach the lifecycle', () => + Effect.gen(function* rejectInvalidVerificationConfiguration() { + const assertion = yield* makeAssertion(); + yield* forEachSequential( + [ + {}, + { + ...assertion.environment, + ONTOS_GATEWAY_ISSUER: 'not-an-absolute-http-url', + }, + { ...assertion.environment, ONTOS_GATEWAY_PUBLIC_JWKS: '{malformed' }, + ], + (environment) => + Effect.gen(function* rejectInvalidConfigurationCase() { + const harness = yield* makeActionTestHarness(); + const app = yield* mountApp(harness, environment); + const response = yield* handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'configuration-test', }), - tag: 'PartyCommandUnavailableProblem', - }, - { - request: decisionRequest(randomUUID(), assertion.token), - tag: 'PartyMatchDecisionUnavailableProblem', - }, - ], - ({ request, tag }) => - Effect.gen(function* reportRedemptionOutageCase() { - const response = yield* handle(app, request); - expect(response.status).toBe(503); - expect(response.headers.get('www-authenticate')).toBe(null); - expect(response.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); - const body = yield* Effect.promise(() => response.json()); - expect(Predicate.isTagged(body, tag)).toBe(true); - expect(body.status).toBe(503); - expect(body.retryable).toBe(true); - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body); - expect(encoded.includes('private redemption')).toBe(false); - expect(encoded.includes(assertion.token)).toBe(false); - expect(encoded.includes(principal.principalId)).toBe(false); - expect(encoded.includes(principal.tenantId)).toBe(false); - expect(readCount()).toBe(0); - expect(harness.snapshot()).toEqual(before); - }), - ); - }), -); - -it.live( - 'generated governed reads authenticate through the shared adapter before starting ReadRuntime', - () => - Effect.gen(function* authenticateGovernedReads() { - const assertion = yield* makeAssertion(); - const harness = yield* makeActionTestHarness(); - let reads = 0; - const receivedPrincipals: unknown[] = []; - const readRuntime: ReadRuntimeService = { - runRead: (input) => - Effect.sync(() => { - reads += 1; - receivedPrincipals.push(input.principal); - }).pipe( - Effect.andThen( - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'No fixture decision', - }), - ), - ), - ), - }; - const app = yield* mountApp(harness, assertion.environment, readRuntime); - yield* forEachSequential([undefined, 'not-a-jwt'], (token) => - Effect.gen(function* rejectMissingGovernedCredentials() { - const response = yield* handle(app, decisionRequest(randomUUID(), token)); - expect(response.status).toBe(401); - expect(response.headers.get('www-authenticate')).toBe('Bearer'); + ); + expect(response.status).toBe(503); + expect(response.headers.get('www-authenticate')).toBe(null); expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); const body = yield* Effect.promise(() => response.json()); - expect( - Schema.is(Schema.TaggedStruct('PartyMatchDecisionAuthenticationProblem', {}))(body), - ).toBe(true); - expect(reads).toBe(0); + expect(Predicate.isTagged(body, 'PartyCommandUnavailableProblem')).toBe(true); + expect(body.retryable).toBe(true); + expect(harness.snapshot().invocations.length).toBe(0); }), - ); - const missingCorrelation = yield* handle( - app, - decisionRequest(randomUUID(), assertion.token, { 'x-correlation-id': '' }), - ); - expect(missingCorrelation.status).toBe(400); - expect(reads).toBe(0); - const valid = yield* handle(app, decisionRequest(randomUUID(), assertion.token)); - expect(valid.status).toBe(404); - expect(reads).toBe(1); - expect(receivedPrincipals).toEqual([principal]); - - const unavailableApp = yield* mountApp(harness, {}, readRuntime); - const unavailable = yield* handle( - unavailableApp, - decisionRequest(randomUUID(), assertion.otherToken), - ); - expect(unavailable.status).toBe(503); - expect(unavailable.headers.get('www-authenticate')).toBe(null); - const body = yield* Effect.promise(() => unavailable.json()); - expect(Schema.is(Schema.TaggedStruct('PartyMatchDecisionUnavailableProblem', {}))(body)).toBe( - true, - ); - expect(body.retryable).toBe(true); - expect(reads).toBe(1); - }), + ); + }), ); -it.live( - 'the complete generated governed Read seam maps every Core failure to its declared HTTP problem', - () => - Effect.gen(function* mapEveryGovernedReadFailure() { - const assertion = yield* makeAssertion(); - const reason = 'private governed Read diagnostic'; - const initialFailure = new ModuleStateCheckUnavailableError({ - code: 'module_state_check_unavailable', - reason, - }); - const cases: readonly [ReadCoreError, number, string][] = [ - [initialFailure, 503, 'PartyMatchDecisionUnavailableProblem'], - [ - new ModuleStateDeniedError({ code: 'module_state_denied', reason }), - 403, - 'PartyMatchDecisionForbiddenProblem', - ], - [ - new OperationAuthenticationRequired({ - code: 'operation_authentication_required', - reason, - }), - 401, - 'PartyMatchDecisionAuthenticationProblem', - ], - [ - new OperationContextDenied({ code: 'operation_context_denied', reason }), - 403, - 'PartyMatchDecisionForbiddenProblem', - ], - [ - new OperationContextInvalid({ code: 'operation_context_invalid', reason }), - 403, - 'PartyMatchDecisionForbiddenProblem', - ], - [ - new OperationContextUnavailable({ code: 'operation_context_unavailable', reason }), - 503, - 'PartyMatchDecisionUnavailableProblem', - ], - [ - new ReadEvidencePersistenceError({ code: 'read_evidence_persistence_failed', reason }), - 503, - 'PartyMatchDecisionUnavailableProblem', - ], - [ - new ReadEvidenceValidationError({ code: 'read_evidence_invalid', reason }), - 500, - 'PartyMatchDecisionInternalProblem', - ], - [ - new ReadHandlerExecutionError({ code: 'read_handler_execution_failed', reason }), - 500, - 'PartyMatchDecisionInternalProblem', - ], - [ - new ReadHandlerNotFound({ code: 'read_handler_not_found', reason }), - 404, - 'PartyMatchDecisionNotFoundProblem', - ], - [ - new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), - 503, - 'PartyMatchDecisionUnavailableProblem', - ], - [ - new ReadInputValidationError({ code: 'read_input_invalid', reason }), - 400, - 'PartyMatchDecisionInvalidProblem', - ], - [ - new ReadPermissionDenied({ code: 'read_permission_denied', reason }), - 403, - 'PartyMatchDecisionForbiddenProblem', - ], - [ - new ReadPermissionUnavailable({ code: 'read_permission_unavailable', reason }), - 503, - 'PartyMatchDecisionUnavailableProblem', - ], - [ - new ReadPolicyDenied({ - code: 'read_policy_denied', - httpStatus: 409, - policyReasonCode: 'policy_conflict', - reason, +it.live('redemption storage outages return safe retryable problems before Action and Read lifecycles', () => + Effect.gen(function* reportRedemptionOutages() { + const assertion = yield* makeAssertion(); + const harness = yield* makeActionTestHarness(); + const { readRuntime, readCount } = makeMissingDecisionReadRuntime(); + const app = yield* mountApp(harness, assertion.environment, readRuntime, { + consume: () => + Effect.fail( + new GatewayAssertionRedemptionUnavailableError({ + reason: 'private redemption storage diagnostic', }), - 409, - 'PartyMatchDecisionPolicyConflictProblem', - ], - [ - new ReadPolicyDenied({ - code: 'read_policy_denied', - httpStatus: 422, - policyReasonCode: 'policy_ineligible', - reason, + ), + }); + const before = harness.snapshot(); + yield* forEachSequential( + [ + { + request: commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'redemption-unavailable', }), - 422, - 'PartyMatchDecisionPolicyProblem', - ], - [ - new ReadPolicyEvaluationError({ code: 'read_policy_evaluation_failed', reason }), - 503, - 'PartyMatchDecisionUnavailableProblem', - ], - [ - new ReadResultValidationError({ code: 'read_result_invalid', reason }), - 500, - 'PartyMatchDecisionInternalProblem', - ], - ]; - let failure: ReadCoreError = initialFailure; - let reads = 0; - const readRuntime: ReadRuntimeService = { - runRead: () => { - reads += 1; - return Effect.fail(failure); + tag: 'PartyCommandUnavailableProblem', }, - }; - const harness = yield* makeActionTestHarness(); - const app = yield* mountApp(harness, assertion.environment, readRuntime); - yield* forEachSequential(cases, ([nextFailure, expectedStatus, expectedTag]) => - Effect.gen(function* verifyGovernedReadFailure() { - failure = nextFailure; - const response = yield* handle(app, decisionRequest(randomUUID(), assertion.token)); - expect(response.status, nextFailure.code).toBe(expectedStatus); + { + request: decisionRequest(randomUUID(), assertion.token), + tag: 'PartyMatchDecisionUnavailableProblem', + }, + ], + ({ request, tag }) => + Effect.gen(function* reportRedemptionOutageCase() { + const response = yield* handle(app, request); + expect(response.status).toBe(503); + expect(response.headers.get('www-authenticate')).toBe(null); expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); - expect(response.headers.get('www-authenticate')).toBe( - expectedStatus === 401 ? 'Bearer' : null, - ); const body = yield* Effect.promise(() => response.json()); - expect(Schema.is(Schema.TaggedStruct(expectedTag, {}))(body), nextFailure.code).toBe( - true, - ); - expect(body.status, nextFailure.code).toBe(expectedStatus); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes( - reason, + expect(Predicate.isTagged(body, tag)).toBe(true); + expect(body.status).toBe(503); + expect(body.retryable).toBe(true); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body); + expect(encoded.includes('private redemption')).toBe(false); + expect(encoded.includes(assertion.token)).toBe(false); + expect(encoded.includes(principal.principalId)).toBe(false); + expect(encoded.includes(principal.tenantId)).toBe(false); + expect(readCount()).toBe(0); + expect(harness.snapshot()).toEqual(before); + }), + ); + }), +); + +it.live('generated governed reads authenticate through the shared adapter before starting ReadRuntime', () => + Effect.gen(function* authenticateGovernedReads() { + const assertion = yield* makeAssertion(); + const harness = yield* makeActionTestHarness(); + let reads = 0; + const receivedPrincipals: unknown[] = []; + const readRuntime: ReadRuntimeService = { + runRead: (input) => + Effect.sync(() => { + reads += 1; + receivedPrincipals.push(input.principal); + }).pipe( + Effect.andThen( + Effect.fail( + new ReadHandlerNotFound({ + code: 'read_handler_not_found', + reason: 'No fixture decision', + }), ), - nextFailure.code, - ).toBe(false); - if (expectedStatus === 503) { - expect(body.retryable, nextFailure.code).toBe(true); - } + ), + ), + }; + const app = yield* mountApp(harness, assertion.environment, readRuntime); + yield* forEachSequential([undefined, 'not-a-jwt'], (token) => + Effect.gen(function* rejectMissingGovernedCredentials() { + const response = yield* handle(app, decisionRequest(randomUUID(), token)); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer'); + expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + const body = yield* Effect.promise(() => response.json()); + expect(Schema.is(Schema.TaggedStruct('PartyMatchDecisionAuthenticationProblem', {}))(body)).toBe(true); + expect(reads).toBe(0); + }), + ); + const missingCorrelation = yield* handle( + app, + decisionRequest(randomUUID(), assertion.token, { + 'x-correlation-id': '', + }), + ); + expect(missingCorrelation.status).toBe(400); + expect(reads).toBe(0); + const valid = yield* handle(app, decisionRequest(randomUUID(), assertion.token)); + expect(valid.status).toBe(404); + expect(reads).toBe(1); + expect(receivedPrincipals).toEqual([principal]); + + const unavailableApp = yield* mountApp(harness, {}, readRuntime); + const unavailable = yield* handle(unavailableApp, decisionRequest(randomUUID(), assertion.otherToken)); + expect(unavailable.status).toBe(503); + expect(unavailable.headers.get('www-authenticate')).toBe(null); + const body = yield* Effect.promise(() => unavailable.json()); + expect(Schema.is(Schema.TaggedStruct('PartyMatchDecisionUnavailableProblem', {}))(body)).toBe(true); + expect(body.retryable).toBe(true); + expect(reads).toBe(1); + }), +); + +it.live('the complete generated governed Read seam maps every Core failure to its declared HTTP problem', () => + Effect.gen(function* mapEveryGovernedReadFailure() { + const assertion = yield* makeAssertion(); + const reason = 'private governed Read diagnostic'; + const initialFailure = new ModuleStateCheckUnavailableError({ + code: 'module_state_check_unavailable', + reason, + }); + const cases: readonly [ReadCoreError, number, string][] = [ + [initialFailure, 503, 'PartyMatchDecisionUnavailableProblem'], + [new ModuleStateDeniedError({ code: 'module_state_denied', reason }), 403, 'PartyMatchDecisionForbiddenProblem'], + [ + new OperationAuthenticationRequired({ + code: 'operation_authentication_required', + reason, }), - ); - expect(reads).toBe(cases.length); - }), + 401, + 'PartyMatchDecisionAuthenticationProblem', + ], + [ + new OperationContextDenied({ + code: 'operation_context_denied', + reason, + }), + 403, + 'PartyMatchDecisionForbiddenProblem', + ], + [ + new OperationContextInvalid({ + code: 'operation_context_invalid', + reason, + }), + 403, + 'PartyMatchDecisionForbiddenProblem', + ], + [ + new OperationContextUnavailable({ + code: 'operation_context_unavailable', + reason, + }), + 503, + 'PartyMatchDecisionUnavailableProblem', + ], + [ + new ReadEvidencePersistenceError({ + code: 'read_evidence_persistence_failed', + reason, + }), + 503, + 'PartyMatchDecisionUnavailableProblem', + ], + [ + new ReadEvidenceValidationError({ + code: 'read_evidence_invalid', + reason, + }), + 500, + 'PartyMatchDecisionInternalProblem', + ], + [ + new ReadHandlerExecutionError({ + code: 'read_handler_execution_failed', + reason, + }), + 500, + 'PartyMatchDecisionInternalProblem', + ], + [new ReadHandlerNotFound({ code: 'read_handler_not_found', reason }), 404, 'PartyMatchDecisionNotFoundProblem'], + [ + new ReadHandlerUnavailable({ + code: 'read_handler_unavailable', + reason, + }), + 503, + 'PartyMatchDecisionUnavailableProblem', + ], + [new ReadInputValidationError({ code: 'read_input_invalid', reason }), 400, 'PartyMatchDecisionInvalidProblem'], + [new ReadPermissionDenied({ code: 'read_permission_denied', reason }), 403, 'PartyMatchDecisionForbiddenProblem'], + [ + new ReadPermissionUnavailable({ + code: 'read_permission_unavailable', + reason, + }), + 503, + 'PartyMatchDecisionUnavailableProblem', + ], + [ + new ReadPolicyDenied({ + code: 'read_policy_denied', + httpStatus: 409, + policyReasonCode: 'policy_conflict', + reason, + }), + 409, + 'PartyMatchDecisionPolicyConflictProblem', + ], + [ + new ReadPolicyDenied({ + code: 'read_policy_denied', + httpStatus: 422, + policyReasonCode: 'policy_ineligible', + reason, + }), + 422, + 'PartyMatchDecisionPolicyProblem', + ], + [ + new ReadPolicyEvaluationError({ + code: 'read_policy_evaluation_failed', + reason, + }), + 503, + 'PartyMatchDecisionUnavailableProblem', + ], + [ + new ReadResultValidationError({ + code: 'read_result_invalid', + reason, + }), + 500, + 'PartyMatchDecisionInternalProblem', + ], + ]; + let failure: ReadCoreError = initialFailure; + let reads = 0; + const readRuntime: ReadRuntimeService = { + runRead: () => { + reads += 1; + return Effect.fail(failure); + }, + }; + const harness = yield* makeActionTestHarness(); + const app = yield* mountApp(harness, assertion.environment, readRuntime); + yield* forEachSequential(cases, ([nextFailure, expectedStatus, expectedTag]) => + Effect.gen(function* verifyGovernedReadFailure() { + failure = nextFailure; + const response = yield* handle(app, decisionRequest(randomUUID(), assertion.token)); + expect(response.status, nextFailure.code).toBe(expectedStatus); + expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + expect(response.headers.get('www-authenticate')).toBe(expectedStatus === 401 ? 'Bearer' : null); + const body = yield* Effect.promise(() => response.json()); + expect(Schema.is(Schema.TaggedStruct(expectedTag, {}))(body), nextFailure.code).toBe(true); + expect(body.status, nextFailure.code).toBe(expectedStatus); + expect( + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes(reason), + nextFailure.code, + ).toBe(false); + if (expectedStatus === 503) { + expect(body.retryable, nextFailure.code).toBe(true); + } + }), + ); + expect(reads).toBe(cases.length); + }), ); it.live('the generated governed Read seam sanitizes unexpected runtime defects', () => @@ -917,65 +909,49 @@ it.live('the generated governed Read seam sanitizes unexpected runtime defects', expect(response.status).toBe(500); expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); const body = yield* Effect.promise(() => response.json()); - expect(Schema.is(Schema.TaggedStruct('PartyMatchDecisionInternalProblem', {}))(body)).toBe( - true, - ); + expect(Schema.is(Schema.TaggedStruct('PartyMatchDecisionInternalProblem', {}))(body)).toBe(true); expect(body.status).toBe(500); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes('private'), - ).toBe(false); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes('private')).toBe(false); }), ); -it.live( - 'replayed assertions are challenged before a second Action or generated Read lifecycle', - () => - Effect.gen(function* rejectAssertionReplays() { - const assertion = yield* makeAssertion(); - const harness = yield* makeActionTestHarness(); - const { readRuntime, readCount } = makeMissingDecisionReadRuntime(); - const app = yield* mountApp( - harness, - assertion.environment, - readRuntime, - makeSingleUseRedemption(), - ); - const firstAction = yield* handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'first-redemption', - }), - ); - expect(firstAction.status).not.toBe(401); - expect(harness.snapshot().invocations.length).toBe(1); - const replayedAction = yield* handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'second-redemption', - }), - ); - expect(replayedAction.status).toBe(401); - expect(replayedAction.headers.get('www-authenticate')).toBe('Bearer'); - expect(replayedAction.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); - expect(harness.snapshot().invocations.length).toBe(1); - const actionAssertionReadReplay = yield* handle( - app, - decisionRequest(randomUUID(), assertion.token), - ); - expect(actionAssertionReadReplay.status).toBe(401); - expect(actionAssertionReadReplay.headers.get('www-authenticate')).toBe('Bearer'); - expect(readCount()).toBe(0); - const firstRead = yield* handle(app, decisionRequest(randomUUID(), assertion.otherToken)); - expect(firstRead.status).toBe(404); - expect(readCount()).toBe(1); - const replayedRead = yield* handle(app, decisionRequest(randomUUID(), assertion.otherToken)); - expect(replayedRead.status).toBe(401); - expect(replayedRead.headers.get('www-authenticate')).toBe('Bearer'); - expect(replayedRead.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); - expect(readCount()).toBe(1); - }), +it.live('replayed assertions are challenged before a second Action or generated Read lifecycle', () => + Effect.gen(function* rejectAssertionReplays() { + const assertion = yield* makeAssertion(); + const harness = yield* makeActionTestHarness(); + const { readRuntime, readCount } = makeMissingDecisionReadRuntime(); + const app = yield* mountApp(harness, assertion.environment, readRuntime, makeSingleUseRedemption()); + const firstAction = yield* handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'first-redemption', + }), + ); + expect(firstAction.status).not.toBe(401); + expect(harness.snapshot().invocations.length).toBe(1); + const replayedAction = yield* handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'second-redemption', + }), + ); + expect(replayedAction.status).toBe(401); + expect(replayedAction.headers.get('www-authenticate')).toBe('Bearer'); + expect(replayedAction.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + expect(harness.snapshot().invocations.length).toBe(1); + const actionAssertionReadReplay = yield* handle(app, decisionRequest(randomUUID(), assertion.token)); + expect(actionAssertionReadReplay.status).toBe(401); + expect(actionAssertionReadReplay.headers.get('www-authenticate')).toBe('Bearer'); + expect(readCount()).toBe(0); + const firstRead = yield* handle(app, decisionRequest(randomUUID(), assertion.otherToken)); + expect(firstRead.status).toBe(404); + expect(readCount()).toBe(1); + const replayedRead = yield* handle(app, decisionRequest(randomUUID(), assertion.otherToken)); + expect(replayedRead.status).toBe(401); + expect(replayedRead.headers.get('www-authenticate')).toBe('Bearer'); + expect(replayedRead.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + expect(readCount()).toBe(1); + }), ); it.live('correlation and idempotency are mandatory before the Core Action lifecycle', () => @@ -990,22 +966,11 @@ it.live('correlation and idempotency are mandatory before the Core Action lifecy return harness.runtime.runAction(input); }, }; - const app = yield* mountApp( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - observingRuntime, - ); - const missingKey = yield* handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token), - ); + const app = yield* mountApp(harness, assertion.environment, undefined, nonPersistingRedemption, observingRuntime); + const missingKey = yield* handle(app, commandRequest('request-search-rebuild', {}, assertion.token)); expect(missingKey.status).toBe(428); const missingKeyBody = yield* Effect.promise(() => missingKey.json()); - expect( - Schema.is(Schema.TaggedStruct('PartyCommandPreconditionRequiredProblem', {}))(missingKeyBody), - ).toBe(true); + expect(Schema.is(Schema.TaggedStruct('PartyCommandPreconditionRequiredProblem', {}))(missingKeyBody)).toBe(true); expect(runtimeCalls).toBe(1); const missingCorrelation = yield* handle( app, @@ -1016,11 +981,7 @@ it.live('correlation and idempotency are mandatory before the Core Action lifecy ); expect(missingCorrelation.status).toBe(400); const missingCorrelationBody = yield* Effect.promise(() => missingCorrelation.json()); - expect( - Schema.is(Schema.TaggedStruct('PartyCommandInvalidRequestProblem', {}))( - missingCorrelationBody, - ), - ).toBe(true); + expect(Schema.is(Schema.TaggedStruct('PartyCommandInvalidRequestProblem', {}))(missingCorrelationBody)).toBe(true); expect(runtimeCalls).toBe(1); const oversizedCorrelation = yield* handle( app, @@ -1038,38 +999,36 @@ it.live('correlation and idempotency are mandatory before the Core Action lifecy }), ); -it.live( - 'the governed runner passes safe transport metadata through one complete Action execution', - () => - Effect.gen(function* preserveSafeActionTransport() { - const assertion = yield* makeAssertion(); - const correlationId = 'x'.repeat(200); - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - }); - const app = yield* mountApp(harness, assertion.environment); - const response = yield* handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'transport-test', - 'x-correlation-id': correlationId, - 'x-trace-id': 'trace-transport-test', - }), - ); - expect(response.status).toBe(200); - const snapshot = harness.snapshot(); - expect(snapshot.invocations.length).toBe(1); - expect(snapshot.committed.length).toBe(1); - expect(snapshot.transactionCount).toBe(1); - expect(snapshot.committed[0]?.transport).toEqual({ - correlationId, - idempotencyKey: 'transport-test', - traceId: 'trace-transport-test', - }); - expect(snapshot.committed[0]?.principal).toEqual(principal); - expect(snapshot.committed[0]?.actionKey).toBe('party.registry.request-search-rebuild'); - }), +it.live('the governed runner passes safe transport metadata through one complete Action execution', () => + Effect.gen(function* preserveSafeActionTransport() { + const assertion = yield* makeAssertion(); + const correlationId = 'x'.repeat(200); + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + }); + const app = yield* mountApp(harness, assertion.environment); + const response = yield* handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'transport-test', + 'x-correlation-id': correlationId, + 'x-trace-id': 'trace-transport-test', + }), + ); + expect(response.status).toBe(200); + const snapshot = harness.snapshot(); + expect(snapshot.invocations.length).toBe(1); + expect(snapshot.committed.length).toBe(1); + expect(snapshot.transactionCount).toBe(1); + expect(snapshot.committed[0]?.transport).toEqual({ + correlationId, + idempotencyKey: 'transport-test', + traceId: 'trace-transport-test', + }); + expect(snapshot.committed[0]?.principal).toEqual(principal); + expect(snapshot.committed[0]?.actionKey).toBe('party.registry.request-search-rebuild'); + }), ); it.live('a decoded relationship timestamp reaches the Action runtime exactly once', () => @@ -1088,13 +1047,7 @@ it.live('a decoded relationship timestamp reaches the Action runtime exactly onc return Effect.fail(failure); }, }; - const app = yield* mountApp( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - actionRuntime, - ); + const app = yield* mountApp(harness, assertion.environment, undefined, nonPersistingRedemption, actionRuntime); const response = yield* handle( app, commandRequest('create-party-relationship', relationshipPayload, assertion.token, { @@ -1263,13 +1216,7 @@ it.live('the endpoint-owned mapper preserves representative Core failure semanti resolveActionCommit: harness.runtime.resolveActionCommit, runAction: () => Effect.fail(failure), }; - const app = yield* mountApp( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - failingRuntime, - ); + const app = yield* mountApp(harness, assertion.environment, undefined, nonPersistingRedemption, failingRuntime); const response = yield* handle( app, commandRequest('request-search-rebuild', {}, assertion.token, { @@ -1316,11 +1263,7 @@ it.live('endpoint-local mappings keep declared not-found capability distinct ove expect(Predicate.isTagged(attachBody, 'ContactsInternalProblem')).toBe(true); const archiveResponse = yield* handle( app, - engagementRequest( - '/contacts/engagement/organizations/archive', - { profileRef }, - assertion.otherToken, - ), + engagementRequest('/contacts/engagement/organizations/archive', { profileRef }, assertion.otherToken), ); expect(archiveResponse.status).toBe(404); const archiveBody = yield* Schema.decodeUnknownEffect(ProblemTagSchema)( @@ -1353,41 +1296,46 @@ it.live('real Core permission denial is a durable 403 and does not execute the c }), ); -it.live( - 'the real handler translates domain conflicts and rolls back without successful evidence', - () => - Effect.gen(function* rollBackDomainConflict() { - const assertion = yield* makeAssertion(); - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - services: [ - bindActionTestServices(archivePartyAction, { - transition: () => Effect.succeed({ _tag: 'conflict' as const, value: archivedParty }), - }), - ], - }); - const app = yield* mountApp(harness, assertion.environment); - - const response = yield* handle( - app, - commandRequest('archive-party', archivePayload, assertion.token, { - 'idempotency-key': 'conflict-test', +it.live('the real handler translates domain conflicts and rolls back without successful evidence', () => + Effect.gen(function* rollBackDomainConflict() { + const assertion = yield* makeAssertion(); + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + services: [ + bindActionTestServices(archivePartyAction, { + transition: () => + Effect.succeed({ + _tag: 'conflict' as const, + value: archivedParty, + }), }), - ); - expect(response.status).toBe(409); - const body = yield* Effect.promise(() => response.json()); - expect(Predicate.isTagged(body, 'PartyCommandConflictProblem')).toBe(true); - expect(body.code).toBe('party_lifecycle_conflict'); - expect(harness.snapshot().invocations.length).toBe(1); - expect(harness.snapshot().committed.length).toBe(0); - }), + ], + }); + const app = yield* mountApp(harness, assertion.environment); + + const response = yield* handle( + app, + commandRequest('archive-party', archivePayload, assertion.token, { + 'idempotency-key': 'conflict-test', + }), + ); + expect(response.status).toBe(409); + const body = yield* Effect.promise(() => response.json()); + expect(Predicate.isTagged(body, 'PartyCommandConflictProblem')).toBe(true); + expect(body.code).toBe('party_lifecycle_conflict'); + expect(harness.snapshot().invocations.length).toBe(1); + expect(harness.snapshot().committed.length).toBe(0); + }), ); it.live('alias conflicts preserve only safe canonical recovery metadata', () => Effect.gen(function* preserveSafeAliasRecoveryMetadata() { const assertion = yield* makeAssertion(); - const canonicalPartyRef = { ...partyRef, resourceId: 'a4000000-0000-4000-8000-000000000002' }; + const canonicalPartyRef = { + ...partyRef, + resourceId: 'a4000000-0000-4000-8000-000000000002', + }; const harness = yield* makeActionTestHarness({ actionPermission: 'allowed', tenantPermission: 'allowed', @@ -1419,9 +1367,7 @@ it.live('alias conflicts preserve only safe canonical recovery metadata', () => expect(body.aliasPartyRef).toEqual(partyRef); expect(body.canonicalPartyRef).toEqual(canonicalPartyRef); expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes( - 'Private diagnostic', - ), + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes('Private diagnostic'), ).toBe(false); expect(harness.snapshot().committed.length).toBe(0); }), @@ -1465,72 +1411,68 @@ it.live('committed request replay stays a terminal 409 and does not execute or e }), ); -it.live( - 'declared not-found, capability-unavailable and unexpected defects retain safe distinct HTTP statuses', - () => - Effect.gen(function* preserveDistinctFailureStatuses() { - const assertion = yield* makeAssertion(); - const cases = [ - { - status: 404, - tag: 'PartyCommandNotFoundProblem', - service: bindActionTestServices(archivePartyAction, { - transition: () => Effect.succeed({ _tag: 'not_found' as const }), - }), - }, - { - status: 503, - tag: 'PartyCommandUnavailableProblem', - service: bindActionTestServices(archivePartyAction, { - transition: () => - Effect.fail( - new PartyPersistenceUnavailable({ - code: 'party_persistence_unavailable', - reason: 'private database diagnostic', - }), - ), - }), - }, - { - status: 500, - tag: 'PartyCommandInternalProblem', - service: bindActionTestServices(archivePartyAction, { - transition: () => Effect.die('private unexpected diagnostic'), - }), - }, - ]; - yield* forEachSequential(cases, (item) => - Effect.gen(function* verifySafeFailureResponse() { - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - services: [item.service], - }); - const app = yield* mountApp(harness, assertion.environment); - - const response = yield* handle( - app, - commandRequest('archive-party', archivePayload, assertion.token, { - 'idempotency-key': `failure-${item.status}`, - }), - ); - expect(response.status).toBe(item.status); - expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); - const body = yield* Effect.promise(() => response.json()); - expect(Predicate.isTagged(body, item.tag)).toBe(true); - expect(body.status).toBe(item.status); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes( - 'private', +it.live('declared not-found, capability-unavailable and unexpected defects retain safe distinct HTTP statuses', () => + Effect.gen(function* preserveDistinctFailureStatuses() { + const assertion = yield* makeAssertion(); + const cases = [ + { + status: 404, + tag: 'PartyCommandNotFoundProblem', + service: bindActionTestServices(archivePartyAction, { + transition: () => Effect.succeed({ _tag: 'not_found' as const }), + }), + }, + { + status: 503, + tag: 'PartyCommandUnavailableProblem', + service: bindActionTestServices(archivePartyAction, { + transition: () => + Effect.fail( + new PartyPersistenceUnavailable({ + code: 'party_persistence_unavailable', + reason: 'private database diagnostic', + }), ), - ).toBe(false); - if (item.status === 503) { - expect(body.retryable).toBe(true); - } - expect(harness.snapshot().committed.length).toBe(0); }), - ); - }), + }, + { + status: 500, + tag: 'PartyCommandInternalProblem', + service: bindActionTestServices(archivePartyAction, { + transition: () => Effect.die('private unexpected diagnostic'), + }), + }, + ]; + yield* forEachSequential(cases, (item) => + Effect.gen(function* verifySafeFailureResponse() { + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + services: [item.service], + }); + const app = yield* mountApp(harness, assertion.environment); + + const response = yield* handle( + app, + commandRequest('archive-party', archivePayload, assertion.token, { + 'idempotency-key': `failure-${item.status}`, + }), + ); + expect(response.status).toBe(item.status); + expect(response.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + const body = yield* Effect.promise(() => response.json()); + expect(Predicate.isTagged(body, item.tag)).toBe(true); + expect(body.status).toBe(item.status); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes('private')).toBe( + false, + ); + if (item.status === 503) { + expect(body.retryable).toBe(true); + } + expect(harness.snapshot().committed.length).toBe(0); + }), + ); + }), ); it.live('semantically insufficient Party evidence is a declared 422, not a server defect', () => @@ -1563,86 +1505,85 @@ it.live('semantically insufficient Party evidence is a declared 422, not a serve const body = yield* Effect.promise(() => response.json()); expect(body.code).toBe('party_evidence_insufficient'); expect(body.status).toBe(422); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes( - 'Private evidence', - ), - ).toBe(false); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(body)).includes('Private evidence')).toBe( + false, + ); expect(harness.snapshot().committed.length).toBe(0); }), ); -it.live( - 'the Core request hash rejects reuse of an idempotency key for a different command payload', - () => - Effect.gen(function* rejectIdempotencyPayloadMismatch() { - const assertion = yield* makeAssertion(); - let executions = 0; - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - services: [ - bindActionTestServices(createPartyAction, { - createOrMatch: () => - Effect.sync(() => { - executions += 1; - return { - outcome: 'CREATED' as const, - partyRef, - decisionRef: { - ...partyRef, - resourceType: 'party.registry.party-match-decision' as const, - }, - }; - }), - }), - ], - }); - const app = yield* mountApp(harness, assertion.environment); - - const first = yield* handle( - app, - commandRequest('create-party', createPayload, assertion.token, { - 'idempotency-key': 'hash-test', +it.live('the Core request hash rejects reuse of an idempotency key for a different command payload', () => + Effect.gen(function* rejectIdempotencyPayloadMismatch() { + const assertion = yield* makeAssertion(); + let executions = 0; + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + services: [ + bindActionTestServices(createPartyAction, { + createOrMatch: () => + Effect.sync(() => { + executions += 1; + return { + outcome: 'CREATED' as const, + partyRef, + decisionRef: { + ...partyRef, + resourceType: 'party.registry.party-match-decision' as const, + }, + }; + }), }), - ); - expect(first.status).toBe(200); - const changed = yield* handle( - app, - commandRequest( - 'create-party', - { candidate: { ...createPayload.candidate, displayName: 'Different organization' } }, - assertion.token, - { 'idempotency-key': 'hash-test' }, - ), - ); - expect(changed.status).toBe(409); - const changedBody = yield* Effect.promise(() => changed.json()); - expect(changedBody.code).toBe('action_request_hash_conflict'); - expect(executions).toBe(1); - expect(harness.snapshot().committed.length).toBe(1); - }), + ], + }); + const app = yield* mountApp(harness, assertion.environment); + + const first = yield* handle( + app, + commandRequest('create-party', createPayload, assertion.token, { + 'idempotency-key': 'hash-test', + }), + ); + expect(first.status).toBe(200); + const changed = yield* handle( + app, + commandRequest( + 'create-party', + { + candidate: { + ...createPayload.candidate, + displayName: 'Different organization', + }, + }, + assertion.token, + { 'idempotency-key': 'hash-test' }, + ), + ); + expect(changed.status).toBe(409); + const changedBody = yield* Effect.promise(() => changed.json()); + expect(changedBody.code).toBe('action_request_hash_conflict'); + expect(executions).toBe(1); + expect(harness.snapshot().committed.length).toBe(1); + }), ); -it.live( - 'commit resolution requires authentication and a valid invocation without creating an Action', - () => - Effect.gen(function* validateCommitResolutionRequest() { - const assertion = yield* makeAssertion(); - const harness = yield* makeActionTestHarness(); - const app = yield* mountApp(harness, assertion.environment); +it.live('commit resolution requires authentication and a valid invocation without creating an Action', () => + Effect.gen(function* validateCommitResolutionRequest() { + const assertion = yield* makeAssertion(); + const harness = yield* makeActionTestHarness(); + const app = yield* mountApp(harness, assertion.environment); - const missingAuth = yield* handle(app, recoveryRequest(randomUUID())); - expect(missingAuth.status).toBe(401); - expect(missingAuth.headers.get('www-authenticate')).toBe('Bearer'); - const malformed = yield* handle(app, recoveryRequest('not-an-id', assertion.token)); - expect(malformed.status).toBe(400); - const malformedBody = yield* Effect.promise(() => malformed.json()); - expect(Predicate.isTagged(malformedBody, 'PartyCommandInvalidRequestProblem')).toBe(true); - const absent = yield* handle(app, recoveryRequest(randomUUID(), assertion.token)); - expect(absent.status).toBe(404); - expect(harness.snapshot().invocations.length).toBe(0); - }), + const missingAuth = yield* handle(app, recoveryRequest(randomUUID())); + expect(missingAuth.status).toBe(401); + expect(missingAuth.headers.get('www-authenticate')).toBe('Bearer'); + const malformed = yield* handle(app, recoveryRequest('not-an-id', assertion.token)); + expect(malformed.status).toBe(400); + const malformedBody = yield* Effect.promise(() => malformed.json()); + expect(Predicate.isTagged(malformedBody, 'PartyCommandInvalidRequestProblem')).toBe(true); + const absent = yield* handle(app, recoveryRequest(randomUUID(), assertion.token)); + expect(absent.status).toBe(404); + expect(harness.snapshot().invocations.length).toBe(0); + }), ); it.live('an open invocation resolves explicitly without authorizing automatic command retry', () => @@ -1652,7 +1593,11 @@ it.live('an open invocation resolves explicitly without authorizing automatic co actionPermission: 'allowed', services: [ bindActionTestServices(archivePartyAction, { - transition: () => Effect.succeed({ _tag: 'conflict' as const, value: archivedParty }), + transition: () => + Effect.succeed({ + _tag: 'conflict' as const, + value: archivedParty, + }), }), ], tenantPermission: 'allowed', @@ -1671,15 +1616,7 @@ it.live('an open invocation resolves explicitly without authorizing automatic co if (invocationId === undefined || invocationId.length === 0) { throw new Error('Expected truthy value'); } - const resolution = yield* handle(app, recoveryRequest(invocationId, assertion.token)); - expect(resolution.status).toBe(200); - const resolutionBody = yield* Effect.promise(() => resolution.json()); - expect(Schema.is(ResolvePartyCommandCommitResultSchema)(resolutionBody)).toBe(true); - expect(Struct.omit(resolutionBody, ['_tag'])).toEqual({ - invocationId, - retryCommand: false, - state: 'OPEN', - }); + yield* expectCommitResolution(app, invocationId, assertion.token, 'OPEN'); expect(harness.snapshot().invocations.length).toBe(1); expect(harness.snapshot().committed.length).toBe(0); }), @@ -1733,9 +1670,7 @@ it.live( const reads: ReadRuntimeService = { runRead: (input) => Effect.gen(function* readOriginalDecision() { - const actor = yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)( - input.principal, - ).pipe( + const actor = yield* Schema.decodeUnknownEffect(TrustedPrincipalContextSchema)(input.principal).pipe( Effect.mapError( () => new ReadPermissionDenied({ @@ -1744,18 +1679,13 @@ it.live( }), ), ); - if ( - actor.principalId !== principal.principalId || - actor.tenantId !== principal.tenantId - ) { + if (actor.principalId !== principal.principalId || actor.tenantId !== principal.tenantId) { return yield* new ReadPermissionDenied({ code: 'read_permission_denied', reason: 'Decision belongs to another principal', }); } - const query = yield* Schema.decodeUnknownEffect(PartyMatchDecisionRequestSchema)( - input.input, - ).pipe( + const query = yield* Schema.decodeUnknownEffect(PartyMatchDecisionRequestSchema)(input.input).pipe( Effect.mapError( () => new ReadHandlerNotFound({ @@ -1765,18 +1695,14 @@ it.live( ), ); const decision = - query.actionInvocationId === undefined - ? undefined - : decisions.get(query.actionInvocationId); + query.actionInvocationId === undefined ? undefined : decisions.get(query.actionInvocationId); if (decision === undefined) { return yield* new ReadHandlerNotFound({ code: 'read_handler_not_found', reason: 'No persisted decision', }); } - return yield* Schema.decodeUnknownEffect(input.registration.descriptor.resultSchema)( - decision, - ).pipe( + return yield* Schema.decodeEffect(input.registration.descriptor.resultSchema)(decision).pipe( Effect.mapError( () => new ReadResultValidationError({ @@ -1808,20 +1734,9 @@ it.live( expect(body.invocationId).toBe(invocationId); expect(harness.snapshot().committed.length).toBe(1); const committedSnapshot = harness.snapshot(); - const deniedRecovery = yield* handle( - app, - recoveryRequest(invocationId, assertion.otherToken), - ); + const deniedRecovery = yield* handle(app, recoveryRequest(invocationId, assertion.otherToken)); expect(deniedRecovery.status).toBe(404); - const resolution = yield* handle(app, recoveryRequest(invocationId, assertion.token)); - expect(resolution.status).toBe(200); - const resolutionBody = yield* Effect.promise(() => resolution.json()); - expect(Schema.is(ResolvePartyCommandCommitResultSchema)(resolutionBody)).toBe(true); - expect(Struct.omit(resolutionBody, ['_tag'])).toEqual({ - invocationId, - retryCommand: false, - state: 'COMMITTED', - }); + yield* expectCommitResolution(app, invocationId, assertion.token, 'COMMITTED'); const missingReadAuth = yield* handle(app, decisionRequest(invocationId)); expect(missingReadAuth.status).toBe(401); const deniedRead = yield* handle(app, decisionRequest(invocationId, assertion.otherToken)); diff --git a/app/verticals/party-registry/tests/unit/api-integration-contract.test.ts b/app/verticals/party-registry/tests/unit/api-integration-contract.test.ts index 84b3c9986..27ac03721 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-contract.test.ts @@ -1,14 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Inspect source files through the Node filesystem boundary; expires: 2026-12-31. -import { expect, it } from 'effect-rstest'; -import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; -import { Effect, Schema } from 'effect'; +import { NodeFileSystem } from '@effect/platform-node'; +import { FileSystem, Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; -import { - partyRegistryApi, - partyRegistryApiContract, - partyRegistryReadinessSchema, -} from '../../shared/api.ts'; +import { partyRegistryApi, partyRegistryApiContract, partyRegistryReadinessSchema } from '../../shared/api.ts'; import type { OperationContext, partyRegistryOperationContexts } from '../../shared/api.ts'; import { ultramodernApiMarker } from '../../shared/ultramodern-build.ts'; @@ -39,21 +35,13 @@ const apiNames = [ ] as const; type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false; + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; type Expect = Value; type ReadinessPathRemainsLiteral = Expect< - Equal< - typeof partyRegistryApiContract.readinessPath, - '/party-registry-api/party-registry/readiness' - > + Equal >; type ReadinessOperationRouteRemainsLiteral = Expect< - Equal< - (typeof partyRegistryOperationContexts)['readiness']['routePath'], - '/party-registry/readiness' - > + Equal<(typeof partyRegistryOperationContexts)['readiness']['routePath'], '/party-registry/readiness'> >; const operationContextRejectsIdentityMetadata: OperationContext = { @@ -84,9 +72,7 @@ it('aggregates every governed read and search API beside readiness', () => { expect(new Set(endpointPaths).size).toBe(endpointPaths.length); expect(endpointPaths.includes('/party-registry/readiness')).toBe(true); expect(endpointPaths.some((path) => path === '/party-registry')).toBe(false); - expect( - endpointPaths.some((path) => path === '/actions' || path === '/party-registry/actions'), - ).toBe(false); + expect(endpointPaths.some((path) => path === '/actions' || path === '/party-registry/actions')).toBe(false); }); it('keeps readiness tied to the immutable build marker', () => { @@ -105,58 +91,62 @@ it('keeps readiness tied to the immutable build marker', () => { ).toBe(true); }); -it.effect('re-exports every governed generated client without exposing private executors', () => - Effect.gen(function* testProgram2() { - const source = yield* Effect.promise(() => - readFile(new URL('../../src/api/party-registry-client.ts', import.meta.url), 'utf-8'), - ); +it.layer(NodeFileSystem.layer)('api-integration-contract', (suite) => { + suite.effect('re-exports every governed generated client without exposing private executors', () => + Effect.gen(function* testProgram2() { + const source = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL('../../src/api/party-registry-client.ts', import.meta.url))), + ); - for (const client of apiNames.filter( - (name) => - name !== 'foundation' && - name !== 'organizationEngagementMutations' && - name !== 'partyCommands' && - name !== 'partyCommandRecovery' && - name !== 'personEngagementMutations', - )) { - const file = client.replaceAll(/[A-Z]/gu, (value) => `-${value.toLowerCase()}`); - expect(source).toMatch(new RegExp(`\\./${file}-client\\.ts`, 'u')); - } - expect(source).toMatch(/\.\/engagement-profile-client\.ts/u); - expect(source).toMatch(/getPartyRegistryReadiness/u); - expect(source).toMatch(/party-command-client/u); - expect(source).toMatch(/export const partyRegistryClient =/u); - expect(source).toMatch(/createPartyRegistryHttpClient/u); - expect(source).not.toMatch(/createPartyRegistryClient/u); - expect(source).not.toMatch(/makeEffectHttpApiClient\(partyRegistryApi/u); - expect(source).not.toMatch( - /export const (?:createPartyRegistry|listPartyRegistry|getPartyRegistry)\s*=/u, - ); - expect(source).not.toMatch(/action\.ts|runAction|ActionRuntime/u); - }), -); + for (const client of apiNames.filter( + (name) => + name !== 'foundation' && + name !== 'organizationEngagementMutations' && + name !== 'partyCommands' && + name !== 'partyCommandRecovery' && + name !== 'personEngagementMutations', + )) { + const file = client.replaceAll(/[A-Z]/gu, (value) => `-${value.toLowerCase()}`); + expect(source).toMatch(new RegExp(`\\./${file}-client\\.ts`, 'u')); + } + expect(source).toMatch(/\.\/engagement-profile-client\.ts/u); + expect(source).toMatch(/getPartyRegistryReadiness/u); + expect(source).toMatch(/party-command-client/u); + expect(source).toMatch(/export const partyRegistryClient =/u); + expect(source).toMatch(/createPartyRegistryHttpClient/u); + expect(source).not.toMatch(/createPartyRegistryClient/u); + expect(source).not.toMatch(/makeEffectHttpApiClient\(partyRegistryApi/u); + expect(source).not.toMatch(/export const (?:createPartyRegistry|listPartyRegistry|getPartyRegistry)\s*=/u); + expect(source).not.toMatch(/action\.ts|runAction|ActionRuntime/u); + }), + ); -it.effect('exposes only the backend Effect API and no placeholder UI module', () => - Effect.gen(function* testProgram3() { - const [frontendFederation, backendFederation, packageSource] = yield* Effect.promise(() => - Promise.all([ - readFile(new URL('../../module-federation.config.ts', import.meta.url), 'utf-8'), - readFile(new URL('../../backend-federation.config.ts', import.meta.url), 'utf-8'), - readFile(new URL('../../package.json', import.meta.url), 'utf-8'), - ]), - ); - const packageJson: { readonly exports: Record } = - yield* Schema.decodeUnknownEffect( + suite.effect('exposes only the backend Effect API and no placeholder UI module', () => + Effect.gen(function* testProgram3() { + const [frontendFederation, backendFederation, packageSource] = yield* FileSystem.FileSystem.use((fs) => + Effect.all( + [ + fs.readFileString(fileURLToPath(new URL('../../module-federation.config.ts', import.meta.url))), + fs.readFileString(fileURLToPath(new URL('../../backend-federation.config.ts', import.meta.url))), + fs.readFileString(fileURLToPath(new URL('../../package.json', import.meta.url))), + ], + { concurrency: 'unbounded' }, + ), + ); + const packageJson: { readonly exports: Record } = yield* Schema.decodeEffect( Schema.fromJsonString( - Schema.Struct({ exports: Schema.Record(Schema.String, Schema.String) }), + Schema.Struct({ + exports: Schema.Record(Schema.String, Schema.String), + }), ), )(packageSource); - expect(frontendFederation).not.toMatch(/['"]\.\/Route['"]|['"]\.\/Widget['"]/u); - expect(backendFederation).toMatch(/['"]\.\/effect-api['"]/u); - expect(packageJson.exports['./Route']).toBe(undefined); - expect(packageJson.exports['./Widget']).toBe(undefined); - expect(packageJson.exports['./api']).toBe('./shared/api.ts'); - expect(packageJson.exports['./api/client']).toBe('./src/api/party-registry-client.ts'); - }), -); + expect(frontendFederation).not.toMatch(/['"]\.\/Route['"]|['"]\.\/Widget['"]/u); + expect(backendFederation).toMatch(/['"]\.\/effect-api['"]/u); + expect(packageJson.exports['./Route']).toBe(undefined); + expect(packageJson.exports['./Widget']).toBe(undefined); + expect(packageJson.exports['./api']).toBe('./shared/api.ts'); + expect(packageJson.exports['./api/client']).toBe('./src/api/party-registry-client.ts'); + }), + ); +}); diff --git a/app/verticals/party-registry/tests/unit/api-integration-correction-client.test.ts b/app/verticals/party-registry/tests/unit/api-integration-correction-client.test.ts index 9fcb8090f..60ae091ca 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-correction-client.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-correction-client.test.ts @@ -1,12 +1,7 @@ +import { DateTime, Effect, Option, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; +import { FetchHttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http'; -import { DateTime, Effect, Option, Schema } from 'effect'; -import { - FetchHttpClient, - HttpRouter, - HttpServerRequest, - HttpServerResponse, -} from 'effect/unstable/http'; import { CorrectPartyFactPayloadSchema } from '../../shared/command-api.ts'; import { correctPartyFactWithAuthorization, @@ -37,174 +32,174 @@ const originalAssertion = { value: 'Incorrect recorded name', }; -it.effect( - 'public clients discover the first assertion and submit a governed correction with its ID', - () => - Effect.gen(function* apiIntegrationCorrectionClientCase1() { - let corrected = false; - const requests: Request[] = []; - const handleRequest = Effect.gen(function* handleCorrectionRequest() { - const request = yield* HttpServerRequest.HttpServerRequest; - const pathname = request.url; - if (pathname.endsWith('/actions/create-party')) { - return HttpServerResponse.jsonUnsafe({ - decisionRef: { ...partyRef, resourceType: 'party.registry.party-match-decision' }, - outcome: 'CREATED', - partyRef, - }); - } - if (pathname.endsWith('/actions/correct-party-fact')) { - const payload = yield* Schema.decodeUnknownEffect(CorrectPartyFactPayloadSchema)( - yield* request.json, - ); - if (payload.factKind === 'RELATIONSHIP') { - throw new Error('Expected identity correction'); - } - expect(payload.factKind).toBe('DISPLAY_NAME'); - expect(payload.targetAssertionId).toBe(originalAssertionId); - expect(payload.partyId).toBe(partyRef.resourceId); - corrected = true; - return HttpServerResponse.jsonUnsafe({ - correctionRef: { ...partyRef, resourceType: 'party.registry.party-correction' }, - factKind: 'DISPLAY_NAME', - followUp: 'ENRICHMENT_REVIEW', - partyRef, - relationshipRef: null, - replacementAssertionId, - replacementRelationshipRef: null, - retractedAssertionId: originalAssertionId, - }); - } - expect(pathname.endsWith('/reads/party-detail')).toBe(true); - const currentAssertion = corrected - ? { - ...originalAssertion, - assertionId: replacementAssertionId, - supersedesAssertionId: originalAssertionId, - value: 'Corrected name', - } - : originalAssertion; +it.effect('public clients discover the first assertion and submit a governed correction with its ID', () => + Effect.gen(function* apiIntegrationCorrectionClientCase1() { + let corrected = false; + const requests: Request[] = []; + const handleRequest = Effect.gen(function* handleCorrectionRequest() { + const request = yield* HttpServerRequest.HttpServerRequest; + const pathname = request.url; + if (pathname.endsWith('/actions/create-party')) { return HttpServerResponse.jsonUnsafe({ - currentFactAssertions: [currentAssertion], - factHistory: corrected - ? [{ ...originalAssertion, isCurrent: false, state: 'SUPERSEDED' }, currentAssertion] - : [originalAssertion], - party: { - archivedAt: null, - createdAt: timestamp, - displayName: currentAssertion.value, - partyRef, - partyType: 'ORGANIZATION', - revision: corrected ? 2 : 1, - updatedAt: timestamp, - }, - resolution: { - aliasChain: [], - canonicalPartyRef: partyRef, - kind: 'DIRECT', - requestedPartyRef: partyRef, + decisionRef: { + ...partyRef, + resourceType: 'party.registry.party-match-decision', }, + outcome: 'CREATED', + partyRef, }); - }); - const server = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler(HttpRouter.add('*', '/*', handleRequest), { - disableLogger: true, - }), - ), - (resource) => Effect.promise(() => resource.dispose()), - ); - const fakeFetch: typeof fetch = (input, init) => { - const request = new Request(input, init); - requests.push(request); - return server.handler(request); - }; - const options = { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'correction-discovery', - idempotencyKey: 'create-for-correction', - }; - const program = Effect.gen(function* verifyPublicCorrectionWorkflow() { - const created = yield* createPartyWithAuthorization( - { - candidate: { - displayName: originalAssertion.value, - evidenceRefs: ['document:original'], - officialIdentifiers: [], - partyType: 'ORGANIZATION', - provenance: { method: 'MANUAL_REVIEW', source: 'document:original' }, - validFrom: DateTime.makeUnsafe(timestamp), - }, - }, - 'Bearer test-assertion', - options, - ); - expect(created.outcome).toBe('CREATED'); - const before = yield* executePartyDetailWithAuthorization( - { includeFactHistory: true, partyRef }, - 'Bearer test-assertion', - options.correlationId, - options, - ); - const target = before.currentFactAssertions.find( - ({ factKind }) => factKind === 'DISPLAY_NAME', - ); - expect(target).toBeDefined(); - if (target === undefined) { - throw new Error('Expected target to be defined'); + } + if (pathname.endsWith('/actions/correct-party-fact')) { + const payload = yield* Schema.decodeUnknownEffect(CorrectPartyFactPayloadSchema)(yield* request.json); + if (payload.factKind === 'RELATIONSHIP') { + throw new Error('Expected identity correction'); } - const correctionPayload = yield* Schema.decodeUnknownEffect(CorrectPartyFactPayloadSchema)({ - evidenceRefs: ['document:reviewed-error'], - evidenceSource: 'DOCUMENT', + expect(payload.factKind).toBe('DISPLAY_NAME'); + expect(payload.targetAssertionId).toBe(originalAssertionId); + expect(payload.partyId).toBe(partyRef.resourceId); + corrected = true; + return HttpServerResponse.jsonUnsafe({ + correctionRef: { + ...partyRef, + resourceType: 'party.registry.party-correction', + }, factKind: 'DISPLAY_NAME', - partyId: partyRef.resourceId, - policyVersion: 'party-correction.v1', - provenance: { method: 'MANUAL_REVIEW', source: 'document:reviewed-error' }, - reasonCode: 'WRONG_IDENTITY_VALUE', - replacementValue: 'Corrected name', - targetAssertionId: target.assertionId, + followUp: 'ENRICHMENT_REVIEW', + partyRef, + relationshipRef: null, + replacementAssertionId, + replacementRelationshipRef: null, + retractedAssertionId: originalAssertionId, }); - const correction = yield* correctPartyFactWithAuthorization( - correctionPayload, - 'Bearer test-assertion', - { ...options, idempotencyKey: 'correct-first-assertion' }, - ); - expect(correction.retractedAssertionId).toBe(target.assertionId); - const after = yield* executePartyDetailWithAuthorization( - { includeFactHistory: true, partyRef }, - 'Bearer test-assertion', - options.correlationId, - options, - ); - expect(after.currentFactAssertions[0]?.assertionId).toBe(replacementAssertionId); - expect( - Option.getOrElse(after.factHistory, () => []).some( - ({ assertionId, state }) => - assertionId === originalAssertionId && state === 'SUPERSEDED', - ), - ).toBe(true); - }); - yield* program.pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); - expect(requests.length).toBe(4); - const createRequest = requests.find(({ url }) => url.endsWith('/actions/create-party')); - expect(createRequest).toBeDefined(); - if (createRequest === undefined) { - throw new Error('Expected createRequest to be defined'); } - expect(yield* Effect.promise(() => createRequest.json())).toEqual({ - candidate: { - displayName: originalAssertion.value, - evidenceRefs: ['document:original'], - officialIdentifiers: [], + expect(pathname.endsWith('/reads/party-detail')).toBe(true); + const currentAssertion = corrected + ? { + ...originalAssertion, + assertionId: replacementAssertionId, + supersedesAssertionId: originalAssertionId, + value: 'Corrected name', + } + : originalAssertion; + return HttpServerResponse.jsonUnsafe({ + currentFactAssertions: [currentAssertion], + factHistory: corrected + ? [{ ...originalAssertion, isCurrent: false, state: 'SUPERSEDED' }, currentAssertion] + : [originalAssertion], + party: { + archivedAt: null, + createdAt: timestamp, + displayName: currentAssertion.value, + partyRef, partyType: 'ORGANIZATION', - provenance: { method: 'MANUAL_REVIEW', source: 'document:original' }, - validFrom: timestamp, + revision: corrected ? 2 : 1, + updatedAt: timestamp, + }, + resolution: { + aliasChain: [], + canonicalPartyRef: partyRef, + kind: 'DIRECT', + requestedPartyRef: partyRef, }, }); + }); + const server = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler(HttpRouter.add('*', '/*', handleRequest), { + disableLogger: true, + }), + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const fakeFetch: typeof fetch = (input, init) => { + const request = new Request(input, init); + requests.push(request); + return server.handler(request); + }; + const options = { + baseUrl: 'https://party.example/party-registry-api', + correlationId: 'correction-discovery', + idempotencyKey: 'create-for-correction', + }; + const program = Effect.gen(function* verifyPublicCorrectionWorkflow() { + const created = yield* createPartyWithAuthorization( + { + candidate: { + displayName: originalAssertion.value, + evidenceRefs: ['document:original'], + officialIdentifiers: [], + partyType: 'ORGANIZATION', + provenance: { + method: 'MANUAL_REVIEW', + source: 'document:original', + }, + validFrom: DateTime.makeUnsafe(timestamp), + }, + }, + 'Bearer test-assertion', + options, + ); + expect(created.outcome).toBe('CREATED'); + const before = yield* executePartyDetailWithAuthorization( + { includeFactHistory: true, partyRef }, + 'Bearer test-assertion', + options.correlationId, + options, + ); + const target = before.currentFactAssertions.find(({ factKind }) => factKind === 'DISPLAY_NAME'); + expect(target).toBeDefined(); + if (target === undefined) { + throw new Error('Expected target to be defined'); + } + const correctionPayload = yield* Schema.decodeEffect(CorrectPartyFactPayloadSchema)({ + evidenceRefs: ['document:reviewed-error'], + evidenceSource: 'DOCUMENT', + factKind: 'DISPLAY_NAME', + partyId: partyRef.resourceId, + policyVersion: 'party-correction.v1', + provenance: { + method: 'MANUAL_REVIEW', + source: 'document:reviewed-error', + }, + reasonCode: 'WRONG_IDENTITY_VALUE', + replacementValue: 'Corrected name', + targetAssertionId: target.assertionId, + }); + const correction = yield* correctPartyFactWithAuthorization(correctionPayload, 'Bearer test-assertion', { + ...options, + idempotencyKey: 'correct-first-assertion', + }); + expect(correction.retractedAssertionId).toBe(target.assertionId); + const after = yield* executePartyDetailWithAuthorization( + { includeFactHistory: true, partyRef }, + 'Bearer test-assertion', + options.correlationId, + options, + ); + expect(after.currentFactAssertions[0]?.assertionId).toBe(replacementAssertionId); expect( - requests.every( - (request) => request.headers.get('authorization') === 'Bearer test-assertion', + Option.getOrElse(after.factHistory, () => []).some( + ({ assertionId, state }) => assertionId === originalAssertionId && state === 'SUPERSEDED', ), ).toBe(true); - }), + }); + yield* program.pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)); + expect(requests.length).toBe(4); + const createRequest = requests.find(({ url }) => url.endsWith('/actions/create-party')); + expect(createRequest).toBeDefined(); + if (createRequest === undefined) { + throw new Error('Expected createRequest to be defined'); + } + expect(yield* Effect.promise(() => createRequest.json())).toEqual({ + candidate: { + displayName: originalAssertion.value, + evidenceRefs: ['document:original'], + officialIdentifiers: [], + partyType: 'ORGANIZATION', + provenance: { method: 'MANUAL_REVIEW', source: 'document:original' }, + validFrom: timestamp, + }, + }); + expect(requests.every((request) => request.headers.get('authorization') === 'Bearer test-assertion')).toBe(true); + }), ); diff --git a/app/verticals/party-registry/tests/unit/api-integration-runtime.test.ts b/app/verticals/party-registry/tests/unit/api-integration-runtime.test.ts index 9fcbdd545..8b9b51055 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-runtime.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-runtime.test.ts @@ -1,26 +1,23 @@ -import { assert, expect, it } from 'effect-rstest'; import { randomUUID } from 'node:crypto'; import { ActionRuntime, GatewayAssertionRedemptionService, ReadRuntime } from '@app/core-runtime'; import type { ActionRuntimeService, ReadRuntimeService } from '@app/core-runtime'; import { HttpApi, HttpApiBuilder, HttpRouter, HttpServer } from '@modern-js/plugin-bff/effect-edge'; import { ConfigProvider, Context, Effect, Layer, Schema } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; import * as FastCheck from 'fast-check'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import { makePartyRegistryApiRuntime, partyRegistryFoundationLive } from '../../api/index.ts'; +import { partyRegistryCorsAllowedHeaders, partyRegistryCorsAllowedMethods } from '../../api/read-server-support.ts'; import { partyRegistryApi, partyRegistryReadinessSchema } from '../../shared/api.ts'; -import { PartyCommandInvalidRequestProblemSchema } from '../../shared/command-api.ts'; import { PartyDetailAuthenticationProblemSchema } from '../../shared/apis/party-detail.ts'; +import { PartyCommandInvalidRequestProblemSchema } from '../../shared/command-api.ts'; import { PartySearchProjectionGateway } from '../../shared/domain/search-projection-gateway.ts'; import type { PartySearchProjectionGatewayService } from '../../shared/domain/search-projection-gateway.ts'; +import { ultramodernApiMarker } from '../../shared/ultramodern-build.ts'; import { AresSubjectService } from '../../src/integrations/ares/ares-subject.service.ts'; import type { AresSubjectServiceContract } from '../../src/integrations/ares/ares-subject.service.ts'; -import { ultramodernApiMarker } from '../../shared/ultramodern-build.ts'; -import { - partyRegistryCorsAllowedHeaders, - partyRegistryCorsAllowedMethods, -} from '../../api/read-server-support.ts'; const principal = { authBindingId: 'a1000000-0000-4000-8000-000000000001', @@ -53,7 +50,11 @@ const makeAssertion = () => }; const token = yield* Effect.promise(() => new SignJWT({ principal, ver: 1 }) - .setProtectedHeader({ alg: 'EdDSA', kid: 'party-runtime-assembly-test', typ: 'JWT' }) + .setProtectedHeader({ + alg: 'EdDSA', + kid: 'party-runtime-assembly-test', + typ: 'JWT', + }) .setIssuer(issuer) .setAudience('party-registry') .setSubject(principal.principalId) @@ -71,464 +72,445 @@ const makeAssertion = () => }; }); -it.effect( - 'serves readiness and rejects the removed placeholder write without business dependencies', - () => - Effect.gen(function* apiIntegrationRuntimeCase1() { - const readinessApi = HttpApi.make('PartyRegistryApi').add(partyRegistryApi.groups.foundation); - const server = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - HttpApiBuilder.layer(readinessApi).pipe( - Layer.provide(partyRegistryFoundationLive), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true }, +it.effect('serves readiness and rejects the removed placeholder write without business dependencies', () => + Effect.gen(function* apiIntegrationRuntimeCase1() { + const readinessApi = HttpApi.make('PartyRegistryApi').add(partyRegistryApi.groups.foundation); + const server = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(readinessApi).pipe( + Layer.provide(partyRegistryFoundationLive), + Layer.provide(HttpServer.layerServices), ), + { disableLogger: true }, ), - (resource) => Effect.promise(() => resource.dispose()), - ); - const response = yield* Effect.promise(() => - server.handler(new Request('http://localhost/party-registry/readiness'), Context.empty()), - ); - expect(response.status).toBe(200); - const readiness = yield* Schema.decodeUnknownEffect(partyRegistryReadinessSchema)( - yield* Effect.promise(() => response.json()), - ); - expect(readiness.marker).toEqual(ultramodernApiMarker); - expect(readiness.status).toBe('ready'); + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const response = yield* Effect.promise(() => + server.handler(new Request('http://localhost/party-registry/readiness'), Context.empty()), + ); + expect(response.status).toBe(200); + const readiness = yield* Schema.decodeUnknownEffect(partyRegistryReadinessSchema)( + yield* Effect.promise(() => response.json()), + ); + expect(readiness.marker).toEqual(ultramodernApiMarker); + expect(readiness.status).toBe('ready'); - const removedWrite = yield* Effect.promise(() => - server.handler( - new Request('http://localhost/party-registry', { - body: '{"name":"Must not create an item"}', - headers: { 'content-type': 'application/json' }, - method: 'POST', - }), - Context.empty(), - ), - ); - expect(removedWrite.status).toBe(404); - }), + const removedWrite = yield* Effect.promise(() => + server.handler( + new Request('http://localhost/party-registry', { + body: '{"name":"Must not create an item"}', + headers: { 'content-type': 'application/json' }, + method: 'POST', + }), + Context.empty(), + ), + ); + expect(removedWrite.status).toBe(404); + }), ); -it.live( - 'builds every declared handler and preserves owner-local CORS through the injectable runtime', - () => - Effect.gen(function* verifyRuntimeAssemblyAndCors() { - const assertion = yield* makeAssertion(); - let redemptionCalls = 0; - let actionCalls = 0; - let actionCommitCalls = 0; - let aresCalls = 0; - let aresLayerLoads = 0; - let counterpartySearchCalls = 0; - let partySearchCalls = 0; - let readCalls = 0; - let searchLayerLoads = 0; - const actionRuntime: ActionRuntimeService = { - resolveActionCommit: () => - Effect.suspend(() => { - actionCommitCalls += 1; - return Effect.die('Action commit substitute reached through the assembled runtime'); - }), - runAction: () => - Effect.suspend(() => { - actionCalls += 1; - return Effect.die('Action substitute reached through the assembled runtime'); - }), - }; - const aresSubjectService = { - subject: (_input) => - Effect.suspend(() => { - aresCalls += 1; - return Effect.die('ARES substitute reached through the assembled runtime'); - }), - } satisfies AresSubjectServiceContract; - const searchProjectionGateway = { - searchCounterparties: (_input) => - Effect.suspend(() => { - counterpartySearchCalls += 1; - return Effect.die( - 'Counterparty search substitute reached through the assembled runtime', - ); - }), - searchParties: (_input) => - Effect.suspend(() => { - partySearchCalls += 1; - return Effect.die('Party search substitute reached through the assembled runtime'); - }), - } satisfies PartySearchProjectionGatewayService; - const readRuntime: ReadRuntimeService = { - runRead: ({ registration }) => - Effect.context().pipe( - Effect.flatMap((context) => { - readCalls += 1; - if (registration.descriptor.readKey === 'party.registry.api.ares-lookup') { - const service = Context.getOrUndefined(context, AresSubjectService); - assert.isOk(service !== undefined); - return service - .subject({ - correlationId: 'runtime-assembly-proof', - ico: '27074358', - }) - .pipe( - Effect.orDie, - Effect.andThen(Effect.die('ARES substitute completed unexpectedly')), - ); - } - if (registration.descriptor.readKey === 'party.registry.search.parties') { - const service = Context.getOrUndefined(context, PartySearchProjectionGateway); - assert.isOk(service !== undefined); - return service - .searchParties({ - includeArchived: false, - query: 'runtime assembly proof', - tenantId: principal.tenantId, - }) - .pipe( - Effect.orDie, - Effect.andThen(Effect.die('Party search substitute completed unexpectedly')), - ); - } - if (registration.descriptor.readKey === 'party.registry.search.counterparties') { - const service = Context.getOrUndefined(context, PartySearchProjectionGateway); - assert.isOk(service !== undefined); - return service - .searchCounterparties({ - effectiveAt: '2026-09-07T00:00:00.000Z', - includeArchived: false, - legalEntityId: 'a5000000-0000-4000-8000-000000000001', - query: 'runtime assembly proof', - tenantId: principal.tenantId, - }) - .pipe( - Effect.orDie, - Effect.andThen( - Effect.die('Counterparty search substitute completed unexpectedly'), - ), - ); - } - return Effect.die('Read substitute reached through the assembled runtime'); - }), - ), - }; - const actionRuntimeLayer = Layer.mergeAll( - Layer.succeed(ActionRuntime, actionRuntime), - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - ONTOS_GATEWAY_ISSUER: assertion.issuer, - ONTOS_GATEWAY_PUBLIC_JWKS: assertion.publicJwks, - }), - ), - ); - const aresSubjectLayer = Layer.effect( - AresSubjectService, - Effect.sync(() => { - aresLayerLoads += 1; - return aresSubjectService; +it.live('builds every declared handler and preserves owner-local CORS through the injectable runtime', () => + Effect.gen(function* verifyRuntimeAssemblyAndCors() { + const assertion = yield* makeAssertion(); + let redemptionCalls = 0; + let actionCalls = 0; + let actionCommitCalls = 0; + let aresCalls = 0; + let aresLayerLoads = 0; + let counterpartySearchCalls = 0; + let partySearchCalls = 0; + let readCalls = 0; + let searchLayerLoads = 0; + const actionRuntime: ActionRuntimeService = { + resolveActionCommit: () => + Effect.suspend(() => { + actionCommitCalls += 1; + return Effect.die('Action commit substitute reached through the assembled runtime'); }), - ); - const searchProjectionLayer = Layer.effect( - PartySearchProjectionGateway, - Effect.sync(() => { - searchLayerLoads += 1; - return searchProjectionGateway; + runAction: () => + Effect.suspend(() => { + actionCalls += 1; + return Effect.die('Action substitute reached through the assembled runtime'); }), - ); - const assembledRuntime = makePartyRegistryApiRuntime( - Layer.succeed(ReadRuntime, readRuntime), - aresSubjectLayer, - searchProjectionLayer, - actionRuntimeLayer, - Layer.succeed(GatewayAssertionRedemptionService, { - consume: () => - Effect.sync(() => { - redemptionCalls += 1; - }), + }; + const aresSubjectService = { + subject: (_input) => + Effect.suspend(() => { + aresCalls += 1; + return Effect.die('ARES substitute reached through the assembled runtime'); }), - ); - const runtime = yield* Effect.acquireRelease( - Effect.sync(() => assembledRuntime.createHandler()), - (resource) => Effect.promise(() => resource.dispose()).pipe(Effect.orDie), - ); - - const unauthenticatedReadRequest = new Request('http://localhost/reads/party-detail', { - body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - partyRef: { - moduleId: 'party.registry', - resourceId: 'a4000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.party', - tenantId: 'a3000000-0000-4000-8000-000000000001', - }, + } satisfies AresSubjectServiceContract; + const searchProjectionGateway = { + searchCounterparties: (_input) => + Effect.suspend(() => { + counterpartySearchCalls += 1; + return Effect.die('Counterparty search substitute reached through the assembled runtime'); }), - headers: { - 'content-type': 'application/json', - 'x-correlation-id': 'runtime-missing-credentials', - }, - method: 'POST', - }); - const unauthenticatedRead = yield* Effect.promise(() => - runtime.handler(unauthenticatedReadRequest), - ); - expect(unauthenticatedRead.status).toBe(401); - expect(redemptionCalls).toBe(0); - expect(unauthenticatedRead.headers.get('www-authenticate')).toBe('Bearer'); - yield* Schema.decodeUnknownEffect(PartyDetailAuthenticationProblemSchema)( - yield* Effect.promise(() => unauthenticatedRead.json()), - ); - const endpoints = Object.values(partyRegistryApi.groups).flatMap((group) => - Object.values(group.endpoints), - ); - const headers = { - authorization: `Bearer ${assertion.token}`, - 'content-type': 'application/json', - 'x-correlation-id': 'runtime-assembly-proof', - }; - const counterpartyRef = { - moduleId: 'party.registry', - resourceId: 'a6000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.counterparty', - tenantId: principal.tenantId, - }; - const provenance = { - evidenceReference: 'runtime-assembly-proof', - method: 'TEST', - source: 'runtime-assembly-proof', - }; - const candidate = { - displayName: 'Runtime assembly proof', - evidenceRefs: ['document:verified'], - officialIdentifiers: [], - partyType: 'ORGANIZATION', - provenance: { method: 'DOCUMENT', source: 'operator' }, - validFrom: '2026-09-07T00:00:00.000Z', - }; - const contactPointRef = { - moduleId: 'party.registry', - resourceId: 'a9000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.party-contact-point', - tenantId: principal.tenantId, - }; - const contactPointProvenance = { - authoritative: true, - method: 'DOCUMENT_REVIEW', - source: 'USER_ASSERTION', - }; - const addContactPointPayload = { - contactPoint: { preferred: false, type: 'EMAIL', value: 'contact@example.test' }, - partyRef, - privacyClassification: 'PUBLIC', - provenance: contactPointProvenance, - validFrom: '2026-09-07T00:00:00.000Z', - verification: { state: 'UNVERIFIED' }, - } as const; - const invalidContactResponseRequest = new Request( - 'http://localhost/party-registry/actions/add-contact-point', - { - body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - ...addContactPointPayload, - contactPoint: { ...addContactPointPayload.contactPoint, value: 'not-an-email' }, + searchParties: (_input) => + Effect.suspend(() => { + partySearchCalls += 1; + return Effect.die('Party search substitute reached through the assembled runtime'); + }), + } satisfies PartySearchProjectionGatewayService; + const readRuntime: ReadRuntimeService = { + runRead: ({ registration }) => + Effect.context().pipe( + Effect.flatMap((context) => { + readCalls += 1; + if (registration.descriptor.readKey === 'party.registry.api.ares-lookup') { + const service = Context.getOrUndefined(context, AresSubjectService); + assert.isOk(service !== undefined); + return service + .subject({ + correlationId: 'runtime-assembly-proof', + ico: '27074358', + }) + .pipe(Effect.orDie, Effect.andThen(Effect.die('ARES substitute completed unexpectedly'))); + } + if (registration.descriptor.readKey === 'party.registry.search.parties') { + const service = Context.getOrUndefined(context, PartySearchProjectionGateway); + assert.isOk(service !== undefined); + return service + .searchParties({ + includeArchived: false, + query: 'runtime assembly proof', + tenantId: principal.tenantId, + }) + .pipe(Effect.orDie, Effect.andThen(Effect.die('Party search substitute completed unexpectedly'))); + } + if (registration.descriptor.readKey === 'party.registry.search.counterparties') { + const service = Context.getOrUndefined(context, PartySearchProjectionGateway); + assert.isOk(service !== undefined); + return service + .searchCounterparties({ + effectiveAt: '2026-09-07T00:00:00.000Z', + includeArchived: false, + legalEntityId: 'a5000000-0000-4000-8000-000000000001', + query: 'runtime assembly proof', + tenantId: principal.tenantId, + }) + .pipe( + Effect.orDie, + Effect.andThen(Effect.die('Counterparty search substitute completed unexpectedly')), + ); + } + return Effect.die('Read substitute reached through the assembled runtime'); }), - headers: { ...headers, 'idempotency-key': 'invalid-contact-point' }, - method: 'POST', + ), + }; + const actionRuntimeLayer = Layer.mergeAll( + Layer.succeed(ActionRuntime, actionRuntime), + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + ONTOS_GATEWAY_ISSUER: assertion.issuer, + ONTOS_GATEWAY_PUBLIC_JWKS: assertion.publicJwks, + }), + ), + ); + const aresSubjectLayer = Layer.effect( + AresSubjectService, + Effect.sync(() => { + aresLayerLoads += 1; + return aresSubjectService; + }), + ); + const searchProjectionLayer = Layer.effect( + PartySearchProjectionGateway, + Effect.sync(() => { + searchLayerLoads += 1; + return searchProjectionGateway; + }), + ); + const assembledRuntime = makePartyRegistryApiRuntime( + Layer.succeed(ReadRuntime, readRuntime), + aresSubjectLayer, + searchProjectionLayer, + actionRuntimeLayer, + Layer.succeed(GatewayAssertionRedemptionService, { + consume: () => + Effect.sync(() => { + redemptionCalls += 1; + }), + }), + ); + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => assembledRuntime.createHandler()), + (resource) => Effect.promise(() => resource.dispose()).pipe(Effect.orDie), + ); + + const unauthenticatedReadRequest = new Request('http://localhost/reads/party-detail', { + body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + partyRef: { + moduleId: 'party.registry', + resourceId: 'a4000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.party', + tenantId: 'a3000000-0000-4000-8000-000000000001', }, - ); - const invalidContactResponse = yield* Effect.promise(() => - runtime.handler(invalidContactResponseRequest), - ); - expect(invalidContactResponse.status).toBe(400); - expect(invalidContactResponse.headers.get('content-type') ?? '').toMatch( - /application\/problem\+json/u, - ); - yield* Schema.decodeUnknownEffect(PartyCommandInvalidRequestProblemSchema)( - yield* Effect.promise(() => invalidContactResponse.json()), - ); - expect(actionCalls).toBe(0); - expect(redemptionCalls).toBe(0); - const manualPayloads = { - '/party-registry/actions/add-contact-point': addContactPointPayload, - '/party-registry/actions/add-party-official-identifier': { - identifier: { - identifierType: 'ICO', - value: '27074358', - verification: 'VERIFIED', - }, - partyRef, - provenanceMethod: 'DOCUMENT', - provenanceSource: 'runtime-assembly-proof', - validFrom: '2026-09-07T00:00:00.000Z', + }), + headers: { + 'content-type': 'application/json', + 'x-correlation-id': 'runtime-missing-credentials', + }, + method: 'POST', + }); + const unauthenticatedRead = yield* Effect.promise(() => runtime.handler(unauthenticatedReadRequest)); + expect(unauthenticatedRead.status).toBe(401); + expect(redemptionCalls).toBe(0); + expect(unauthenticatedRead.headers.get('www-authenticate')).toBe('Bearer'); + yield* Schema.decodeUnknownEffect(PartyDetailAuthenticationProblemSchema)( + yield* Effect.promise(() => unauthenticatedRead.json()), + ); + const endpoints = Object.values(partyRegistryApi.groups).flatMap((group) => Object.values(group.endpoints)); + const headers = { + authorization: `Bearer ${assertion.token}`, + 'content-type': 'application/json', + 'x-correlation-id': 'runtime-assembly-proof', + }; + const counterpartyRef = { + moduleId: 'party.registry', + resourceId: 'a6000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.counterparty', + tenantId: principal.tenantId, + }; + const provenance = { + evidenceReference: 'runtime-assembly-proof', + method: 'TEST', + source: 'runtime-assembly-proof', + }; + const candidate = { + displayName: 'Runtime assembly proof', + evidenceRefs: ['document:verified'], + officialIdentifiers: [], + partyType: 'ORGANIZATION', + provenance: { method: 'DOCUMENT', source: 'operator' }, + validFrom: '2026-09-07T00:00:00.000Z', + }; + const contactPointRef = { + moduleId: 'party.registry', + resourceId: 'a9000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.party-contact-point', + tenantId: principal.tenantId, + }; + const contactPointProvenance = { + authoritative: true, + method: 'DOCUMENT_REVIEW', + source: 'USER_ASSERTION', + }; + const addContactPointPayload = { + contactPoint: { + preferred: false, + type: 'EMAIL', + value: 'contact@example.test', + }, + partyRef, + privacyClassification: 'PUBLIC', + provenance: contactPointProvenance, + validFrom: '2026-09-07T00:00:00.000Z', + verification: { state: 'UNVERIFIED' }, + } as const; + const invalidContactResponseRequest = new Request('http://localhost/party-registry/actions/add-contact-point', { + body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + ...addContactPointPayload, + contactPoint: { + ...addContactPointPayload.contactPoint, + value: 'not-an-email', }, - '/party-registry/actions/counterparty-role-add': { - counterpartyRef, - provenance, - roleType: 'CUSTOMER', - validFrom: '2026-09-07T00:00:00.000Z', + }), + headers: { ...headers, 'idempotency-key': 'invalid-contact-point' }, + method: 'POST', + }); + const invalidContactResponse = yield* Effect.promise(() => runtime.handler(invalidContactResponseRequest)); + expect(invalidContactResponse.status).toBe(400); + expect(invalidContactResponse.headers.get('content-type') ?? '').toMatch(/application\/problem\+json/u); + yield* Schema.decodeUnknownEffect(PartyCommandInvalidRequestProblemSchema)( + yield* Effect.promise(() => invalidContactResponse.json()), + ); + expect(actionCalls).toBe(0); + expect(redemptionCalls).toBe(0); + const manualPayloads = { + '/party-registry/actions/add-contact-point': addContactPointPayload, + '/party-registry/actions/add-party-official-identifier': { + identifier: { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', }, - '/party-registry/actions/counterparty-role-end': { - counterpartyRef, - provenance, - rolePeriodRef: { - moduleId: 'party.registry', - resourceId: 'a7000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.counterparty-role-period', - tenantId: principal.tenantId, - }, - validTo: '2026-09-07T00:00:00.000Z', + partyRef, + provenanceMethod: 'DOCUMENT', + provenanceSource: 'runtime-assembly-proof', + validFrom: '2026-09-07T00:00:00.000Z', + }, + '/party-registry/actions/counterparty-role-add': { + counterpartyRef, + provenance, + roleType: 'CUSTOMER', + validFrom: '2026-09-07T00:00:00.000Z', + }, + '/party-registry/actions/counterparty-role-end': { + counterpartyRef, + provenance, + rolePeriodRef: { + moduleId: 'party.registry', + resourceId: 'a7000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.counterparty-role-period', + tenantId: principal.tenantId, }, - '/party-registry/actions/create-party': { candidate }, - '/party-registry/actions/end-contact-point': { - contactPointRef, - effectiveEnd: '2026-09-07T00:00:00.000Z', - provenance: contactPointProvenance, - reason: 'Runtime assembly proof', - target: { type: 'WHOLE_CONTACT_POINT' }, + validTo: '2026-09-07T00:00:00.000Z', + }, + '/party-registry/actions/create-party': { candidate }, + '/party-registry/actions/end-contact-point': { + contactPointRef, + effectiveEnd: '2026-09-07T00:00:00.000Z', + provenance: contactPointProvenance, + reason: 'Runtime assembly proof', + target: { type: 'WHOLE_CONTACT_POINT' }, + }, + '/party-registry/actions/match-party': { candidate }, + '/party-registry/actions/update-party': { + displayName: 'Updated runtime assembly proof', + expectedRevision: 1, + partyRef, + provenanceMethod: 'DOCUMENT', + provenanceSource: 'runtime-assembly-proof', + validFrom: '2026-09-07T00:00:00.000Z', + }, + '/party-registry/actions/update-contact-point': { + change: { preferred: true, type: 'SET_CHANNEL_PREFERRED' }, + contactPointRef, + expectedRevision: 1, + provenance: contactPointProvenance, + }, + '/party-registry/actions/update-party-official-identifier': { + change: { + expectedVerification: 'UNVERIFIED', + type: 'SET_VERIFICATION', + verification: 'VERIFIED', }, - '/party-registry/actions/match-party': { candidate }, - '/party-registry/actions/update-party': { - displayName: 'Updated runtime assembly proof', - expectedRevision: 1, - partyRef, - provenanceMethod: 'DOCUMENT', - provenanceSource: 'runtime-assembly-proof', - validFrom: '2026-09-07T00:00:00.000Z', + evidenceRefs: ['document:verified'], + officialIdentifierRef: { + moduleId: 'party.registry', + resourceId: 'a8000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.party-official-identifier', + tenantId: principal.tenantId, }, - '/party-registry/actions/update-contact-point': { - change: { preferred: true, type: 'SET_CHANNEL_PREFERRED' }, - contactPointRef, - expectedRevision: 1, - provenance: contactPointProvenance, + reason: 'Runtime assembly proof', + }, + '/party-registry/actions/update-party-relationship': { + changeReason: 'Runtime assembly proof', + expectedRevision: 1, + provenance: { method: 'TEST', source: 'runtime-assembly-proof' }, + relationshipRef: { + moduleId: 'party.registry', + resourceId: 'aa000000-0000-4000-8000-000000000001', + resourceType: 'party.registry.party-relationship', + tenantId: principal.tenantId, }, - '/party-registry/actions/update-party-official-identifier': { - change: { - expectedVerification: 'UNVERIFIED', - type: 'SET_VERIFICATION', - verification: 'VERIFIED', + }, + '/reads/party-match': { candidate }, + } as const; + for (const [index, endpoint] of endpoints.entries()) { + const callsBefore: number = actionCalls + actionCommitCalls + readCalls; + const rawPayloadSchema = endpoint.payload.get('application/json')?.schemas[0]; + // Runtime HTTP descriptors erase codec types. These wire codecs require no services. + const payloadSchema = + rawPayloadSchema === undefined ? undefined : Schema.make>(rawPayloadSchema.ast); + const manualPayload = Object.entries(manualPayloads).find(([path]) => path === endpoint.path)?.[1]; + const payload = + payloadSchema === undefined + ? undefined + : (manualPayload ?? + (yield* Schema.encodeEffect(payloadSchema)( + FastCheck.sample(Schema.toArbitrary(payloadSchema)(FastCheck), { + numRuns: 1, + seed: index + 1, + })[0], + ))); + const request = + payload === undefined + ? new Request(`http://localhost${endpoint.path}`, { + headers: { + ...headers, + 'idempotency-key': `runtime-assembly-${index + 1}`, + }, + method: endpoint.method, + }) + : new Request(`http://localhost${endpoint.path}`, { + body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(payload), + headers: { + ...headers, + 'idempotency-key': `runtime-assembly-${index + 1}`, + }, + method: endpoint.method, + }); + const response = yield* Effect.promise(() => runtime.handler(request)); + if (endpoint.path === '/party-registry/readiness') { + expect(response.status).toBe(200); + expect(actionCalls + actionCommitCalls + readCalls).toBe(callsBefore); + } else { + expect(response.status, `${endpoint.method} ${endpoint.path} must execute a supplied runtime substitute`).toBe( + 500, + ); + expect( + actionCalls + actionCommitCalls + readCalls, + `${endpoint.method} ${endpoint.path} must reach exactly one supplied core runtime`, + ).toBe(callsBefore + 1); + } + } + assert.isOk(redemptionCalls > 0); + assert.isOk(actionCalls > 0); + expect(actionCommitCalls).toBe(1); + assert.isOk(readCalls > 0); + expect(aresCalls).toBe(1); + expect(aresLayerLoads).toBe(1); + expect(partySearchCalls).toBe(1); + expect(counterpartySearchCalls).toBe(1); + expect(searchLayerLoads).toBe(1); + const preflight = yield* Effect.promise(() => + runtime.handler( + new Request('http://localhost/party-registry/readiness', { + headers: { + 'access-control-request-headers': 'Authorization, X-Correlation-Id', + 'access-control-request-method': 'GET', + origin: 'http://localhost:3020', }, - evidenceRefs: ['document:verified'], - officialIdentifierRef: { - moduleId: 'party.registry', - resourceId: 'a8000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.party-official-identifier', - tenantId: principal.tenantId, + method: 'OPTIONS', + }), + ), + ); + expect(preflight.status).toBe(204); + expect(preflight.headers.get('access-control-allow-origin')).toBe('http://localhost:3020'); + expect(commaSeparatedHeader(preflight.headers.get('access-control-allow-methods'))).toEqual( + [...partyRegistryCorsAllowedMethods].toSorted(), + ); + expect(commaSeparatedHeader(preflight.headers.get('access-control-allow-headers'))).toEqual( + [...partyRegistryCorsAllowedHeaders].toSorted(), + ); + expect(preflight.headers.get('access-control-max-age')).toBe('600'); + const loopbackPreflight = yield* Effect.promise(() => + runtime.handler( + new Request('http://localhost/party-registry/readiness', { + headers: { + 'access-control-request-method': 'GET', + origin: 'http://127.0.0.1:3020', }, - reason: 'Runtime assembly proof', - }, - '/party-registry/actions/update-party-relationship': { - changeReason: 'Runtime assembly proof', - expectedRevision: 1, - provenance: { method: 'TEST', source: 'runtime-assembly-proof' }, - relationshipRef: { - moduleId: 'party.registry', - resourceId: 'aa000000-0000-4000-8000-000000000001', - resourceType: 'party.registry.party-relationship', - tenantId: principal.tenantId, + method: 'OPTIONS', + }), + ), + ); + expect(loopbackPreflight.headers.get('access-control-allow-origin')).toBe('http://127.0.0.1:3020'); + const foreignPreflight = yield* Effect.promise(() => + runtime.handler( + new Request('http://localhost/party-registry/readiness', { + headers: { + 'access-control-request-method': 'GET', + origin: 'https://foreign.example.test', }, - }, - '/reads/party-match': { candidate }, - } as const; - for (const [index, endpoint] of endpoints.entries()) { - const callsBefore: number = actionCalls + actionCommitCalls + readCalls; - const rawPayloadSchema = endpoint.payload.get('application/json')?.schemas[0]; - // Runtime HTTP descriptors erase codec types. These wire codecs require no services. - const payloadSchema = - rawPayloadSchema === undefined - ? undefined - : Schema.make>(rawPayloadSchema.ast); - const manualPayload = Object.entries(manualPayloads).find( - ([path]) => path === endpoint.path, - )?.[1]; - const payload = - payloadSchema === undefined - ? undefined - : (manualPayload ?? - (yield* Schema.encodeEffect(payloadSchema)( - FastCheck.sample(Schema.toArbitrary(payloadSchema)(FastCheck), { - numRuns: 1, - seed: index + 1, - })[0], - ))); - const request = - payload === undefined - ? new Request(`http://localhost${endpoint.path}`, { - headers: { ...headers, 'idempotency-key': `runtime-assembly-${index + 1}` }, - method: endpoint.method, - }) - : new Request(`http://localhost${endpoint.path}`, { - body: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(payload), - headers: { ...headers, 'idempotency-key': `runtime-assembly-${index + 1}` }, - method: endpoint.method, - }); - const response = yield* Effect.promise(() => runtime.handler(request)); - if (endpoint.path === '/party-registry/readiness') { - expect(response.status).toBe(200); - expect(actionCalls + actionCommitCalls + readCalls).toBe(callsBefore); - } else { - expect( - response.status, - `${endpoint.method} ${endpoint.path} must execute a supplied runtime substitute`, - ).toBe(500); - expect( - actionCalls + actionCommitCalls + readCalls, - `${endpoint.method} ${endpoint.path} must reach exactly one supplied core runtime`, - ).toBe(callsBefore + 1); - } - } - assert.isOk(redemptionCalls > 0); - assert.isOk(actionCalls > 0); - expect(actionCommitCalls).toBe(1); - assert.isOk(readCalls > 0); - expect(aresCalls).toBe(1); - expect(aresLayerLoads).toBe(1); - expect(partySearchCalls).toBe(1); - expect(counterpartySearchCalls).toBe(1); - expect(searchLayerLoads).toBe(1); - const preflight = yield* Effect.promise(() => - runtime.handler( - new Request('http://localhost/party-registry/readiness', { - headers: { - 'access-control-request-headers': 'Authorization, X-Correlation-Id', - 'access-control-request-method': 'GET', - origin: 'http://localhost:3020', - }, - method: 'OPTIONS', - }), - ), - ); - expect(preflight.status).toBe(204); - expect(preflight.headers.get('access-control-allow-origin')).toBe('http://localhost:3020'); - expect(commaSeparatedHeader(preflight.headers.get('access-control-allow-methods'))).toEqual( - [...partyRegistryCorsAllowedMethods].toSorted(), - ); - expect(commaSeparatedHeader(preflight.headers.get('access-control-allow-headers'))).toEqual( - [...partyRegistryCorsAllowedHeaders].toSorted(), - ); - expect(preflight.headers.get('access-control-max-age')).toBe('600'); - const loopbackPreflight = yield* Effect.promise(() => - runtime.handler( - new Request('http://localhost/party-registry/readiness', { - headers: { - 'access-control-request-method': 'GET', - origin: 'http://127.0.0.1:3020', - }, - method: 'OPTIONS', - }), - ), - ); - expect(loopbackPreflight.headers.get('access-control-allow-origin')).toBe( - 'http://127.0.0.1:3020', - ); - const foreignPreflight = yield* Effect.promise(() => - runtime.handler( - new Request('http://localhost/party-registry/readiness', { - headers: { - 'access-control-request-method': 'GET', - origin: 'https://foreign.example.test', - }, - method: 'OPTIONS', - }), - ), - ); - expect(foreignPreflight.headers.get('access-control-allow-origin')).toBe(null); - }), + method: 'OPTIONS', + }), + ), + ); + expect(foreignPreflight.headers.get('access-control-allow-origin')).toBe(null); + }), ); diff --git a/app/verticals/party-registry/tests/unit/ares-application-policy.test.ts b/app/verticals/party-registry/tests/unit/ares-application-policy.test.ts index 169f9c526..a45af6169 100644 --- a/app/verticals/party-registry/tests/unit/ares-application-policy.test.ts +++ b/app/verticals/party-registry/tests/unit/ares-application-policy.test.ts @@ -1,6 +1,6 @@ +import { DateTime, Option, Result, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; -import { DateTime, Option, Result, Schema } from 'effect'; import { AresAppliedEvidenceSchema, aresRegisteredAddressMatches, @@ -81,7 +81,12 @@ it('#246 canonical equality is no-change and conflicting facts never authorize o displayName: 'Example', icoValues: ['01234567'], registeredAddresses: [ - { addressLine1: 'Main 10', city: 'Praha', countryCode: 'CZ', postalCode: '12000' }, + { + addressLine1: 'Main 10', + city: 'Praha', + countryCode: 'CZ', + postalCode: '12000', + }, ], }); expect(result.outcome).toBe('NO_CHANGE'); @@ -90,7 +95,12 @@ it('#246 canonical equality is no-change and conflicting facts never authorize o ...canonical, displayName: 'Different', registeredAddresses: [ - { addressLine1: 'Main 100', city: 'Praha', countryCode: 'CZ', postalCode: '12000' }, + { + addressLine1: 'Main 100', + city: 'Praha', + countryCode: 'CZ', + postalCode: '12000', + }, ], }); expect(conflict.factDecisions[0]?.outcome).toBe('NEEDS_CONFIRMATION'); @@ -136,19 +146,20 @@ it('#246 structural registered address comparison cannot confuse substrings or u postalCode: '12000', }; expect(aresRegisteredAddressMatches(observed, current)).toBe(true); - expect(aresRegisteredAddressMatches(observed, { ...current, addressLine1: 'Main 100' })).toBe( - false, - ); + expect( + aresRegisteredAddressMatches(observed, { + ...current, + addressLine1: 'Main 100', + }), + ).toBe(false); expect(aresRegisteredAddressMatches(observed, { ...current, region: 'Extra' })).toBe(false); expect(aresRegisteredAddressMatches(observed, { ...current, postalCode: '13000' })).toBe(false); }); it('#246 stale observations and archived Parties cannot be enriched', () => { - expect( - derive({ ...canonical, archived: true }).factDecisions.every( - (decision) => decision.route === null, - ), - ).toBe(true); + expect(derive({ ...canonical, archived: true }).factDecisions.every((decision) => decision.route === null)).toBe( + true, + ); const stale = deriveAresEvidenceApplication({ canonical, decidedAt: '2026-09-03T09:01:00.000Z', @@ -181,9 +192,7 @@ it('#246 durable evidence retains observation and authority metadata without raw const encoded = Result.getOrThrow(Schema.encodeUnknownResult(AresAppliedEvidenceSchema)(durable)); expect(encoded.observedAt).toBe(evidence.observedAt); expect(encoded.providerChangedOn).toBe(evidence.providerChangedOn); - expect(Result.getOrThrow(Schema.decodeUnknownResult(AresAppliedEvidenceSchema)(encoded))).toEqual( - durable, - ); + expect(Result.getOrThrow(Schema.decodeResult(AresAppliedEvidenceSchema)(encoded))).toEqual(durable); }); const acceptedEvidence = (fact: 'BUSINESS_NAME' | 'ICO') => { @@ -194,9 +203,7 @@ const acceptedEvidence = (fact: 'BUSINESS_NAME' | 'ICO') => { throw new Error('Expected decision to be defined'); } return Result.getOrThrow( - Schema.encodeUnknownResult(AresAppliedEvidenceSchema)( - makeAresAppliedEvidence(result, decision), - ), + Schema.encodeUnknownResult(AresAppliedEvidenceSchema)(makeAresAppliedEvidence(result, decision)), ); }; const conflictingName: AresCanonicalSnapshot = { @@ -240,8 +247,14 @@ it('ordinary change, missing provenance, ambiguous assertions and temporal misma const prior = acceptedEvidence('BUSINESS_NAME'); for (const snapshot of [ { ...conflictingName, factEvidence: [] }, - { ...conflictingName, factEvidence: [assertion, { ...assertion, assertionId: 'other' }] }, - { ...conflictingName, factEvidence: [{ ...assertion, externalEvidence: null }] }, + { + ...conflictingName, + factEvidence: [assertion, { ...assertion, assertionId: 'other' }], + }, + { + ...conflictingName, + factEvidence: [{ ...assertion, externalEvidence: null }], + }, { ...conflictingName, factEvidence: [{ ...assertion, externalEvidence: { ...prior, queryIco: '87654321' } }], @@ -249,12 +262,20 @@ it('ordinary change, missing provenance, ambiguous assertions and temporal misma { ...conflictingName, factEvidence: [ - { ...assertion, externalEvidence: { ...prior, providerRecordRef: 'different-record' } }, + { + ...assertion, + externalEvidence: { ...prior, providerRecordRef: 'different-record' }, + }, ], }, { ...conflictingName, - factEvidence: [{ ...assertion, externalEvidence: { ...prior, providerChangedOn: null } }], + factEvidence: [ + { + ...assertion, + externalEvidence: { ...prior, providerChangedOn: null }, + }, + ], }, { ...conflictingName, @@ -265,10 +286,16 @@ it('ordinary change, missing provenance, ambiguous assertions and temporal misma expect(deriveAresCorrectionReviewHandoffs(decideName(snapshot), snapshot)).toEqual([]); } expect( - decideName(conflictingName, { ...evidence, providerChangedOn: '2026-09-02' }).outcome, + decideName(conflictingName, { + ...evidence, + providerChangedOn: '2026-09-02', + }).outcome, ).toBe('NEEDS_CONFIRMATION'); expect( - decideName(conflictingName, { ...evidence, observedAt: '2026-09-03T07:00:00.000Z' }).outcome, + decideName(conflictingName, { + ...evidence, + observedAt: '2026-09-03T07:00:00.000Z', + }).outcome, ).toBe('NEEDS_CONFIRMATION'); expect(decideName({ ...conflictingName, archived: true }).outcome).toBe('NEEDS_CONFIRMATION'); }); @@ -288,12 +315,8 @@ it('historical ICO suspicion nominates only its assertion and never permits othe icoValues: ['87654321'], }; const result = derive(snapshot); - expect(result.factDecisions.find((item) => item.fact === 'ICO')?.outcome).toBe( - 'CORRECTION_CANDIDATE', - ); - expect(result.factDecisions.find((item) => item.fact === 'BUSINESS_NAME')?.outcome).toBe( - 'IDENTITY_AMBIGUITY', - ); + expect(result.factDecisions.find((item) => item.fact === 'ICO')?.outcome).toBe('CORRECTION_CANDIDATE'); + expect(result.factDecisions.find((item) => item.fact === 'BUSINESS_NAME')?.outcome).toBe('IDENTITY_AMBIGUITY'); expect(result.factDecisions.every((item) => item.route === null)).toBe(true); expect(deriveAresCorrectionReviewHandoffs(result, snapshot)[0]?.fact).toBe('ICO'); expect(derive({ ...snapshot, identityAmbiguous: true }).outcome).toBe('IDENTITY_AMBIGUITY'); @@ -304,7 +327,11 @@ it('candidate prefill supplies a proposal without declaring subject type or acto expect(candidate.partyType).toBe('UNRESOLVED'); expect(candidate.subjectEvidence).toEqual([]); expect(candidate.officialIdentifiers).toEqual([ - { identifierType: 'ICO', value: evidence.subject.ico, verification: 'UNVERIFIED' }, + { + identifierType: 'ICO', + value: evidence.subject.ico, + verification: 'UNVERIFIED', + }, ]); expect(candidate.provenance.externalEvidence).toBe(undefined); expect(candidate.displayName).toBe(evidence.subject.businessName); @@ -335,7 +362,11 @@ it('six amended outcomes remain reachable and unsupported name or address never decidedAt: '2026-09-03T08:01:00.000Z', evidence: { ...evidence, - subject: { ...evidence.subject, businessName: 'x'.repeat(301), registeredAddress: null }, + subject: { + ...evidence.subject, + businessName: 'x'.repeat(301), + registeredAddress: null, + }, }, selectedFacts: ['BUSINESS_NAME', 'REGISTERED_ADDRESS'], userConfirmed: true, @@ -353,9 +384,7 @@ it('authoritative ICO enrichment requires an ORGANIZATION and address enrichment userConfirmed: true, }); expect(result.outcome).toBe('NEEDS_CONFIRMATION'); - expect(result.factDecisions[0]?.reasonCode).toBe( - 'party_type_not_supported_for_authoritative_ico', - ); + expect(result.factDecisions[0]?.reasonCode).toBe('party_type_not_supported_for_authoritative_ico'); } const address = evidence.subject.registeredAddress; expect(address).toBeDefined(); @@ -369,7 +398,10 @@ it('authoritative ICO enrichment requires an ORGANIZATION and address enrichment const result = deriveAresEvidenceApplication({ canonical, decidedAt: '2026-09-03T08:01:00.000Z', - evidence: { ...evidence, subject: { ...evidence.subject, registeredAddress } }, + evidence: { + ...evidence, + subject: { ...evidence.subject, registeredAddress }, + }, selectedFacts: ['REGISTERED_ADDRESS'], userConfirmed: true, }); diff --git a/app/verticals/party-registry/tests/unit/ares-evidence-contract.test.ts b/app/verticals/party-registry/tests/unit/ares-evidence-contract.test.ts index 96bdb1d38..610b1a4d6 100644 --- a/app/verticals/party-registry/tests/unit/ares-evidence-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/ares-evidence-contract.test.ts @@ -1,18 +1,9 @@ +import { Effect, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Schema } from 'effect'; -import { - AresCanonicalRouteSchema, - AresEvidenceApplicationSchema, -} from '../../shared/domain/ares-application.ts'; -import { - AresSubjectEvidenceSchema, - AresSubjectLookupIcoSchema, -} from '../../shared/domain/ares-evidence.ts'; -import { - AresLookupRequestSchema, - AresLookupResponseSchema, -} from '../../shared/apis/ares-lookup.ts'; +import { AresLookupRequestSchema, AresLookupResponseSchema } from '../../shared/apis/ares-lookup.ts'; +import { AresCanonicalRouteSchema, AresEvidenceApplicationSchema } from '../../shared/domain/ares-application.ts'; +import { AresSubjectEvidenceSchema, AresSubjectLookupIcoSchema } from '../../shared/domain/ares-evidence.ts'; const evidence = { cacheAgeSeconds: 0, @@ -43,23 +34,21 @@ const evidence = { }, } as const; -it.effect( - 'normalizes only surrounding whitespace and preserves leading zeroes in an exact IČO', - () => - Effect.gen(function* validateContract1() { - expect(yield* Schema.decodeUnknownEffect(AresSubjectLookupIcoSchema)(' 01234567 ')).toBe( - '01234567', - ); - expect( - yield* Schema.decodeUnknownEffect(AresLookupRequestSchema)({ ico: ' 01234567 ' }), - ).toEqual({ - ico: '01234567', - }); +it.effect('normalizes only surrounding whitespace and preserves leading zeroes in an exact IČO', () => + Effect.gen(function* validateContract1() { + expect(yield* Schema.decodeEffect(AresSubjectLookupIcoSchema)(' 01234567 ')).toBe('01234567'); + expect( + yield* Schema.decodeEffect(AresLookupRequestSchema)({ + ico: ' 01234567 ', + }), + ).toEqual({ + ico: '01234567', + }); - for (const ico of ['1234567', '123456789', '1234 5678', 'abcdefgh', '']) { - expect(() => Schema.decodeUnknownSync(AresSubjectLookupIcoSchema)(ico)).toThrow(); - } - }), + for (const ico of ['1234567', '123456789', '1234 5678', 'abcdefgh', '']) { + expect(() => Schema.decodeSync(AresSubjectLookupIcoSchema)(ico)).toThrow(); + } + }), ); it.effect('returns one bounded evidence envelope and strips unowned provider payload fields', () => @@ -78,19 +67,19 @@ it.effect('returns one bounded evidence envelope and strips unowned provider pay expect(encoded).toEqual(evidence); expect(Object.hasOwn(decoded, 'rawResponse')).toBe(false); expect(Object.hasOwn(decoded.subject, 'czNace')).toBe(false); - expect(yield* Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)(encoded)).toEqual(decoded); + expect(yield* Schema.decodeEffect(AresSubjectEvidenceSchema)(encoded)).toEqual(decoded); }), ); it.effect('keeps observed time separate from provider change time and cache-serving time', () => Effect.gen(function* validateContract3() { expect(() => - Schema.decodeUnknownSync(AresSubjectEvidenceSchema)({ + Schema.decodeSync(AresSubjectEvidenceSchema)({ ...evidence, observedAt: '2026-02-30T08:00:00.000Z', }), ).toThrow(); - const cached = yield* Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)({ + const cached = yield* Schema.decodeEffect(AresSubjectEvidenceSchema)({ ...evidence, cacheAgeSeconds: 120, servedAt: '2026-09-03T08:02:00.000Z', @@ -106,25 +95,15 @@ it.effect('keeps observed time separate from provider change time and cache-serv it.effect('allows ARES evidence to route only through standard Party-owned lifecycle Actions', () => Effect.gen(function* validateContract4() { - const routes = [ - 'PARTY_UPDATE', - 'IDENTIFIER_ADD', - 'CONTACT_POINT_ADD', - 'PARTY_CORRECTION', - ] as const; + const routes = ['PARTY_UPDATE', 'IDENTIFIER_ADD', 'CONTACT_POINT_ADD', 'PARTY_CORRECTION'] as const; for (const route of routes) { - expect(yield* Schema.decodeUnknownEffect(AresCanonicalRouteSchema)(route)).toBe(route); + expect(yield* Schema.decodeEffect(AresCanonicalRouteSchema)(route)).toBe(route); } - for (const forbiddenRoute of [ - 'PARTY_CREATE', - 'ARES_APPLY', - 'PARTY_MERGE', - 'RAW_PROVIDER_OVERWRITE', - ]) { + for (const forbiddenRoute of ['PARTY_CREATE', 'ARES_APPLY', 'PARTY_MERGE', 'RAW_PROVIDER_OVERWRITE']) { expect(() => Schema.decodeUnknownSync(AresCanonicalRouteSchema)(forbiddenRoute)).toThrow(); } - const application = yield* Schema.decodeUnknownEffect(AresEvidenceApplicationSchema)({ + const application = yield* Schema.decodeEffect(AresEvidenceApplicationSchema)({ decidedAt: '2026-09-03T08:01:00.000Z', evidence, factDecisions: [ @@ -164,7 +143,7 @@ it.effect('allows ARES evidence to route only through standard Party-owned lifec it.effect('rejects unattended enrichment and mutation routes on non-applying outcomes', () => Effect.gen(function* validateContract5() { expect(() => - Schema.decodeUnknownSync(AresEvidenceApplicationSchema)({ + Schema.decodeSync(AresEvidenceApplicationSchema)({ decidedAt: '2026-09-03T08:01:00.000Z', evidence, factDecisions: [ @@ -182,7 +161,7 @@ it.effect('rejects unattended enrichment and mutation routes on non-applying out }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(AresEvidenceApplicationSchema)({ + Schema.decodeSync(AresEvidenceApplicationSchema)({ decidedAt: '2026-09-03T08:01:00.000Z', evidence, factDecisions: [ @@ -200,7 +179,7 @@ it.effect('rejects unattended enrichment and mutation routes on non-applying out }), ).toThrow(); - const conflict = yield* Schema.decodeUnknownEffect(AresEvidenceApplicationSchema)({ + const conflict = yield* Schema.decodeEffect(AresEvidenceApplicationSchema)({ decidedAt: '2026-09-03T08:01:00.000Z', evidence, factDecisions: [ diff --git a/app/verticals/party-registry/tests/unit/ares-lookup-read.test.ts b/app/verticals/party-registry/tests/unit/ares-lookup-read.test.ts index 3672b44f3..afd2cec20 100644 --- a/app/verticals/party-registry/tests/unit/ares-lookup-read.test.ts +++ b/app/verticals/party-registry/tests/unit/ares-lookup-read.test.ts @@ -1,8 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Read-only architecture assertions use native filesystem promises. expires: 2026-12-31. +import { fileURLToPath } from 'node:url'; + +import { NodeFileSystem } from '@effect/platform-node'; +import { FileSystem, Effect, Schema, SchemaAST, Predicate, Struct } from 'effect'; import { assert, expect, it } from 'effect-rstest'; -import { readFile, readdir } from 'node:fs/promises'; -import { Effect, Schema, SchemaAST, Predicate, Struct } from 'effect'; import { getReadHandler } from '../../../../packages/core-runtime/src/reads/definition.ts'; import { AresLookupApi, @@ -54,7 +55,7 @@ const problemTag = (schema: Schema.Top): SchemaAST.LiteralValue => { return tag.literal; }; -const evidence = Schema.decodeUnknownSync(AresLookupResponseSchema)(evidenceWire); +const evidence = Schema.decodeSync(AresLookupResponseSchema)(evidenceWire); const scope = Object.freeze({ authBindingId: '00000000-0000-4000-8000-000000000005', @@ -65,7 +66,9 @@ const scope = Object.freeze({ principalId: '00000000-0000-4000-8000-000000000003', tenantId: '00000000-0000-4000-8000-000000000001', }); -const request = Schema.decodeUnknownSync(AresLookupRequestSchema)({ ico: '48039101' }); +const request = Schema.decodeSync(AresLookupRequestSchema)({ + ico: '48039101', +}); it('declares a tenant-authorized Party evidence Read with optional Legal Entity context', () => { expect(aresLookupRead.descriptor.accessKind).toBe('detail'); @@ -76,9 +79,8 @@ it('declares a tenant-authorized Party evidence Read with optional Legal Entity expect(aresLookupRead.descriptor.evidencePolicy.captureMode).toBe('metadata_only'); }); -it.effect( - 'passes trusted correlation to the private adapter and returns exactly one evidence result', - () => +it.layer(NodeFileSystem.layer)('ares-lookup-read', (suite) => { + suite.effect('passes trusted correlation to the private adapter and returns exactly one evidence result', () => Effect.gen(function* aresLookupReadCase1() { const calls: unknown[] = []; const result = yield* getReadHandler(aresLookupRead)(request, { @@ -97,29 +99,42 @@ it.effect( ); expect(calls).toEqual([{ correlationId: scope.correlationId, ico: '48039101' }]); - expect(result).toEqual({ evidence: { resultCount: 1 }, result: evidence }); + expect(result).toEqual({ + evidence: { resultCount: 1 }, + result: evidence, + }); }), -); + ); -it.effect( - 'maps provider failures to the closed governed Read error vocabulary without leaking details', - () => + suite.effect('maps provider failures to the closed governed Read error vocabulary without leaking details', () => Effect.gen(function* aresLookupReadCase2() { const failures = [ [ - new AresSubjectNotFound({ code: 'ares_subject_not_found', reason: 'private 404 body' }), + new AresSubjectNotFound({ + code: 'ares_subject_not_found', + reason: 'private 404 body', + }), 'ReadHandlerNotFound', ], [ - new AresSubjectDenied({ code: 'ares_subject_denied', reason: 'private denial' }), + new AresSubjectDenied({ + code: 'ares_subject_denied', + reason: 'private denial', + }), 'ReadHandlerUnavailable', ], [ - new AresSubjectThrottled({ code: 'ares_subject_throttled', reason: 'private throttle' }), + new AresSubjectThrottled({ + code: 'ares_subject_throttled', + reason: 'private throttle', + }), 'ReadHandlerUnavailable', ], [ - new AresSubjectTimeout({ code: 'ares_subject_timeout', reason: 'private timeout' }), + new AresSubjectTimeout({ + code: 'ares_subject_timeout', + reason: 'private timeout', + }), 'ReadHandlerUnavailable', ], [ @@ -156,101 +171,101 @@ it.effect( ); for (const { error, expectedTag } of errors) { expect(Predicate.isTagged(error, expectedTag)).toBe(true); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( - 'private', - ), - ).toBe(false); + expect((yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes('private')).toBe( + false, + ); } }), -); + ); -it.effect('publishes safe status-matched Problem Details and no provider payload schema', () => - Effect.gen(function* validateContract2() { - interface ProblemFixture { - readonly _tag: string; - readonly detail: string; - readonly retryable?: true; - readonly status: number; - readonly title: string; - readonly type: string; - } - const schemas = [ - [AresLookupInvalidProblemSchema, 'AresLookupInvalidProblem', 400], - [AresLookupAuthenticationProblemSchema, 'AresLookupAuthenticationProblem', 401], - [AresLookupForbiddenProblemSchema, 'AresLookupForbiddenProblem', 403], - [AresLookupNotFoundProblemSchema, 'AresLookupNotFoundProblem', 404], - [AresLookupPolicyConflictProblemSchema, 'AresLookupPolicyConflictProblem', 409], - [AresLookupPolicyProblemSchema, 'AresLookupPolicyProblem', 422], - [AresLookupUnavailableProblemSchema, 'AresLookupUnavailableProblem', 503], - [AresLookupInternalProblemSchema, 'AresLookupInternalProblem', 500], - ] as const; - for (const [schema, tag, status] of schemas) { - const fixture: ProblemFixture = - status === 503 - ? { - _tag: tag, - detail: 'safe detail', - retryable: true, - status, - title: 'safe title', - type: 'https://ontos.dev/problems/test', - } - : { - _tag: tag, - detail: 'safe detail', - status, - title: 'safe title', - type: 'https://ontos.dev/problems/test', - }; - expect((yield* Schema.decodeUnknownEffect(schema)(fixture)).status).toBe(status); - const encoding = schema.ast.annotations?.['~httpApiEncoding']; - expect(Predicate.isTagged(encoding, 'Json')).toBe(true); - if (!Predicate.isTagged(encoding, 'Json')) { - throw new Error('Expected JSON HTTP API encoding'); + suite.effect('publishes safe status-matched Problem Details and no provider payload schema', () => + Effect.gen(function* validateContract2() { + interface ProblemFixture { + readonly _tag: string; + readonly detail: string; + readonly retryable?: true; + readonly status: number; + readonly title: string; + readonly type: string; } - expect(Struct.omit(encoding, ['_tag'])).toEqual({ - contentType: 'application/problem+json', - }); - } - expect([...AresLookupApi.groups.aresLookup.endpoints.execute.error].map(problemTag)).toEqual([ - 'AresLookupInvalidProblem', - 'AresLookupAuthenticationProblem', - 'AresLookupForbiddenProblem', - 'AresLookupNotFoundProblem', - 'AresLookupPolicyConflictProblem', - 'AresLookupPolicyProblem', - 'AresLookupUnavailableProblem', - 'AresLookupInternalProblem', - ]); - expect(yield* Schema.decodeUnknownEffect(AresLookupRequestSchema)({ ico: '48039101' })).toEqual( - { + const schemas = [ + [AresLookupInvalidProblemSchema, 'AresLookupInvalidProblem', 400], + [AresLookupAuthenticationProblemSchema, 'AresLookupAuthenticationProblem', 401], + [AresLookupForbiddenProblemSchema, 'AresLookupForbiddenProblem', 403], + [AresLookupNotFoundProblemSchema, 'AresLookupNotFoundProblem', 404], + [AresLookupPolicyConflictProblemSchema, 'AresLookupPolicyConflictProblem', 409], + [AresLookupPolicyProblemSchema, 'AresLookupPolicyProblem', 422], + [AresLookupUnavailableProblemSchema, 'AresLookupUnavailableProblem', 503], + [AresLookupInternalProblemSchema, 'AresLookupInternalProblem', 500], + ] as const; + for (const [schema, tag, status] of schemas) { + const fixture: ProblemFixture = + status === 503 + ? { + _tag: tag, + detail: 'safe detail', + retryable: true, + status, + title: 'safe title', + type: 'https://ontos.dev/problems/test', + } + : { + _tag: tag, + detail: 'safe detail', + status, + title: 'safe title', + type: 'https://ontos.dev/problems/test', + }; + expect((yield* Schema.decodeUnknownEffect(schema)(fixture)).status).toBe(status); + const encoding = schema.ast.annotations?.['~httpApiEncoding']; + expect(Predicate.isTagged(encoding, 'Json')).toBe(true); + if (!Predicate.isTagged(encoding, 'Json')) { + throw new Error('Expected JSON HTTP API encoding'); + } + expect(Struct.omit(encoding, ['_tag'])).toEqual({ + contentType: 'application/problem+json', + }); + } + expect([...AresLookupApi.groups.aresLookup.endpoints.execute.error].map(problemTag)).toEqual([ + 'AresLookupInvalidProblem', + 'AresLookupAuthenticationProblem', + 'AresLookupForbiddenProblem', + 'AresLookupNotFoundProblem', + 'AresLookupPolicyConflictProblem', + 'AresLookupPolicyProblem', + 'AresLookupUnavailableProblem', + 'AresLookupInternalProblem', + ]); + expect( + yield* Schema.decodeEffect(AresLookupRequestSchema)({ + ico: '48039101', + }), + ).toEqual({ ico: '48039101', - }, - ); - expect(yield* Schema.decodeUnknownEffect(AresLookupResponseSchema)(evidenceWire)).toEqual( - evidence, - ); - expect(AresLookupApi.identifier).toBe('AresLookupApi'); - }), -); + }); + expect(yield* Schema.decodeEffect(AresLookupResponseSchema)(evidenceWire)).toEqual(evidence); + expect(AresLookupApi.identifier).toBe('AresLookupApi'); + }), + ); -it.effect('keeps the ARES integration read-only and exposes no ARES Action', () => - Effect.gen(function* aresLookupReadCase3() { - const sourceFiles = [ - new URL('../../src/integrations/ares/ares-subject.service.ts', import.meta.url), - new URL('../../src/api/ares-lookup.read.ts', import.meta.url), - ]; - const sources = yield* Effect.all( - sourceFiles.map((sourceFile) => Effect.promise(() => readFile(sourceFile, 'utf-8'))), - { concurrency: 'unbounded' }, - ); - for (const source of sources) { - expect(source).not.toMatch(/from ['"].*(?:\/db\/|\/actions\/|\/services\/party-)/u); - } - const actionFiles = yield* Effect.promise(() => - readdir(new URL('../../src/actions/', import.meta.url)), - ); - expect(actionFiles.some((name) => name.includes('ares'))).toBe(false); - }), -); + suite.effect('keeps the ARES integration read-only and exposes no ARES Action', () => + Effect.gen(function* aresLookupReadCase3() { + const sourceFiles = [ + new URL('../../src/integrations/ares/ares-subject.service.ts', import.meta.url), + new URL('../../src/api/ares-lookup.read.ts', import.meta.url), + ]; + const sources = yield* Effect.forEach( + sourceFiles, + (sourceFile) => FileSystem.FileSystem.use((fs) => fs.readFileString(fileURLToPath(sourceFile))), + { concurrency: 'unbounded' }, + ); + for (const source of sources) { + expect(source).not.toMatch(/from ['"].*(?:\/db\/|\/actions\/|\/services\/party-)/u); + } + const actionFiles = yield* FileSystem.FileSystem.use((fs) => + fs.readDirectory(fileURLToPath(new URL('../../src/actions/', import.meta.url))), + ); + expect(actionFiles.some((name) => name.includes('ares'))).toBe(false); + }), + ); +}); diff --git a/app/verticals/party-registry/tests/unit/ares-subject.service.test.ts b/app/verticals/party-registry/tests/unit/ares-subject.service.test.ts index 4347650ee..fe2e37b21 100644 --- a/app/verticals/party-registry/tests/unit/ares-subject.service.test.ts +++ b/app/verticals/party-registry/tests/unit/ares-subject.service.test.ts @@ -1,14 +1,11 @@ +import { DateTime, Effect, Fiber, Logger, Option, Predicate, Schema } from 'effect'; // @effect-diagnostics strictEffectProvide:off -- Tests intentionally provide isolated adapter and logger layers. expires: 2026-12-31. import { expect, it } from 'effect-rstest'; - -import { DateTime, Effect, Fiber, Logger, Option, Predicate, Schema } from 'effect'; import { TestClock } from 'effect/testing'; import { HttpClient, HttpClientError, HttpClientResponse } from 'effect/unstable/http'; import type { HttpClientRequest } from 'effect/unstable/http'; -import { - AresSubjectService, - AresSubjectServiceLive, -} from '../../src/integrations/ares/ares-subject.service.ts'; + +import { AresSubjectService, AresSubjectServiceLive } from '../../src/integrations/ares/ares-subject.service.ts'; type HttpRunner = Parameters[0]; @@ -38,7 +35,10 @@ const jsonResponse = ( ): HttpClientResponse.HttpClientResponse => HttpClientResponse.fromWeb( request, - Response.json(body, { headers: { 'content-type': 'application/json' }, status }), + Response.json(body, { + headers: { 'content-type': 'application/json' }, + status, + }), ); const rawResponse = ( @@ -48,7 +48,10 @@ const rawResponse = ( ): HttpClientResponse.HttpClientResponse => HttpClientResponse.fromWeb( request, - new Response(body, { headers: { 'content-type': 'application/json' }, status }), + new Response(body, { + headers: { 'content-type': 'application/json' }, + status, + }), ); const clientFrom = (runner: HttpRunner): HttpClient.HttpClient => HttpClient.make(runner); @@ -57,10 +60,7 @@ const lookup = (client: HttpClient.HttpClient, ico = '48039101', correlationId = Effect.gen(function* lookupAresSubject() { const service = yield* AresSubjectService; return yield* service.subject({ correlationId, ico }); - }).pipe( - Effect.provide(AresSubjectServiceLive), - Effect.provideService(HttpClient.HttpClient, client), - ); + }).pipe(Effect.provide(AresSubjectServiceLive), Effect.provideService(HttpClient.HttpClient, client)); const capturedLoggerLayer = (entries: string[]) => Logger.layer([ @@ -71,8 +71,10 @@ const capturedLoggerLayer = (entries: string[]) => it.effect('maps a bounded ARES observation and sends an exact credential-free JSON request', () => Effect.gen(function* aresSubjectServiceCase1() { - const requests: { readonly request: HttpClientRequest.HttpClientRequest; readonly url: URL }[] = - []; + const requests: { + readonly request: HttpClientRequest.HttpClientRequest; + readonly url: URL; + }[] = []; const client = clientFrom((request, url) => { requests.push({ request, url }); return Effect.succeed( @@ -96,9 +98,9 @@ it.effect('maps a bounded ARES observation and sends an exact credential-free JS throw new Error('Expected registeredAddress to be defined'); } expect(Option.getOrUndefined(registeredAddress.municipality)).toBe('Praha'); - expect( - result.providerChangedOn.pipe(Option.map(DateTime.formatIsoDateUtc), Option.getOrUndefined), - ).toBe('2026-09-01'); + expect(result.providerChangedOn.pipe(Option.map(DateTime.formatIsoDateUtc), Option.getOrUndefined)).toBe( + '2026-09-01', + ); expect(Option.getOrUndefined(result.providerRecordRef)).toBe('provider-record-123'); expect(Object.hasOwn(result, 'czNace')).toBe(false); expect(Object.hasOwn(result, 'seznamRegistraci')).toBe(false); @@ -121,100 +123,96 @@ it.effect('rejects malformed IČOs before provider I/O', () => return Effect.succeed(jsonResponse(request, 200, rawSubject())); }); - yield* Effect.all( - ['1234567', '123456789', '1234 5678', 'abcdefgh', '../48039101'].map((ico) => + yield* Effect.forEach( + ['1234567', '123456789', '1234 5678', 'abcdefgh', '../48039101'], + (ico) => Effect.gen(function* aresSubjectServiceCase3() { const error = yield* Effect.flip(lookup(client, ico)); expect(Predicate.isTagged(error, 'AresSubjectInvalidIco')).toBe(true); }), - ), { concurrency: 'unbounded' }, ); expect(requests).toBe(0); }), ); -it.effect( - 'represents absent optional provider facts explicitly without inventing Party facts', - () => - Effect.gen(function* aresSubjectServiceCase4() { - const client = clientFrom((request) => - Effect.succeed( - jsonResponse(request, 200, { - ico: '48039101', - obchodniJmeno: null, - sidlo: {}, - }), - ), - ); - const result = yield* client.pipe(lookup); +it.effect('represents absent optional provider facts explicitly without inventing Party facts', () => + Effect.gen(function* aresSubjectServiceCase4() { + const client = clientFrom((request) => + Effect.succeed( + jsonResponse(request, 200, { + ico: '48039101', + obchodniJmeno: null, + sidlo: {}, + }), + ), + ); + const result = yield* client.pipe(lookup); - expect(result.subject).toEqual({ - businessName: Option.none(), - dic: Option.none(), - dissolvedOn: Option.none(), - establishedOn: Option.none(), - ico: '48039101', - legalFormCode: Option.none(), - registeredAddress: Option.none(), - }); - expect(Option.isNone(result.providerChangedOn)).toBe(true); - expect(Option.isNone(result.providerRecordRef)).toBe(true); - }), + expect(result.subject).toEqual({ + businessName: Option.none(), + dic: Option.none(), + dissolvedOn: Option.none(), + establishedOn: Option.none(), + ico: '48039101', + legalFormCode: Option.none(), + registeredAddress: Option.none(), + }); + expect(Option.isNone(result.providerChangedOn)).toBe(true); + expect(Option.isNone(result.providerRecordRef)).toBe(true); + }), ); -it.effect( - 'keeps not-found, denial, throttling, timeout, and unavailable failures distinct and safe', - () => - Effect.gen(function* aresSubjectServiceCase5() { - const statusCases = [ - [400, 'AresSubjectResponseInvalid', 1], - [401, 'AresSubjectDenied', 1], - [403, 'AresSubjectDenied', 1], - [404, 'AresSubjectNotFound', 1], - [418, 'AresSubjectResponseInvalid', 1], - [429, 'AresSubjectThrottled', 3], - [500, 'AresSubjectUnavailable', 3], - [502, 'AresSubjectUnavailable', 3], - ] as const; - yield* Effect.all( - statusCases.map(([status, tag, expectedAttempts]) => - Effect.gen(function* aresSubjectServiceCase6() { - let attempts = 0; - const client = clientFrom((request) => { - attempts += 1; - return Effect.succeed( - jsonResponse(request, status, { - kod: 'PRIVATE_PROVIDER_CODE', - popis: 'private provider detail', - }), - ); - }); - const program = Effect.flip(lookup(client)); - const fiberProgram = Effect.gen(function* finishRetries() { - const fiber = yield* program.pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* TestClock.adjust('10 seconds'); - return yield* Fiber.join(fiber); - }); - const error = yield* expectedAttempts === 3 ? fiberProgram : program; - expect(Predicate.isTagged(error, tag)).toBe(true); - expect(attempts).toBe(expectedAttempts); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( - 'PRIVATE_PROVIDER_CODE', - ), - ).toBe(false); - expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( - 'private provider detail', - ), - ).toBe(false); - }), - ), - { concurrency: 'unbounded' }, - ); - }), +it.effect('keeps not-found, denial, throttling, timeout, and unavailable failures distinct and safe', () => + Effect.gen(function* aresSubjectServiceCase5() { + const statusCases = [ + [400, 'AresSubjectResponseInvalid', 1], + [401, 'AresSubjectDenied', 1], + [403, 'AresSubjectDenied', 1], + [404, 'AresSubjectNotFound', 1], + [418, 'AresSubjectResponseInvalid', 1], + [429, 'AresSubjectThrottled', 3], + [500, 'AresSubjectUnavailable', 3], + [502, 'AresSubjectUnavailable', 3], + ] as const; + yield* Effect.all( + statusCases.map(([status, tag, expectedAttempts]) => + Effect.gen(function* aresSubjectServiceCase6() { + let attempts = 0; + const client = clientFrom((request) => { + attempts += 1; + return Effect.succeed( + jsonResponse(request, status, { + kod: 'PRIVATE_PROVIDER_CODE', + popis: 'private provider detail', + }), + ); + }); + const program = Effect.flip(lookup(client)); + const fiberProgram = Effect.gen(function* finishRetries() { + const fiber = yield* program.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust('10 seconds'); + return yield* Fiber.join(fiber); + }); + const error = yield* expectedAttempts === 3 ? fiberProgram : program; + expect(Predicate.isTagged(error, tag)).toBe(true); + expect(attempts).toBe(expectedAttempts); + expect( + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( + 'PRIVATE_PROVIDER_CODE', + ), + ).toBe(false); + expect( + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( + 'private provider detail', + ), + ).toBe(false); + }), + ), + { concurrency: 'unbounded' }, + ); + }), ); it.effect('retries transport faults with bounded backoff without exposing diagnostics', () => @@ -234,9 +232,7 @@ it.effect('retries transport faults with bounded backoff without exposing diagno ); }); const program = Effect.gen(function* runTransportRetries() { - const fiber = yield* Effect.flip(lookup(client, '48039101', 'corr\nprivate')).pipe( - Effect.forkChild, - ); + const fiber = yield* Effect.flip(lookup(client, '48039101', 'corr\nprivate')).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust('10 seconds'); return yield* Fiber.join(fiber); @@ -246,9 +242,7 @@ it.effect('retries transport faults with bounded backoff without exposing diagno expect(Predicate.isTagged(error, 'AresSubjectUnavailable')).toBe(true); expect(attempts).toBe(3); expect( - (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes( - 'private socket diagnostic', - ), + (yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(error)).includes('private socket diagnostic'), ).toBe(false); expect(logs.join('\n')).toMatch(/private socket diagnostic/u); expect(logs.join('\n')).toMatch(/corr private/u); @@ -292,9 +286,7 @@ it.effect('bounds stalled response bodies with the same three-attempt timeout po ); }); const program = Effect.gen(function* runBodyTimeouts() { - const fiber = yield* Effect.flip(lookup(client).pipe(Effect.timeout('20 seconds'))).pipe( - Effect.forkChild, - ); + const fiber = yield* Effect.flip(lookup(client).pipe(Effect.timeout('20 seconds'))).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust('30 seconds'); return yield* Fiber.join(fiber); @@ -305,36 +297,37 @@ it.effect('bounds stalled response bodies with the same three-attempt timeout po }), ); -it.effect( - 'rejects malformed JSON, schema drift, mismatched IČO, and oversized text without partial evidence', - () => - Effect.gen(function* aresSubjectServiceCase10() { - const responses: readonly (( - request: HttpClientRequest.HttpClientRequest, - ) => HttpClientResponse.HttpClientResponse)[] = [ - (request) => rawResponse(request, 200, '{'), - (request) => jsonResponse(request, 200, { obchodniJmeno: 'missing IČO' }), - (request) => jsonResponse(request, 200, rawSubject('12345678')), - (request) => - jsonResponse(request, 200, { ...rawSubject(), obchodniJmeno: 'x'.repeat(501) }), - ]; +it.effect('rejects malformed JSON, schema drift, mismatched IČO, and oversized text without partial evidence', () => + Effect.gen(function* aresSubjectServiceCase10() { + const responses: readonly (( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientResponse.HttpClientResponse)[] = [ + (request) => rawResponse(request, 200, '{'), + (request) => jsonResponse(request, 200, { obchodniJmeno: 'missing IČO' }), + (request) => jsonResponse(request, 200, rawSubject('12345678')), + (request) => + jsonResponse(request, 200, { + ...rawSubject(), + obchodniJmeno: 'x'.repeat(501), + }), + ]; - yield* Effect.all( - responses.map((response) => - Effect.gen(function* aresSubjectServiceCase11() { - let requests = 0; - const client = clientFrom((request) => { - requests += 1; - return Effect.succeed(response(request)); - }); - const error = yield* Effect.flip(lookup(client)); - expect(Predicate.isTagged(error, 'AresSubjectResponseInvalid')).toBe(true); - expect(requests).toBe(1); - }), - ), - { concurrency: 'unbounded' }, - ); - }), + yield* Effect.forEach( + responses, + (response) => + Effect.gen(function* aresSubjectServiceCase11() { + let requests = 0; + const client = clientFrom((request) => { + requests += 1; + return Effect.succeed(response(request)); + }); + const error = yield* Effect.flip(lookup(client)); + expect(Predicate.isTagged(error, 'AresSubjectResponseInvalid')).toBe(true); + expect(requests).toBe(1); + }), + { concurrency: 'unbounded' }, + ); + }), ); it.effect('coalesces identical requests and exposes cache age without changing observedAt', () => @@ -342,9 +335,7 @@ it.effect('coalesces identical requests and exposes cache age without changing o let requests = 0; const client = clientFrom((request) => { requests += 1; - return Effect.sleep('1 second').pipe( - Effect.andThen(Effect.succeed(jsonResponse(request, 200, rawSubject()))), - ); + return Effect.sleep('1 second').pipe(Effect.andThen(Effect.succeed(jsonResponse(request, 200, rawSubject())))); }); const program = Effect.gen(function* exerciseCache() { const service = yield* AresSubjectService; @@ -360,12 +351,12 @@ it.effect('coalesces identical requests and exposes cache age without changing o yield* TestClock.adjust('1 second'); const initial = yield* Fiber.join(concurrent); yield* TestClock.adjust('2 minutes'); - const cached = yield* service.subject({ correlationId: 'cached', ico: '48039101' }); + const cached = yield* service.subject({ + correlationId: 'cached', + ico: '48039101', + }); return { cached, initial }; - }).pipe( - Effect.provide(AresSubjectServiceLive), - Effect.provideService(HttpClient.HttpClient, client), - ); + }).pipe(Effect.provide(AresSubjectServiceLive), Effect.provideService(HttpClient.HttpClient, client)); const result = yield* program; expect(requests).toBe(1); @@ -412,10 +403,7 @@ it.effect('bounds distinct upstream lookups to four concurrent requests', () => expect(active).toBe(4); yield* TestClock.adjust('2 seconds'); return yield* Fiber.join(fiber); - }).pipe( - Effect.provide(AresSubjectServiceLive), - Effect.provideService(HttpClient.HttpClient, client), - ); + }).pipe(Effect.provide(AresSubjectServiceLive), Effect.provideService(HttpClient.HttpClient, client)); const results = yield* program; expect(results.length).toBe(8); diff --git a/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts b/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts index 7c7b5b255..ed429d5fb 100644 --- a/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts +++ b/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts @@ -1,33 +1,32 @@ -import { assert, it } from 'effect-rstest'; import { Effect, Result } from 'effect'; +import { assert, it } from 'effect-rstest'; + import { EngagementProfileConflict } from '../../shared/domain/engagement-profile.ts'; import { handleAttachEngagement } from '../../src/actions/attach-engagement-handler.ts'; -it.effect( - 'engagement creation runs only after successful validation and preserves the result', - () => - Effect.gen(function* createsAfterValidation() { - const calls: string[] = []; - const payload = { partyId: 'party' }; - const profile = { profileId: 'profile' }; - const result = yield* handleAttachEngagement(payload, { - services: { - validate: (input) => - Effect.sync(() => { - assert.equal(input, payload); - calls.push('validate'); - }), - create: (input) => - Effect.sync(() => { - assert.equal(input, payload); - calls.push('create'); - return profile; - }), - }, - }); - assert.equal(result, profile); - assert.deepEqual(calls, ['validate', 'create']); - }), +it.effect('engagement creation runs only after successful validation and preserves the result', () => + Effect.gen(function* createsAfterValidation() { + const calls: string[] = []; + const payload = { partyId: 'party' }; + const profile = { profileId: 'profile' }; + const result = yield* handleAttachEngagement(payload, { + services: { + validate: (input) => + Effect.sync(() => { + assert.equal(input, payload); + calls.push('validate'); + }), + create: (input) => + Effect.sync(() => { + assert.equal(input, payload); + calls.push('create'); + return profile; + }), + }, + }); + assert.equal(result, profile); + assert.deepEqual(calls, ['validate', 'create']); + }), ); it.effect('engagement validation failure retains its typed error and prevents persistence', () => diff --git a/app/verticals/party-registry/tests/unit/audit-evidence-contract.test.ts b/app/verticals/party-registry/tests/unit/audit-evidence-contract.test.ts index 355f2ab9e..b1186d88b 100644 --- a/app/verticals/party-registry/tests/unit/audit-evidence-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/audit-evidence-contract.test.ts @@ -1,12 +1,10 @@ -import { expect, it } from 'effect-rstest'; import { Schema } from 'effect'; -import { - PartySubjectEvidenceSchema, - makePartyRef, -} from '../../shared/domain/identity-contracts.ts'; +import { expect, it } from 'effect-rstest'; + +import { PartySubjectEvidenceSchema, makePartyRef } from '../../shared/domain/identity-contracts.ts'; import { PartyMatchDecisionRecordSchema } from '../../shared/domain/matching-contracts.ts'; -import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; +import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; const tenant = '11111111-1111-4111-8111-111111111111'; const id = '22222222-2222-4222-8222-222222222222'; @@ -27,7 +25,10 @@ it('typed subject evidence accepts arbitrary reference spelling, rejects unsuppo }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(PartySubjectEvidenceSchema)({ ...evidence, statement: '' }), + Schema.decodeUnknownSync(PartySubjectEvidenceSchema)({ + ...evidence, + statement: '', + }), ).toThrow(); }); it('Create recovery distinguishes matching outcome and enforces reference invariants', () => { @@ -50,7 +51,12 @@ it('Create recovery distinguishes matching outcome and enforces reference invari expect(() => decode({ ...record, operation: 'MATCH' })).toThrow(); expect(() => decode({ ...record, caseRef: makeDuplicateCandidateCaseRef(tenant, id) })).toThrow(); expect(() => - decode({ ...record, committedCreateOutcome: null, outcome: 'NO_MATCH', partyRef: null }), + decode({ + ...record, + committedCreateOutcome: null, + outcome: 'NO_MATCH', + partyRef: null, + }), ).toThrow(); expect( decode({ @@ -75,10 +81,8 @@ it('matching decision JSON keeps nullable and optional wire fields compatible', outcome: 'NO_MATCH' as const, partyRef: null, }; - const decoded = Schema.decodeUnknownSync(PartyMatchDecisionRecordSchema)(record); - const encoded = Schema.encodeUnknownSync(Schema.toCodecJson(PartyMatchDecisionRecordSchema))( - decoded, - ); + const decoded = Schema.decodeSync(PartyMatchDecisionRecordSchema)(record); + const encoded = Schema.encodeUnknownSync(Schema.toCodecJson(PartyMatchDecisionRecordSchema))(decoded); expect(encoded).toEqual(record); const omitted = { caseRef: record.caseRef, @@ -90,12 +94,10 @@ it('matching decision JSON keeps nullable and optional wire fields compatible', outcome: record.outcome, partyRef: record.partyRef, }; - const omittedEncoded = Schema.encodeUnknownSync( - Schema.toCodecJson(PartyMatchDecisionRecordSchema), - )(Schema.decodeUnknownSync(PartyMatchDecisionRecordSchema)(omitted)); - const omittedEncodedObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))( - omittedEncoded, + const omittedEncoded = Schema.encodeUnknownSync(Schema.toCodecJson(PartyMatchDecisionRecordSchema))( + Schema.decodeSync(PartyMatchDecisionRecordSchema)(omitted), ); + const omittedEncodedObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(omittedEncoded); expect('committedCreateOutcome' in omittedEncodedObject).toBe(false); expect('evidenceEvaluation' in omittedEncodedObject).toBe(false); }); diff --git a/app/verticals/party-registry/tests/unit/catalog-contract.test.ts b/app/verticals/party-registry/tests/unit/catalog-contract.test.ts index cc1245195..3e11ea420 100644 --- a/app/verticals/party-registry/tests/unit/catalog-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/catalog-contract.test.ts @@ -1,4 +1,5 @@ import { assert, expect, it } from 'effect-rstest'; + import { comparePartyCatalog, expectedPartyTableCatalog } from '../../src/db/catalog.ts'; it('reports exact Party Registry catalog differences', () => { diff --git a/app/verticals/party-registry/tests/unit/contact-point-contract.test.ts b/app/verticals/party-registry/tests/unit/contact-point-contract.test.ts index 33231800c..ad447f07e 100644 --- a/app/verticals/party-registry/tests/unit/contact-point-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/contact-point-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { AddressContactPointValueSchema, AddressContactPointInputSchema, @@ -12,21 +13,15 @@ import { normalizePhone, assertAddressPurposeRules, } from '../../shared/domain/contact-point.ts'; -import { - AddContactPointPayloadSchema, - addContactPointAction, -} from '../../src/actions/add-contact-point.action.ts'; +import { OutboxPayloadSchema as ContactPointAddedOutboxPayloadSchema } from '../../shared/outbox/party-registry-contact-point-added-v1.ts'; +import { AddContactPointPayloadSchema, addContactPointAction } from '../../src/actions/add-contact-point.action.ts'; +import { EndContactPointPayloadSchema, endContactPointAction } from '../../src/actions/end-contact-point.action.ts'; import { UpdateContactPointPayloadSchema, updateContactPointAction, } from '../../src/actions/update-contact-point.action.ts'; -import { - EndContactPointPayloadSchema, - endContactPointAction, -} from '../../src/actions/end-contact-point.action.ts'; import { partyContactPointDetailRead } from '../../src/api/party-contact-point-detail.read.ts'; import { partyContactPointsRead } from '../../src/api/party-contact-points.read.ts'; -import { OutboxPayloadSchema as ContactPointAddedOutboxPayloadSchema } from '../../shared/outbox/party-registry-contact-point-added-v1.ts'; const partyRef = { moduleId: 'party.registry', @@ -48,7 +43,7 @@ it('normalizes EMAIL without provider-specific identity heuristics', () => { normalizeEmail('qatest+two@example.com').lookupValue, ); expect(() => - Schema.decodeUnknownSync(EmailContactPointInputSchema)({ + Schema.decodeSync(EmailContactPointInputSchema)({ preferred: false, type: 'EMAIL', value: 'not-an-email', @@ -70,7 +65,7 @@ it('normalizes PHONE only with explicit international or country context and pre }); expect(() => normalizePhone('777 123 456')).toThrow(); expect(() => - Schema.decodeUnknownSync(PhoneContactPointInputSchema)({ + Schema.decodeSync(PhoneContactPointInputSchema)({ countryCode: 'CZ', preferred: false, type: 'PHONE', @@ -78,7 +73,7 @@ it('normalizes PHONE only with explicit international or country context and pre }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(PhoneContactPointInputSchema)({ + Schema.decodeSync(PhoneContactPointInputSchema)({ extension: '1234567890123', preferred: false, type: 'PHONE', @@ -87,7 +82,7 @@ it('normalizes PHONE only with explicit international or country context and pre ).toThrow(); expect(() => normalizePhone('+420777123456', 'CZ', '123456789012')).not.toThrow(); expect(() => - Schema.decodeUnknownSync(PhoneContactPointInputSchema)({ + Schema.decodeSync(PhoneContactPointInputSchema)({ preferred: false, type: 'PHONE', value: '777 123 456', @@ -95,7 +90,7 @@ it('normalizes PHONE only with explicit international or country context and pre ).toThrow(); }); it('keeps ADDRESS structured, multi-purpose, and preferred independently per purpose', () => { - const decoded = Schema.decodeUnknownSync(AddressContactPointInputSchema)({ + const decoded = Schema.decodeSync(AddressContactPointInputSchema)({ address: { addressLine1: ' Na Prikope 1 ', city: ' Praha ', @@ -155,7 +150,11 @@ it('requires authoritative, registry-scoped evidence only for REGISTERED', () => }, { preferred: false, purpose: 'CORRESPONDENCE' }, ], - { ...provenance, authoritative: true, evidenceReference: 'evidence:ares:subject:1' }, + { + ...provenance, + authoritative: true, + evidenceReference: 'evidence:ares:subject:1', + }, ), ).not.toThrow(); }); @@ -180,8 +179,12 @@ it('declares tenant-authorized idempotent Actions and prevents value overwrite t expect(action.descriptor.tenantPermission).not.toBe(undefined); } expect(() => - Schema.decodeUnknownSync(AddContactPointPayloadSchema)({ - contactPoint: { preferred: true, type: 'EMAIL', value: 'user@example.test' }, + Schema.decodeSync(AddContactPointPayloadSchema)({ + contactPoint: { + preferred: true, + type: 'EMAIL', + value: 'user@example.test', + }, partyRef, privacyClassification: 'PERSONAL', provenance, @@ -190,7 +193,9 @@ it('declares tenant-authorized idempotent Actions and prevents value overwrite t }), ).not.toThrow(); expect(() => - Schema.decodeUnknownSync(UpdateContactPointPayloadSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownSync(UpdateContactPointPayloadSchema, { + onExcessProperty: 'error', + })({ change: { preferred: true, type: 'SET_CHANNEL_PREFERRED' }, contactPointRef: { ...partyRef, @@ -209,12 +214,20 @@ it('governs contact reads with tenant Party authority even when Legal Entity con } }); it('publishes stable references instead of mutable contact data', () => { - const contactPointRef = { ...partyRef, resourceType: 'party.registry.party-contact-point' }; + const contactPointRef = { + ...partyRef, + resourceType: 'party.registry.party-contact-point', + }; expect( - Schema.decodeUnknownSync(ContactPointAddedOutboxPayloadSchema)({ contactPointRef, partyRef }), + Schema.decodeUnknownSync(ContactPointAddedOutboxPayloadSchema)({ + contactPointRef, + partyRef, + }), ).toEqual({ contactPointRef, partyRef }); expect(() => - Schema.decodeUnknownSync(ContactPointAddedOutboxPayloadSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownSync(ContactPointAddedOutboxPayloadSchema, { + onExcessProperty: 'error', + })({ contactPointRef, displayValue: 'private@example.test', partyRef, @@ -222,7 +235,10 @@ it('publishes stable references instead of mutable contact data', () => { ).toThrow(); }); it('models removal as a reasoned temporal end of a whole contact or one ADDRESS purpose', () => { - const contactPointRef = { ...partyRef, resourceType: 'party.registry.party-contact-point' }; + const contactPointRef = { + ...partyRef, + resourceType: 'party.registry.party-contact-point', + }; for (const target of [ { type: 'WHOLE_CONTACT_POINT' }, { target: { purpose: 'DELIVERY' }, type: 'ADDRESS_PURPOSE' }, @@ -269,7 +285,10 @@ it('models removal as a reasoned temporal end of a whole contact or one ADDRESS ).toThrow(); }); it('models an originally wrong Contact Point as an explicit correction with optional validated replacement', () => { - const contactPointRef = { ...partyRef, resourceType: 'party.registry.party-contact-point' }; + const contactPointRef = { + ...partyRef, + resourceType: 'party.registry.party-contact-point', + }; expect(() => Schema.decodeUnknownSync(UpdateContactPointPayloadSchema)({ change: { @@ -282,7 +301,10 @@ it('models an originally wrong Contact Point as an explicit correction with opti value: 'correct@example.test', }, privacyClassification: 'PERSONAL', - provenance: { ...provenance, evidenceReference: 'evidence:customer-confirmation:42' }, + provenance: { + ...provenance, + evidenceReference: 'evidence:customer-confirmation:42', + }, validFrom: '2026-09-03T10:00:00.000Z', verification: { state: 'UNVERIFIED' }, }, @@ -290,7 +312,10 @@ it('models an originally wrong Contact Point as an explicit correction with opti }, contactPointRef, expectedRevision: 3, - provenance: { ...provenance, evidenceReference: 'evidence:customer-confirmation:42' }, + provenance: { + ...provenance, + evidenceReference: 'evidence:customer-confirmation:42', + }, }), ).not.toThrow(); expect(() => @@ -319,8 +344,8 @@ it('projects independently auditable whole-contact and ADDRESS-purpose ends', () reason: 'Correspondence moved to another address', recordedAt: '2026-09-03T10:00:00.000Z', } as const; - const end = Schema.decodeUnknownSync(ContactPointEndSchema)(encodedEnd); - const address = Schema.decodeUnknownSync(AddressContactPointValueSchema)({ + const end = Schema.decodeSync(ContactPointEndSchema)(encodedEnd); + const address = Schema.decodeSync(AddressContactPointValueSchema)({ address: { addressLine1: 'Na Prikope 1', addressLine2: null, diff --git a/app/verticals/party-registry/tests/unit/contact-point-correction-action.test.ts b/app/verticals/party-registry/tests/unit/contact-point-correction-action.test.ts index fbf5a3100..01cf7e5dc 100644 --- a/app/verticals/party-registry/tests/unit/contact-point-correction-action.test.ts +++ b/app/verticals/party-registry/tests/unit/contact-point-correction-action.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { createActionCollector } from '../../../../packages/core-runtime/src/actions/collector.ts'; import { getActionHandler } from '../../../../packages/core-runtime/src/actions/definition.ts'; import type { PartyContactPoint } from '../../shared/domain/contact-point.ts'; @@ -47,65 +48,63 @@ const replacement: PartyContactPoint = { }, verification: { state: 'UNVERIFIED' }, }; -it.effect( - 'correction publishes the corrected stable ref while returning the validated replacement', - () => - Effect.gen(function* correctionScenario() { - const collector = createActionCollector( - updateContactPointAction.descriptor.domainEvents, - 'party.registry', - updateContactPointAction.descriptor.accessEvidencePolicy, - ); - const handler = getActionHandler(updateContactPointAction); - const result = yield* handler( - { - change: { - evidenceReferences: ['evidence:party-confirmation:42'], - reason: 'Original mailbox never belonged to this Party', - replacement: { - contactPoint: { - preferred: true, - type: 'EMAIL', - value: 'correct@example.test', - }, - privacyClassification: 'PERSONAL', - provenance: replacement.provenance, - validFrom: replacement.validFrom, - verification: replacement.verification, +it.effect('correction publishes the corrected stable ref while returning the validated replacement', () => + Effect.gen(function* correctionScenario() { + const collector = createActionCollector( + updateContactPointAction.descriptor.domainEvents, + 'party.registry', + updateContactPointAction.descriptor.accessEvidencePolicy, + ); + const handler = getActionHandler(updateContactPointAction); + const result = yield* handler( + { + change: { + evidenceReferences: ['evidence:party-confirmation:42'], + reason: 'Original mailbox never belonged to this Party', + replacement: { + contactPoint: { + preferred: true, + type: 'EMAIL', + value: 'correct@example.test', }, - type: 'CORRECT_CONTACT_POINT', + privacyClassification: 'PERSONAL', + provenance: replacement.provenance, + validFrom: replacement.validFrom, + verification: replacement.verification, }, - contactPointRef: originalContactPointRef, - expectedRevision: 2, - provenance: replacement.provenance, + type: 'CORRECT_CONTACT_POINT', }, - { - actionInvocationId: '40000000-0000-4000-8000-000000000001', - addDomainEvent: collector.addDomainEvent, - addOutboxMessage: collector.addOutboxMessage, - recordAuditEvidence: collector.recordAuditEvidence, - recordDataAccess: collector.recordDataAccess, - scope: { - authMethod: 'system', - correlationId: 'correction-test', - principalId: '50000000-0000-4000-8000-000000000001', - tenantId, - }, - services: { update: () => Effect.succeed(replacement) }, - }, - ); - expect(result.contactPointRef).toEqual(replacement.contactPointRef); - const snapshot = collector.snapshot(); - expect(snapshot.domainEvents.length).toBe(1); - expect(snapshot.domainEvents[0]?.subjectResourceId).toBe(originalContactPointRef.resourceId); - expect(snapshot.domainEvents[0]?.payloadJson).toEqual({ - contactPointRef: originalContactPointRef, - partyRef, - revision: 3, - }); - expect(snapshot.outboxMessages[0]?.message.payloadJson).toEqual({ contactPointRef: originalContactPointRef, - partyRef, - }); - }), + expectedRevision: 2, + provenance: replacement.provenance, + }, + { + actionInvocationId: '40000000-0000-4000-8000-000000000001', + addDomainEvent: collector.addDomainEvent, + addOutboxMessage: collector.addOutboxMessage, + recordAuditEvidence: collector.recordAuditEvidence, + recordDataAccess: collector.recordDataAccess, + scope: { + authMethod: 'system', + correlationId: 'correction-test', + principalId: '50000000-0000-4000-8000-000000000001', + tenantId, + }, + services: { update: () => Effect.succeed(replacement) }, + }, + ); + expect(result.contactPointRef).toEqual(replacement.contactPointRef); + const snapshot = collector.snapshot(); + expect(snapshot.domainEvents.length).toBe(1); + expect(snapshot.domainEvents[0]?.subjectResourceId).toBe(originalContactPointRef.resourceId); + expect(snapshot.domainEvents[0]?.payloadJson).toEqual({ + contactPointRef: originalContactPointRef, + partyRef, + revision: 3, + }); + expect(snapshot.outboxMessages[0]?.message.payloadJson).toEqual({ + contactPointRef: originalContactPointRef, + partyRef, + }); + }), ); diff --git a/app/verticals/party-registry/tests/unit/contact-point-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/contact-point-persistence.service.test.ts index e254e163c..d9bcb6048 100644 --- a/app/verticals/party-registry/tests/unit/contact-point-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/contact-point-persistence.service.test.ts @@ -1,9 +1,10 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- This focused harness models only the Drizzle native Effect query surface exercised by Contact Point ending. expires: 2026-12-31. */ import { is, SQL } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; import { DateTime, Effect, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; import { makePartyAliasResolutionService } from '../../src/merge/party-alias-resolution.service.ts'; @@ -21,7 +22,7 @@ const actionInvocationId = '40000000-0000-4000-8000-000000000001'; const principalId = '50000000-0000-4000-8000-000000000001'; const instantAsDate = (instant: string): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); const directAliases = makePartyAliasResolutionService({ - findAlias: () => Effect.succeed(Option.none()), + findAlias: () => Effect.succeedNone, partyExists: () => Effect.succeed(true), }); const addContactPointRecord = ( @@ -145,19 +146,16 @@ const transactionHarness = ( const updateSets: Readonly>[] = []; const select = () => { const rows = selectQueue.shift() ?? []; - const chain = Object.assign( - Effect.sync(() => rows), - { - for: () => Effect.succeed(rows), - from: () => chain, - limit: () => chain, - orderBy: () => chain, - where: (condition: SQL) => { - selectWheres.push(condition); - return chain; - }, + const chain = Object.assign(Effect.succeed(rows), { + for: () => Effect.succeed(rows), + from: () => chain, + limit: () => chain, + orderBy: () => chain, + where: (condition: SQL) => { + selectWheres.push(condition); + return chain; }, - ); + }); return chain; }; const update = () => { @@ -187,9 +185,7 @@ const transactionHarness = ( return chain; }; // SAFETY: the harness implements precisely the select/update fluent methods used by this service. - const transaction = { insert, select, update } as unknown as Parameters< - typeof endContactPointRecord - >[0]; + const transaction = { insert, select, update } as unknown as Parameters[0]; return { insertValues, selectWheres, transaction, updateSets }; }; const scope = { @@ -219,9 +215,7 @@ const wholeEndCommand = (effectiveEnd: string, reason = 'Party retired this mail }); it.effect('stores future end provenance while keeping the contact current until the boundary', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const effectiveEnd = '2099-01-01T00:00:00.000Z'; const updated = contactRow({ endEvidenceRefs: ['evidence:contact-end:1'], @@ -234,32 +228,18 @@ it.effect('stores future end provenance while keeping the contact current until revision: 2, validTo: instantAsDate(effectiveEnd), }); - const harness = transactionHarness([ - [contactRow()], - [{ partyId }], - [contactRow()], - [updated], - [], - ]); - const result = yield* endContactPointRecord( - harness.transaction, - scope, - wholeEndCommand(effectiveEnd), - ); + const harness = transactionHarness([[contactRow()], [{ partyId }], [contactRow()], [updated], []]); + const result = yield* endContactPointRecord(harness.transaction, scope, wholeEndCommand(effectiveEnd)); expect(harness.updateSets[0]?.['state']).toBe('ACTIVE'); expect(harness.updateSets[0]?.['isCurrent']).toBe(true); expect(harness.updateSets[0]?.['endEvidenceRefs']).toEqual(['evidence:contact-end:1']); expect(harness.updateSets[0]?.['endReason']).toBe('Party retired this mailbox'); - expect(result.contactPoint.end?.provenance.evidenceReferences).toEqual([ - 'evidence:contact-end:1', - ]); + expect(result.contactPoint.end?.provenance.evidenceReferences).toEqual(['evidence:contact-end:1']); }), ); it.effect('stores end provenance on both a last ADDRESS purpose and its owning address', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const effectiveEnd = '2026-02-01T00:00:00.000Z'; const address = contactRow({ addressLine1: 'Na Prikope 1', @@ -308,65 +288,53 @@ it.effect('stores end provenance on both a last ADDRESS purpose and its owning a ]); const command = { ...wholeEndCommand(effectiveEnd), - target: { target: { purpose: 'DELIVERY' as const }, type: 'ADDRESS_PURPOSE' as const }, + target: { + target: { purpose: 'DELIVERY' as const }, + type: 'ADDRESS_PURPOSE' as const, + }, }; const result = yield* endContactPointRecord(harness.transaction, scope, command); expect(harness.updateSets.length).toBe(2); expect(harness.updateSets[0]?.['endProvenanceSource']).toBe('USER_ASSERTION'); expect(harness.updateSets[1]?.['endProvenanceMethod']).toBe('MANUAL_CONFIRMATION'); expect(result.contactPoint.value.type).toBe('ADDRESS'); - expect( - result.contactPoint.value.type === 'ADDRESS' && - result.contactPoint.value.purposes[0]?.end?.reason, - ).toBe('Party retired this mailbox'); + expect(result.contactPoint.value.type === 'ADDRESS' && result.contactPoint.value.purposes[0]?.end?.reason).toBe( + 'Party retired this mailbox', + ); }), ); -it.effect( - 'reuses only an exact end request and rejects changed evidence at the same boundary', - () => - Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); - const effectiveEnd = '2026-02-01T00:00:00.000Z'; - const ended = contactRow({ - endEvidenceRefs: ['evidence:contact-end:1'], - endProvenanceMethod: 'MANUAL_CONFIRMATION', - endProvenanceSource: 'USER_ASSERTION', - endReason: 'Party retired this mailbox', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: instantAsDate('2026-09-03T12:00:00.000Z'), - isCurrent: false, - revision: 2, - state: 'ENDED', - validTo: instantAsDate(effectiveEnd), - }); - const exactHarness = transactionHarness([[ended], [{ partyId }], [ended], []]); - const exact = yield* endContactPointRecord( - exactHarness.transaction, - scope, - wholeEndCommand(effectiveEnd), - ); - expect(exact.changed).toBe(false); - expect(exactHarness.updateSets.length).toBe(0); - const changedHarness = transactionHarness([[ended], [{ partyId }], [ended]]); - const changed = yield* Effect.exit( - endContactPointRecord( - changedHarness.transaction, - scope, - wholeEndCommand(effectiveEnd, 'A different reason'), - ), - ); - expect(Predicate.isTagged(changed, 'Failure')).toBe(true); - expect(changedHarness.updateSets.length).toBe(0); - }), +it.effect('reuses only an exact end request and rejects changed evidence at the same boundary', () => + Effect.gen(function* contactPointScenario() { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); + const effectiveEnd = '2026-02-01T00:00:00.000Z'; + const ended = contactRow({ + endEvidenceRefs: ['evidence:contact-end:1'], + endProvenanceMethod: 'MANUAL_CONFIRMATION', + endProvenanceSource: 'USER_ASSERTION', + endReason: 'Party retired this mailbox', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: instantAsDate('2026-09-03T12:00:00.000Z'), + isCurrent: false, + revision: 2, + state: 'ENDED', + validTo: instantAsDate(effectiveEnd), + }); + const exactHarness = transactionHarness([[ended], [{ partyId }], [ended], []]); + const exact = yield* endContactPointRecord(exactHarness.transaction, scope, wholeEndCommand(effectiveEnd)); + expect(exact.changed).toBe(false); + expect(exactHarness.updateSets.length).toBe(0); + const changedHarness = transactionHarness([[ended], [{ partyId }], [ended]]); + const changed = yield* Effect.exit( + endContactPointRecord(changedHarness.transaction, scope, wholeEndCommand(effectiveEnd, 'A different reason')), + ); + expect(Predicate.isTagged(changed, 'Failure')).toBe(true); + expect(changedHarness.updateSets.length).toBe(0); + }), ); it.effect('stores correction end provenance on the preserved original Contact Point', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const original = contactRow(); const corrected = contactRow({ endEvidenceRefs: ['evidence:wrong-mailbox:1'], @@ -433,9 +401,7 @@ const updateCommand = (change: Parameters[2]['c }); it.effect('re-adds a scheduled-ended purpose as a new period without reopening its history', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const stalePurpose = purposeRow({ endEvidenceRefs: ['evidence:contact-end:1'], endProvenanceMethod: 'MANUAL_CONFIRMATION', @@ -482,9 +448,7 @@ it.effect('re-adds a scheduled-ended purpose as a new period without reopening i ); it.effect('rejects a REGISTERED context collision as a typed domain conflict before mutation', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const address = addressRow(); const conflicting = purposeRow({ contactPointId: '30000000-0000-4000-8000-000000000002', @@ -526,7 +490,11 @@ it.effect('rejects a REGISTERED context collision as a typed domain conflict bef acceptedByActionInvocationId: actionInvocationId, acceptedByPrincipalId: principalId, contactPoint: { - address: { addressLine1: 'Another street 2', city: 'Praha', countryCode: 'CZ' }, + address: { + addressLine1: 'Another street 2', + city: 'Praha', + countryCode: 'CZ', + }, purposes: [ { preferred: true, @@ -560,9 +528,7 @@ it.effect('rejects a REGISTERED context collision as a typed domain conflict bef ); it.effect('advances revisions on both the transferred purpose and its owning address', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const address = addressRow(); const current = purposeRow({ preferred: false }); const previousPreferred = purposeRow({ @@ -600,9 +566,7 @@ it.effect('advances revisions on both the transferred purpose and its owning add ); it.effect('preserves original provenance evidence and appends deduplicated enrichment', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const row = contactRow({ additionalEvidenceRefs: ['evidence:second'], evidenceReference: 'evidence:first', @@ -627,90 +591,75 @@ it.effect('preserves original provenance evidence and appends deduplicated enric }), ); expect(harness.updateSets[0]?.['evidenceReference']).toBe(undefined); - expect(harness.updateSets[0]?.['additionalEvidenceRefs']).toEqual([ - 'evidence:second', - 'evidence:third', - ]); - expect(result.provenance.evidenceReferences).toEqual([ - 'evidence:first', - 'evidence:second', - 'evidence:third', - ]); + expect(harness.updateSets[0]?.['additionalEvidenceRefs']).toEqual(['evidence:second', 'evidence:third']); + expect(result.provenance.evidenceReferences).toEqual(['evidence:first', 'evidence:second', 'evidence:third']); }), ); -it.effect( - 'rejects invalid E.164 and oversized extensions through the service typed-error path', - () => - Effect.all( - [ - { preferred: false, type: 'PHONE' as const, value: '+0123456789' }, - { - extension: '1234567890123', - preferred: false, - type: 'PHONE' as const, - value: '+420777123456', - }, - ].map((contactPoint) => - Effect.gen(function* contactPointCase() { - const harness = transactionHarness([]); - const error = yield* Effect.flip( - addContactPointRecord(harness.transaction, scope, { - acceptedByActionInvocationId: actionInvocationId, - acceptedByPrincipalId: principalId, - contactPoint, - partyRef: { - moduleId: 'party.registry', - resourceId: partyId, - resourceType: 'party.registry.party', - tenantId, - }, - privacyClassification: 'PERSONAL', - provenance: { - authoritative: false, - method: 'MANUAL_CONFIRMATION', - source: 'USER_ASSERTION', - }, - validFrom: '2026-01-01T00:00:00.000Z', - verification: { state: 'UNVERIFIED' }, - }), - ); - expect(Predicate.isTagged(error, 'PartyContactPointInvalid')).toBe(true); - expect(harness.selectWheres.length).toBe(0); - expect(harness.insertValues.length).toBe(0); - }), - ), - ).pipe(Effect.asVoid), +it.effect('rejects invalid E.164 and oversized extensions through the service typed-error path', () => + Effect.forEach( + [ + { preferred: false, type: 'PHONE' as const, value: '+0123456789' }, + { + extension: '1234567890123', + preferred: false, + type: 'PHONE' as const, + value: '+420777123456', + }, + ], + (contactPoint) => + Effect.gen(function* contactPointCase() { + const harness = transactionHarness([]); + const error = yield* Effect.flip( + addContactPointRecord(harness.transaction, scope, { + acceptedByActionInvocationId: actionInvocationId, + acceptedByPrincipalId: principalId, + contactPoint, + partyRef: { + moduleId: 'party.registry', + resourceId: partyId, + resourceType: 'party.registry.party', + tenantId, + }, + privacyClassification: 'PERSONAL', + provenance: { + authoritative: false, + method: 'MANUAL_CONFIRMATION', + source: 'USER_ASSERTION', + }, + validFrom: '2026-01-01T00:00:00.000Z', + verification: { state: 'UNVERIFIED' }, + }), + ); + expect(Predicate.isTagged(error, 'PartyContactPointInvalid')).toBe(true); + expect(harness.selectWheres.length).toBe(0); + expect(harness.insertValues.length).toBe(0); + }), + ).pipe(Effect.asVoid), ); it.effect( 'rejects an explicit alias Party add but keeps durable ContactPoint updates readable through the full chain', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const intermediatePartyId = '20000000-0000-4000-8000-000000000002'; const canonicalPartyId = '20000000-0000-4000-8000-000000000003'; const aliases = makePartyAliasResolutionService({ findAlias: (requestedTenantId, requestedPartyId) => { if (requestedPartyId === partyId) { - return Effect.succeed( - Option.some({ - aliasPartyId: partyId, - canonicalPartyId: intermediatePartyId, - tenantId: requestedTenantId, - }), - ); + return Effect.succeedSome({ + aliasPartyId: partyId, + canonicalPartyId: intermediatePartyId, + tenantId: requestedTenantId, + }); } if (requestedPartyId === intermediatePartyId) { - return Effect.succeed( - Option.some({ - aliasPartyId: intermediatePartyId, - canonicalPartyId, - tenantId: requestedTenantId, - }), - ); + return Effect.succeedSome({ + aliasPartyId: intermediatePartyId, + canonicalPartyId, + tenantId: requestedTenantId, + }); } - return Effect.succeed(Option.none()); + return Effect.succeedNone; }, partyExists: () => Effect.succeed(true), }); @@ -722,7 +671,11 @@ it.effect( { acceptedByActionInvocationId: actionInvocationId, acceptedByPrincipalId: principalId, - contactPoint: { preferred: false, type: 'EMAIL', value: 'new@example.test' }, + contactPoint: { + preferred: false, + type: 'EMAIL', + value: 'new@example.test', + }, partyRef: { moduleId: 'party.registry', resourceId: partyId, @@ -742,10 +695,9 @@ it.effect( ), ); expect(Schema.is(PartyAliasWriteRejected)(rejected)).toBe(true); - expect( - (yield* Schema.decodeUnknownEffect(PartyAliasWriteRejected)(rejected)).canonicalPartyRef - .resourceId, - ).toBe(canonicalPartyId); + expect((yield* Schema.decodeUnknownEffect(PartyAliasWriteRejected)(rejected)).canonicalPartyRef.resourceId).toBe( + canonicalPartyId, + ); expect(addHarness.insertValues.length).toBe(0); const row = contactRow(); const updateHarness = transactionHarness([ @@ -768,12 +720,7 @@ it.effect( expect(updated.storedPartyRef?.resourceId).toBe(partyId); expect(updateHarness.updateSets.length).toBe(1); const readHarness = transactionHarness([[row], []]); - const detail = yield* findPartyContactPointRecord( - readHarness.transaction, - scope, - contactPointId, - aliases, - ); + const detail = yield* findPartyContactPointRecord(readHarness.transaction, scope, contactPointId, aliases); expect(Option.getOrThrow(detail).partyRef.resourceId).toBe(canonicalPartyId); expect(Option.getOrThrow(detail).storedPartyRef.resourceId).toBe(partyId); const ended = contactRow({ @@ -787,13 +734,7 @@ it.effect( revision: 2, validTo: instantAsDate('2099-01-01T00:00:00.000Z'), }); - const endHarness = transactionHarness([ - [row], - [{ partyId: canonicalPartyId }], - [row], - [ended], - [], - ]); + const endHarness = transactionHarness([[row], [{ partyId: canonicalPartyId }], [row], [ended], []]); const endResult = yield* endContactPointRecord( endHarness.transaction, scope, @@ -806,17 +747,9 @@ it.effect( ); it.effect('advances the replaced channel preference revision as well as the selected contact', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); const row = contactRow({ preferred: false }); - const harness = transactionHarness([ - [row], - [{ partyId }], - [row], - [contactRow({ revision: 2 })], - [], - ]); + const harness = transactionHarness([[row], [{ partyId }], [row], [contactRow({ revision: 2 })], []]); yield* updateContactPointRecord( harness.transaction, scope, @@ -835,10 +768,8 @@ it.effect( 'persists bounded ARES provenance on the address and purpose without using observation time as effective time', () => Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); - const externalEvidence = yield* Schema.decodeUnknownEffect(AresAppliedEvidenceSchema)({ + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); + const externalEvidence = yield* Schema.decodeEffect(AresAppliedEvidenceSchema)({ authorityPolicyKey: 'party_registry.ares_enrichment', authorityPolicyVersion: '1', cacheAgeSeconds: 120, @@ -854,8 +785,7 @@ it.effect( reasonCode: 'selected_missing_fact_confirmed', servedAt: '2026-09-03T10:02:00.000Z', }); - const encodedExternalEvidence = - yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(externalEvidence); + const encodedExternalEvidence = yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(externalEvidence); const address = addressRow({ externalEvidence: encodedExternalEvidence }); const purpose = purposeRow({ externalEvidence: encodedExternalEvidence, @@ -868,7 +798,11 @@ it.effect( acceptedByActionInvocationId: actionInvocationId, acceptedByPrincipalId: principalId, contactPoint: { - address: { addressLine1: 'Na Prikope 1', city: 'Praha', countryCode: 'CZ' }, + address: { + addressLine1: 'Na Prikope 1', + city: 'Praha', + countryCode: 'CZ', + }, purposes: [ { preferred: true, @@ -897,9 +831,7 @@ it.effect( }); expect(harness.insertValues[0]?.['externalEvidence']).toEqual(encodedExternalEvidence); expect(harness.insertValues[1]?.['externalEvidence']).toEqual(encodedExternalEvidence); - expect(harness.insertValues[0]?.['validFrom']).toEqual( - instantAsDate('2026-08-01T00:00:00.000Z'), - ); + expect(harness.insertValues[0]?.['validFrom']).toEqual(instantAsDate('2026-08-01T00:00:00.000Z')); expect( result.provenance.externalEvidence === undefined ? undefined @@ -911,123 +843,114 @@ it.effect( } }), ); -it.effect( - 'treats PHONE extensions as distinct endpoints while rejecting an exact duplicate extension', - () => - Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); - const existing = contactRow({ - contactPointType: 'PHONE', - displayValue: '+420777123456', - normalizedValue: '+420777123456', - phoneCountryCode: 'CZ', - phoneExtension: '101', - }); - const created = contactRow({ - ...existing, - contactPointId: '30000000-0000-4000-8000-000000000002', - phoneExtension: '102', +it.effect('treats PHONE extensions as distinct endpoints while rejecting an exact duplicate extension', () => + Effect.gen(function* contactPointScenario() { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); + const existing = contactRow({ + contactPointType: 'PHONE', + displayValue: '+420777123456', + normalizedValue: '+420777123456', + phoneCountryCode: 'CZ', + phoneExtension: '101', + }); + const created = contactRow({ + ...existing, + contactPointId: '30000000-0000-4000-8000-000000000002', + phoneExtension: '102', + preferred: false, + }); + const command = (extension: string): Parameters[2] => ({ + acceptedByActionInvocationId: actionInvocationId, + acceptedByPrincipalId: principalId, + contactPoint: { + extension, preferred: false, - }); - const command = (extension: string): Parameters[2] => ({ - acceptedByActionInvocationId: actionInvocationId, - acceptedByPrincipalId: principalId, - contactPoint: { extension, preferred: false, type: 'PHONE', value: '+420777123456' }, - partyRef: { - moduleId: 'party.registry', - resourceId: partyId, - resourceType: 'party.registry.party', - tenantId, - }, - privacyClassification: 'BUSINESS_SENSITIVE', - provenance: { - authoritative: false, - method: 'MANUAL_CONFIRMATION', - source: 'USER_ASSERTION', - }, - validFrom: '2026-01-01T00:00:00.000Z', - verification: { state: 'UNVERIFIED' }, - }); - const newHarness = transactionHarness([[{ partyId }], [existing], []], [[created]]); - const result = yield* addContactPointRecord(newHarness.transaction, scope, command('102')); - expect(newHarness.insertValues.length).toBe(1); - expect(result.value.type === 'PHONE' && result.value.extension).toBe('102'); - const duplicateHarness = transactionHarness([[{ partyId }], [existing]]); - const duplicate = yield* Effect.flip( - addContactPointRecord(duplicateHarness.transaction, scope, command('101')), - ); - expect(Predicate.isTagged(duplicate, 'PartyContactPointAlreadyExists')).toBe(true); - expect(duplicateHarness.insertValues.length).toBe(0); - }), + type: 'PHONE', + value: '+420777123456', + }, + partyRef: { + moduleId: 'party.registry', + resourceId: partyId, + resourceType: 'party.registry.party', + tenantId, + }, + privacyClassification: 'BUSINESS_SENSITIVE', + provenance: { + authoritative: false, + method: 'MANUAL_CONFIRMATION', + source: 'USER_ASSERTION', + }, + validFrom: '2026-01-01T00:00:00.000Z', + verification: { state: 'UNVERIFIED' }, + }); + const newHarness = transactionHarness([[{ partyId }], [existing], []], [[created]]); + const result = yield* addContactPointRecord(newHarness.transaction, scope, command('102')); + expect(newHarness.insertValues.length).toBe(1); + expect(result.value.type === 'PHONE' && result.value.extension).toBe('102'); + const duplicateHarness = transactionHarness([[{ partyId }], [existing]]); + const duplicate = yield* Effect.flip(addContactPointRecord(duplicateHarness.transaction, scope, command('101'))); + expect(Predicate.isTagged(duplicate, 'PartyContactPointAlreadyExists')).toBe(true); + expect(duplicateHarness.insertValues.length).toBe(0); + }), ); -it.effect( - 'whole ADDRESS end preserves an earlier purpose end and its independent accepted evidence', - () => - Effect.gen(function* contactPointScenario() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z')), - ); - const address = addressRow(); - const earlierEndAudit = { - endEvidenceRefs: ['evidence:delivery-contract-ended'], - endProvenanceMethod: 'DOCUMENT_REVIEW', - endProvenanceSource: 'EXTERNAL_EVIDENCE', - endReason: 'Independent delivery contract end', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: instantAsDate('2026-01-02T00:00:00.000Z'), - }; - const earlierPurpose = purposeRow({ - ...earlierEndAudit, - validTo: instantAsDate('2090-01-01T00:00:00.000Z'), - }); - const openPurpose = purposeRow({ - contactPointPurposeId: '60000000-0000-4000-8000-000000000002', - purposeKey: 'CORRESPONDENCE', - }); - const wholeEndAudit = { - endEvidenceRefs: ['evidence:contact-end:1'], - endProvenanceMethod: 'MANUAL_CONFIRMATION', - endProvenanceSource: 'USER_ASSERTION', - endReason: 'Party retired this mailbox', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: instantAsDate('2026-09-03T12:00:00.000Z'), - revision: 2, - validTo: instantAsDate('2099-01-01T00:00:00.000Z'), - }; - const harness = transactionHarness([ - [address], - [{ partyId }], - [address], - [earlierPurpose, openPurpose], - [addressRow(wholeEndAudit)], - [earlierPurpose, purposeRow({ ...openPurpose, ...wholeEndAudit })], - ]); - const result = yield* endContactPointRecord( - harness.transaction, - scope, - wholeEndCommand('2099-01-01T00:00:00.000Z'), - ); - expect(harness.updateSets.length, 'only the address and still-open purpose are changed').toBe( - 2, - ); - expect(harness.updateSets[1]?.['revision']).toBe(2); - if (result.contactPoint.value.type === 'ADDRESS') { - const [preserved] = result.contactPoint.value.purposes; - expect( - preserved?.validTo === null || preserved?.validTo === undefined - ? preserved?.validTo - : DateTime.formatIso(preserved.validTo), - ).toBe('2090-01-01T00:00:00.000Z'); - expect(preserved?.end?.reason).toBe('Independent delivery contract end'); - expect(preserved?.end?.provenance.evidenceReferences).toEqual([ - 'evidence:delivery-contract-ended', - ]); - } else { - expect.unreachable('Expected ADDRESS result'); - } - }), +it.effect('whole ADDRESS end preserves an earlier purpose end and its independent accepted evidence', () => + Effect.gen(function* contactPointScenario() { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T12:00:00.000Z'))); + const address = addressRow(); + const earlierEndAudit = { + endEvidenceRefs: ['evidence:delivery-contract-ended'], + endProvenanceMethod: 'DOCUMENT_REVIEW', + endProvenanceSource: 'EXTERNAL_EVIDENCE', + endReason: 'Independent delivery contract end', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: instantAsDate('2026-01-02T00:00:00.000Z'), + }; + const earlierPurpose = purposeRow({ + ...earlierEndAudit, + validTo: instantAsDate('2090-01-01T00:00:00.000Z'), + }); + const openPurpose = purposeRow({ + contactPointPurposeId: '60000000-0000-4000-8000-000000000002', + purposeKey: 'CORRESPONDENCE', + }); + const wholeEndAudit = { + endEvidenceRefs: ['evidence:contact-end:1'], + endProvenanceMethod: 'MANUAL_CONFIRMATION', + endProvenanceSource: 'USER_ASSERTION', + endReason: 'Party retired this mailbox', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: instantAsDate('2026-09-03T12:00:00.000Z'), + revision: 2, + validTo: instantAsDate('2099-01-01T00:00:00.000Z'), + }; + const harness = transactionHarness([ + [address], + [{ partyId }], + [address], + [earlierPurpose, openPurpose], + [addressRow(wholeEndAudit)], + [earlierPurpose, purposeRow({ ...openPurpose, ...wholeEndAudit })], + ]); + const result = yield* endContactPointRecord( + harness.transaction, + scope, + wholeEndCommand('2099-01-01T00:00:00.000Z'), + ); + expect(harness.updateSets.length, 'only the address and still-open purpose are changed').toBe(2); + expect(harness.updateSets[1]?.['revision']).toBe(2); + if (result.contactPoint.value.type === 'ADDRESS') { + const [preserved] = result.contactPoint.value.purposes; + expect( + preserved?.validTo === null || preserved?.validTo === undefined + ? preserved?.validTo + : DateTime.formatIso(preserved.validTo), + ).toBe('2090-01-01T00:00:00.000Z'); + expect(preserved?.end?.reason).toBe('Independent delivery contract end'); + expect(preserved?.end?.provenance.evidenceReferences).toEqual(['evidence:delivery-contract-ended']); + } else { + expect.unreachable('Expected ADDRESS result'); + } + }), ); diff --git a/app/verticals/party-registry/tests/unit/correction-contract.test.ts b/app/verticals/party-registry/tests/unit/correction-contract.test.ts index 96e848150..31c3e30f3 100644 --- a/app/verticals/party-registry/tests/unit/correction-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/correction-contract.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'effect-rstest'; /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- This harness implements the correction service's Drizzle boundary. expires: 2026-12-31. */ import { DateTime, Effect, Match, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { PartyCorrectionCommandSchema, PartyCorrectionDetailSchema, @@ -48,9 +49,7 @@ const relationshipCommandEncoded = { replacementValidFrom: null, replacementValidTo: '2026-02-01T00:00:00.000Z', } as const; -const relationshipCommand = decode(SupersedeRelationshipCorrectionCommandSchema)( - relationshipCommandEncoded, -); +const relationshipCommand = Schema.decodeSync(SupersedeRelationshipCorrectionCommandSchema)(relationshipCommandEncoded); it('correction is closed to Party type, display name, and official identifier assertions', () => { for (const factKind of ['PARTY_TYPE', 'DISPLAY_NAME', 'OFFICIAL_IDENTIFIER']) { expect(() => @@ -80,16 +79,11 @@ it('correction follow-up is typed and duplicate confirmation remains readiness-o expect(classifyCorrectionRoute('DISPLAY_NAME')).toBe('ENRICHMENT_REVIEW'); expect(classifyCorrectionRoute('OFFICIAL_IDENTIFIER')).toBe('CLAIM_REASSIGNMENT_REVIEW'); expect(classifyCorrectionRoute('RELATIONSHIP')).toBe('RELATIONSHIP_REVIEW'); - expect(confirmDuplicatePartiesAction.descriptor.actionKey).toBe( - 'party.registry.confirm-duplicate-parties', + expect(confirmDuplicatePartiesAction.descriptor.actionKey).toBe('party.registry.confirm-duplicate-parties'); + expect(Object.hasOwn(confirmDuplicatePartiesAction.descriptor.domainEvents, 'party.registry.party-merged.v1')).toBe( + false, ); - expect( - Object.hasOwn( - confirmDuplicatePartiesAction.descriptor.domainEvents, - 'party.registry.party-merged.v1', - ), - ).toBe(false); - const partyTypeCommand = decode(PartyCorrectionCommandSchema)({ + const partyTypeCommand = Schema.decodeSync(PartyCorrectionCommandSchema)({ ...evidence, factKind: 'PARTY_TYPE', partyId, @@ -106,22 +100,16 @@ it('correction follow-up is typed and duplicate confirmation remains readiness-o ], targetAssertionId: assertionId, }); - expect(correctPartyFactAction.descriptor.tenantPermission?.(partyTypeCommand)).toBe( - 'manage_party_identity', - ); + expect(correctPartyFactAction.descriptor.tenantPermission?.(partyTypeCommand)).toBe('manage_party_identity'); expect(correctPartyFactAction.descriptor.auditProfile).toBe('sensitive'); - expect(correctPartyFactAction.descriptor.tenantPermission?.(relationshipCommand)).toBe( - 'manage_party_relationships', - ); + expect(correctPartyFactAction.descriptor.tenantPermission?.(relationshipCommand)).toBe('manage_party_relationships'); }); it('relationship correction is closed, revisioned, interval checked, and has no caller authority hints', () => { const strictDecode = Schema.decodeUnknownSync(PartyCorrectionCommandSchema, { onExcessProperty: 'error', }); const decoded = strictDecode(relationshipCommandEncoded); - expect(Schema.encodeSync(PartyCorrectionCommandSchema)(decoded)).toEqual( - relationshipCommandEncoded, - ); + expect(Schema.encodeSync(PartyCorrectionCommandSchema)(decoded)).toEqual(relationshipCommandEncoded); expect(() => strictDecode({ ...relationshipCommandEncoded, reasonCode: 'OTHER' })).toThrow(); expect(() => strictDecode({ ...relationshipCommandEncoded, expectedRevision: 0 })).toThrow(); expect(() => @@ -139,7 +127,10 @@ it('relationship correction is closed, revisioned, interval checked, and has no 'approvingPrincipalId', ]) { expect(() => - strictDecode({ ...relationshipCommandEncoded, [field]: 'caller-controlled' }), + strictDecode({ + ...relationshipCommandEncoded, + [field]: 'caller-controlled', + }), ).toThrow(); } }); @@ -233,7 +224,10 @@ it.effect( actionInvocationId, principalId, }); - expect(h.updateSets[0]).toEqual({ assertionState: 'SUPERSEDED', revision: 2 }); + expect(h.updateSets[0]).toEqual({ + assertionState: 'SUPERSEDED', + revision: 2, + }); expect(h.insertValues[0]?.['fromPartyId']).toBe(partyId); expect(h.insertValues[0]?.['toPartyId']).toBe(organizationId); expect(h.insertValues[0]?.['relationshipType']).toBe('CONTACT_PERSON_OF'); @@ -247,139 +241,130 @@ it.effect( expect(Option.getOrThrow(result.replacementRelationshipRef).resourceId).toBe(replacementId); }), ); -it.effect( - 'relationship retraction retains original effective validity and creates no replacement', - () => - Effect.gen(function* correctionScenario2() { - const command = decode(PartyCorrectionCommandSchema)({ - ...evidence, - correctionMode: 'RETRACT', - expectedRevision: 1, - factKind: 'RELATIONSHIP', - relationshipRef, - }); - const original = relationshipRow({ - validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), - }); - const h = transactionHarness( - [[], [original], [], [{ partyId }], [], [{ partyId: organizationId }]], - [[{ correctionId }]], - [[original]], - ); - const result = yield* correctPartyFactRecord(h.transaction, tenantId, command, { - actionInvocationId, - principalId, - }); - expect(h.updateSets[0]).toEqual({ assertionState: 'RETRACTED', revision: 2 }); - expect(h.insertValues.length).toBe(1); - expect(Option.isNone(result.replacementAssertionId)).toBe(true); - }), +it.effect('relationship retraction retains original effective validity and creates no replacement', () => + Effect.gen(function* correctionScenario2() { + const command = yield* Schema.decodeEffect(PartyCorrectionCommandSchema)({ + ...evidence, + correctionMode: 'RETRACT', + expectedRevision: 1, + factKind: 'RELATIONSHIP', + relationshipRef, + }); + const original = relationshipRow({ + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), + }); + const h = transactionHarness( + [[], [original], [], [{ partyId }], [], [{ partyId: organizationId }]], + [[{ correctionId }]], + [[original]], + ); + const result = yield* correctPartyFactRecord(h.transaction, tenantId, command, { + actionInvocationId, + principalId, + }); + expect(h.updateSets[0]).toEqual({ + assertionState: 'RETRACTED', + revision: 2, + }); + expect(h.insertValues.length).toBe(1); + expect(Option.isNone(result.replacementAssertionId)).toBe(true); + }), ); -it.effect( - 'stale revision and foreign-tenant relationship correction fail before business writes', - () => - Effect.all( +it.effect('stale revision and foreign-tenant relationship correction fail before business writes', () => + Effect.forEach( + [ + Schema.decodeSync(SupersedeRelationshipCorrectionCommandSchema)({ + ...relationshipCommandEncoded, + expectedRevision: 2, + }), + Schema.decodeSync(SupersedeRelationshipCorrectionCommandSchema)({ + ...relationshipCommandEncoded, + relationshipRef: { ...relationshipRef, tenantId: organizationId }, + }), + ], + (command) => + Effect.gen(function* correctionScenario4() { + const h = transactionHarness([[], [relationshipRow()]]); + const error = yield* Effect.flip( + correctPartyFactRecord(h.transaction, tenantId, command, { + actionInvocationId, + principalId, + }), + ); + expect(Predicate.isTagged(error, 'PartyCorrectionConflict')).toBe(true); + expect(h.updateSets.length).toBe(0); + expect(h.insertValues.length).toBe(0); + }), + { concurrency: 1 }, + ), +); +it.effect('detail exposes immutable original/result semantics, governance, and source distinct from actor', () => + Effect.gen(function* correctionScenario6() { + const h = transactionHarness([ [ - decode(SupersedeRelationshipCorrectionCommandSchema)({ - ...relationshipCommandEncoded, - expectedRevision: 2, - }), - decode(SupersedeRelationshipCorrectionCommandSchema)({ - ...relationshipCommandEncoded, - relationshipRef: { ...relationshipRef, tenantId: organizationId }, + { + actingPrincipalId: principalId, + actionInvocationId, + approvingPrincipalId: null, + correctionId, + evidenceRefs: evidence.evidenceRefs, + officialIdentifierId: null, + partyFactAssertionId: null, + partyId, + policyVersion: evidence.policyVersion, + reason: encodeStoredCorrectionReason(relationshipCommand), + recordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), + relationshipId: assertionId, + replacementOfficialIdentifierId: null, + replacementPartyFactAssertionId: null, + replacementRelationshipId: replacementId, + }, + ], + [ + relationshipRow({ + assertionState: 'SUPERSEDED', + endProvenanceMethod: 'DOCUMENT_REVIEW', + endProvenanceSource: 'ORIGINAL_END_RECORD', + endReason: null, + endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-16T00:00:00.000Z')), + validTo: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-15T00:00:00.000Z')), }), - ].map((command) => - Effect.gen(function* correctionScenario4() { - const h = transactionHarness([[], [relationshipRow()]]); - const error = yield* Effect.flip( - correctPartyFactRecord(h.transaction, tenantId, command, { - actionInvocationId, - principalId, - }), - ); - expect(Predicate.isTagged(error, 'PartyCorrectionConflict')).toBe(true); - expect(h.updateSets.length).toBe(0); - expect(h.insertValues.length).toBe(0); + ], + [ + relationshipRow({ + relationshipId: replacementId, + validTo: DateTime.toDateUtc(DateTime.makeUnsafe(relationshipCommandEncoded.replacementValidTo)), }), + ], + ]); + const found = yield* findPartyCorrection(h.transaction, tenantId, correctionId); + const detail = Match.value(found).pipe( + Match.tag('found', ({ value }) => Schema.encodeSync(PartyCorrectionDetailSchema)(value)), + Match.tag('not_found', () => expect.unreachable('Expected the correction detail to be found')), + Match.exhaustive, + ); + expect( + yield* Schema.encodeEffect(PartyCorrectionDetailSchema)( + yield* Schema.decodeEffect(PartyCorrectionDetailSchema)(detail), ), - { concurrency: 1 }, - ), -); -it.effect( - 'detail exposes immutable original/result semantics, governance, and source distinct from actor', - () => - Effect.gen(function* correctionScenario6() { - const h = transactionHarness([ - [ - { - actingPrincipalId: principalId, - actionInvocationId, - approvingPrincipalId: null, - correctionId, - evidenceRefs: evidence.evidenceRefs, - officialIdentifierId: null, - partyFactAssertionId: null, - partyId, - policyVersion: evidence.policyVersion, - reason: encodeStoredCorrectionReason(relationshipCommand), - recordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - relationshipId: assertionId, - replacementOfficialIdentifierId: null, - replacementPartyFactAssertionId: null, - replacementRelationshipId: replacementId, - }, - ], - [ - relationshipRow({ - assertionState: 'SUPERSEDED', - endProvenanceMethod: 'DOCUMENT_REVIEW', - endProvenanceSource: 'ORIGINAL_END_RECORD', - endReason: null, - endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-16T00:00:00.000Z')), - validTo: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-15T00:00:00.000Z')), - }), - ], - [ - relationshipRow({ - relationshipId: replacementId, - validTo: DateTime.toDateUtc( - DateTime.makeUnsafe(relationshipCommandEncoded.replacementValidTo), - ), - }), - ], - ]); - const found = yield* findPartyCorrection(h.transaction, tenantId, correctionId); - const detail = Match.value(found).pipe( - Match.tag('found', ({ value }) => Schema.encodeSync(PartyCorrectionDetailSchema)(value)), - Match.tag('not_found', () => - expect.unreachable('Expected the correction detail to be found'), - ), - Match.exhaustive, - ); - expect( - yield* Schema.encodeEffect(PartyCorrectionDetailSchema)( - yield* Schema.decodeUnknownEffect(PartyCorrectionDetailSchema)(detail), - ), - ).toEqual(detail); - expect(detail.actingPrincipalId).toBe(principalId); - expect(detail.approvingPrincipalId).toBe(null); - expect(detail.evidenceSource).toBe('DOCUMENT'); - expect(detail.actionInvocationId).toBe(actionInvocationId); - expect(detail.originalAssertion.assertionId).toBe(assertionId); - expect(detail.originalAssertion.validTo).toBe('2026-01-15T00:00:00.000Z'); - expect(detail.originalAssertion.factKind).toBe('RELATIONSHIP'); - if (detail.originalAssertion.factKind === 'RELATIONSHIP') { - expect(detail.originalAssertion.endEvidence?.reason).toBe(null); - expect(detail.originalAssertion.endEvidence?.provenance.source).toBe('ORIGINAL_END_RECORD'); - expect(detail.originalAssertion.endEvidence?.recordedAt).toBe('2026-01-16T00:00:00.000Z'); - } - expect(detail.resultingAssertion?.assertionId).toBe(replacementId); - expect(detail.resultingAssertion?.validTo).toBe( - relationshipCommandEncoded.replacementValidTo, - ); - expect(detail.governance.legalHolds).toBe('HONOR_GOVERNED_LEGAL_HOLDS'); - expect(detail.governance.policyVersion).toBe(detail.policyVersion); - }), + ).toEqual(detail); + expect(detail.actingPrincipalId).toBe(principalId); + expect(detail.approvingPrincipalId).toBe(null); + expect(detail.evidenceSource).toBe('DOCUMENT'); + expect(detail.actionInvocationId).toBe(actionInvocationId); + expect(detail.originalAssertion.assertionId).toBe(assertionId); + expect(detail.originalAssertion.validTo).toBe('2026-01-15T00:00:00.000Z'); + expect(detail.originalAssertion.factKind).toBe('RELATIONSHIP'); + if (detail.originalAssertion.factKind === 'RELATIONSHIP') { + expect(detail.originalAssertion.endEvidence?.reason).toBe(null); + expect(detail.originalAssertion.endEvidence?.provenance.source).toBe('ORIGINAL_END_RECORD'); + expect(detail.originalAssertion.endEvidence?.recordedAt).toBe('2026-01-16T00:00:00.000Z'); + } + expect(detail.resultingAssertion?.assertionId).toBe(replacementId); + expect(detail.resultingAssertion?.validTo).toBe(relationshipCommandEncoded.replacementValidTo); + expect(detail.governance.legalHolds).toBe('HONOR_GOVERNED_LEGAL_HOLDS'); + expect(detail.governance.policyVersion).toBe(detail.policyVersion); + }), ); it.effect( 'relationship overlap is a typed conflict and no correction journal is written after failed replacement', @@ -390,7 +375,12 @@ it.effect( [[], [original], [], [{ partyId }], [], [{ partyId: organizationId }]], [], [[original]], - { cause: { code: '23P01', constraint: 'party_relationships_no_overlap_excl' } }, + { + cause: { + code: '23P01', + constraint: 'party_relationships_no_overlap_excl', + }, + }, ); const error = yield* Effect.flip( correctPartyFactRecord(h.transaction, tenantId, relationshipCommand, { @@ -432,8 +422,14 @@ it.effect('correction of a durable relationship preserves stored alias endpoints ); it('correction history requires reviewer authority; ordinary identity read permission is insufficient', () => { const target = partyCorrectionPermissionTarget(); - expect(target).toEqual({ kind: 'tenant', permission: 'review_party_identity' }); - expect(target).not.toEqual({ kind: 'tenant', permission: 'read_party_identity' }); + expect(target).toEqual({ + kind: 'tenant', + permission: 'review_party_identity', + }); + expect(target).not.toEqual({ + kind: 'tenant', + permission: 'read_party_identity', + }); }); for (const scenario of [ { @@ -483,7 +479,7 @@ for (const scenario of [ ], ...scenario.claimReads, ]); - const command = decode(PartyCorrectionCommandSchema)({ + const command = yield* Schema.decodeUnknownEffect(PartyCorrectionCommandSchema)({ ...evidence, factKind: 'PARTY_TYPE', partyId, @@ -514,42 +510,40 @@ for (const scenario of [ ); } -it.effect( - 'type Correction cannot treat a reviewer decision or source label as subject evidence', - () => - Effect.gen(function* correctionScenario10() { - const h = transactionHarness([ - [], - [{ partyId }], - [], - [{ partyId }], - [ - { - assertionId, - factKind: 'PARTY_TYPE', - isCurrent: true, - normalizedValue: 'PERSON', - partyId, - state: 'ACTIVE', - }, - ], - ]); - const command = decode(PartyCorrectionCommandSchema)({ - ...evidence, - factKind: 'PARTY_TYPE', - partyId, - replacementValue: 'ORGANIZATION', - targetAssertionId: assertionId, - }); - const error = yield* Effect.flip( - correctPartyFactRecord(h.transaction, tenantId, command, { - actionInvocationId, - principalId, - }), - ); - expect(Predicate.isTagged(error, 'PartyCorrectionConflict')).toBe(true); - expect(error.reason).toBe('subject_evidence_required'); - expect(h.insertValues.length).toBe(0); - expect(h.updateSets.length).toBe(0); - }), +it.effect('type Correction cannot treat a reviewer decision or source label as subject evidence', () => + Effect.gen(function* correctionScenario10() { + const h = transactionHarness([ + [], + [{ partyId }], + [], + [{ partyId }], + [ + { + assertionId, + factKind: 'PARTY_TYPE', + isCurrent: true, + normalizedValue: 'PERSON', + partyId, + state: 'ACTIVE', + }, + ], + ]); + const command = yield* Schema.decodeEffect(PartyCorrectionCommandSchema)({ + ...evidence, + factKind: 'PARTY_TYPE', + partyId, + replacementValue: 'ORGANIZATION', + targetAssertionId: assertionId, + }); + const error = yield* Effect.flip( + correctPartyFactRecord(h.transaction, tenantId, command, { + actionInvocationId, + principalId, + }), + ); + expect(Predicate.isTagged(error, 'PartyCorrectionConflict')).toBe(true); + expect(error.reason).toBe('subject_evidence_required'); + expect(h.insertValues.length).toBe(0); + expect(h.updateSets.length).toBe(0); + }), ); diff --git a/app/verticals/party-registry/tests/unit/cors-origin.test.ts b/app/verticals/party-registry/tests/unit/cors-origin.test.ts index 9acc22cb1..bf9606601 100644 --- a/app/verticals/party-registry/tests/unit/cors-origin.test.ts +++ b/app/verticals/party-registry/tests/unit/cors-origin.test.ts @@ -1,12 +1,8 @@ import { expect, it } from 'effect-rstest'; -import { - partyRegistryCorsAllowedOrigins, - resolvePartyRegistryShellOrigin, -} from '../../api/read-server-support.ts'; + +import { partyRegistryCorsAllowedOrigins, resolvePartyRegistryShellOrigin } from '../../api/read-server-support.ts'; it('Party CORS accepts only the configured nonlocal Shell origin without a localhost fallback', () => { const shellOrigin = 'https://operations.example.test'; - expect(partyRegistryCorsAllowedOrigins(resolvePartyRegistryShellOrigin(shellOrigin))).toEqual([ - shellOrigin, - ]); + expect(partyRegistryCorsAllowedOrigins(resolvePartyRegistryShellOrigin(shellOrigin))).toEqual([shellOrigin]); }); diff --git a/app/verticals/party-registry/tests/unit/counterparty-contract.test.ts b/app/verticals/party-registry/tests/unit/counterparty-contract.test.ts index 1f0253137..01ed5caf0 100644 --- a/app/verticals/party-registry/tests/unit/counterparty-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/counterparty-contract.test.ts @@ -1,5 +1,21 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { CounterpartyReadRequestSchema, CounterpartyReadResponseSchema } from '../../shared/apis/counterparty-read.ts'; +import { + CounterpartyRoleHistoryRequestSchema, + CounterpartyRoleHistoryResponseSchema, +} from '../../shared/apis/counterparty-role-history.ts'; +import { + CounterpartyAuditEvidenceSchema, + CounterpartyIsoTimestampSchema, + CounterpartyPartyProjectionSchema, + CounterpartyRolePeriodSchema, + LegalEntityRefSchema, +} from '../../shared/domain/counterparty-contract.ts'; +import { OutboxPayloadSchema as CounterpartyCreatedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-created-v1.ts'; +import { OutboxPayloadSchema as CounterpartyRoleAddedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-role-added-v1.ts'; +import { OutboxPayloadSchema as CounterpartyRoleEndedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-role-ended-v1.ts'; import { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema, @@ -15,32 +31,11 @@ import { CounterpartyRoleEndResultSchema, counterpartyRoleEndAction, } from '../../src/actions/counterparty-role-end.action.ts'; -import { - counterpartyReadPermissionTarget, - counterpartyReadRead, -} from '../../src/api/counterparty-read.read.ts'; +import { counterpartyReadPermissionTarget, counterpartyReadRead } from '../../src/api/counterparty-read.read.ts'; import { counterpartyRoleHistoryPermissionTarget, counterpartyRoleHistoryRead, } from '../../src/api/counterparty-role-history.read.ts'; -import { - CounterpartyReadRequestSchema, - CounterpartyReadResponseSchema, -} from '../../shared/apis/counterparty-read.ts'; -import { - CounterpartyRoleHistoryRequestSchema, - CounterpartyRoleHistoryResponseSchema, -} from '../../shared/apis/counterparty-role-history.ts'; -import { - CounterpartyAuditEvidenceSchema, - CounterpartyIsoTimestampSchema, - CounterpartyPartyProjectionSchema, - CounterpartyRolePeriodSchema, - LegalEntityRefSchema, -} from '../../shared/domain/counterparty-contract.ts'; -import { OutboxPayloadSchema as CounterpartyCreatedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-created-v1.ts'; -import { OutboxPayloadSchema as CounterpartyRoleAddedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-role-added-v1.ts'; -import { OutboxPayloadSchema as CounterpartyRoleEndedOutboxPayloadSchema } from '../../shared/outbox/party-registry-counterparty-role-ended-v1.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; const partyId = '20000000-0000-4000-8000-000000000001'; @@ -81,15 +76,13 @@ const provenance = { it('declares required Legal Entity scope and Counterparty resource authorization', () => { expect( - [counterpartyCreateAction, counterpartyRoleAddAction, counterpartyRoleEndAction].map( - ({ descriptor }) => ({ - actionKey: descriptor.actionKey, - idempotency: descriptor.idempotency, - legalEntityPermission: descriptor.legalEntityPermission, - legalEntityScope: descriptor.legalEntityScope, - resourcePermission: descriptor.resourcePermission?.kind, - }), - ), + [counterpartyCreateAction, counterpartyRoleAddAction, counterpartyRoleEndAction].map(({ descriptor }) => ({ + actionKey: descriptor.actionKey, + idempotency: descriptor.idempotency, + legalEntityPermission: descriptor.legalEntityPermission, + legalEntityScope: descriptor.legalEntityScope, + resourcePermission: descriptor.resourcePermission?.kind, + })), ).toEqual([ { actionKey: 'party.registry.counterparty-create', @@ -117,13 +110,15 @@ it('declares required Legal Entity scope and Counterparty resource authorization it.effect('creates a durable Counterparty without inventing an implicit role', () => Effect.gen(function* contractScenario2() { - const payload = yield* Schema.decodeUnknownEffect(CounterpartyCreatePayloadSchema, { + const payload = yield* Schema.decodeEffect(CounterpartyCreatePayloadSchema, { onExcessProperty: 'error', })({ partyRef, provenance }); expect(payload).toEqual({ partyRef, provenance }); expect( yield* Effect.flip( - Schema.decodeUnknownEffect(CounterpartyCreatePayloadSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownEffect(CounterpartyCreatePayloadSchema, { + onExcessProperty: 'error', + })({ partyRef, provenance, roleType: 'CUSTOMER', @@ -143,7 +138,7 @@ it.effect('creates a durable Counterparty without inventing an implicit role', ( ), ).toBeDefined(); expect( - yield* Schema.decodeUnknownEffect(CounterpartyCreateResultSchema)({ + yield* Schema.decodeEffect(CounterpartyCreateResultSchema)({ counterpartyRef, created: true, legalEntityRef, @@ -156,7 +151,7 @@ it.effect('creates a durable Counterparty without inventing an implicit role', ( it.effect('publishes only stable references and bounded lifecycle facts', () => Effect.gen(function* contractScenario3() { expect( - yield* Schema.decodeUnknownEffect(CounterpartyCreatedOutboxPayloadSchema, { + yield* Schema.decodeEffect(CounterpartyCreatedOutboxPayloadSchema, { onExcessProperty: 'error', })({ counterpartyRef, legalEntityRef, partyRef }), ).toEqual({ counterpartyRef, legalEntityRef, partyRef }); @@ -167,11 +162,9 @@ it.effect('publishes only stable references and bounded lifecycle facts', () => validFrom: '2026-09-03T10:00:00.000Z', validTo: null, }; + expect(yield* Schema.decodeEffect(CounterpartyRoleAddedOutboxPayloadSchema)(added)).toEqual(added); expect( - yield* Schema.decodeUnknownEffect(CounterpartyRoleAddedOutboxPayloadSchema)(added), - ).toEqual(added); - expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleEndedOutboxPayloadSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleEndedOutboxPayloadSchema)({ ...added, validTo: '2027-01-31T23:59:59.000Z', })).validTo, @@ -191,12 +184,12 @@ it.effect('preserves Counterparty JSON round trips for timestamps, references, a const timestamp = '2026-09-03T10:00:00.000Z'; expect( yield* Schema.encodeEffect(CounterpartyIsoTimestampSchema)( - yield* Schema.decodeUnknownEffect(CounterpartyIsoTimestampSchema)(timestamp), + yield* Schema.decodeEffect(CounterpartyIsoTimestampSchema)(timestamp), ), ).toBe(timestamp); expect( yield* Schema.encodeEffect(LegalEntityRefSchema)( - yield* Schema.decodeUnknownEffect(LegalEntityRefSchema)(legalEntityRef), + yield* Schema.decodeEffect(LegalEntityRefSchema)(legalEntityRef), ), ).toEqual(legalEntityRef); @@ -211,12 +204,12 @@ it.effect('preserves Counterparty JSON round trips for timestamps, references, a }; expect( yield* Schema.encodeEffect(CounterpartyRolePeriodSchema)( - yield* Schema.decodeUnknownEffect(CounterpartyRolePeriodSchema)(roleWithoutEndProvenance), + yield* Schema.decodeEffect(CounterpartyRolePeriodSchema)(roleWithoutEndProvenance), ), ).toEqual(roleWithoutEndProvenance); expect( yield* Schema.encodeEffect(CounterpartyRolePeriodSchema)( - yield* Schema.decodeUnknownEffect(CounterpartyRolePeriodSchema)({ + yield* Schema.decodeEffect(CounterpartyRolePeriodSchema)({ ...roleWithoutEndProvenance, endProvenance: null, }), @@ -224,7 +217,7 @@ it.effect('preserves Counterparty JSON round trips for timestamps, references, a ).toEqual({ ...roleWithoutEndProvenance, endProvenance: null }); expect( yield* Schema.encodeEffect(CounterpartyAuditEvidenceSchema)( - yield* Schema.decodeUnknownEffect(CounterpartyAuditEvidenceSchema)({ + yield* Schema.decodeEffect(CounterpartyAuditEvidenceSchema)({ evidenceReference: null, provenanceMethod: provenance.method, provenanceReason: provenance.reason, @@ -239,7 +232,7 @@ it.effect('preserves Counterparty JSON round trips for timestamps, references, a }); expect( yield* Schema.encodeEffect(CounterpartyPartyProjectionSchema)( - yield* Schema.decodeUnknownEffect(CounterpartyPartyProjectionSchema)({ + yield* Schema.decodeEffect(CounterpartyPartyProjectionSchema)({ archived: false, canonicalPartyRef: partyRef, displayName: null, @@ -261,7 +254,7 @@ it.effect('accepts only CUSTOMER and SUPPLIER role periods with explicit evidenc Effect.gen(function* contractScenario5() { for (const roleType of ['CUSTOMER', 'SUPPLIER'] as const) { expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleAddPayloadSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleAddPayloadSchema)({ counterpartyRef, provenance, roleType, @@ -284,7 +277,7 @@ it.effect('accepts only CUSTOMER and SUPPLIER role periods with explicit evidenc for (const validFrom of ['2026-02-30T00:00:00.000Z', '2026-01-01T00:00:00Z']) { expect( yield* Effect.flip( - Schema.decodeUnknownEffect(CounterpartyRoleAddPayloadSchema)({ + Schema.decodeEffect(CounterpartyRoleAddPayloadSchema)({ counterpartyRef, provenance, roleType: 'CUSTOMER', @@ -294,7 +287,7 @@ it.effect('accepts only CUSTOMER and SUPPLIER role periods with explicit evidenc ).toBeDefined(); } expect( - yield* Schema.decodeUnknownEffect(CounterpartyRoleAddResultSchema)({ + yield* Schema.decodeEffect(CounterpartyRoleAddResultSchema)({ counterpartyRef, rolePeriodRef, roleType: 'CUSTOMER', @@ -309,7 +302,7 @@ it.effect('accepts only CUSTOMER and SUPPLIER role periods with explicit evidenc validTo: null, }); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleAddPayloadSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleAddPayloadSchema)({ counterpartyRef, provenance: { evidenceReference: provenance.evidenceReference, @@ -325,7 +318,7 @@ it.effect('accepts only CUSTOMER and SUPPLIER role periods with explicit evidenc it.effect('ends one named role period without deleting Counterparty history', () => Effect.gen(function* contractScenario6() { - const payload = yield* Schema.decodeUnknownEffect(CounterpartyRoleEndPayloadSchema)({ + const payload = yield* Schema.decodeEffect(CounterpartyRoleEndPayloadSchema)({ counterpartyRef, provenance, rolePeriodRef, @@ -338,7 +331,7 @@ it.effect('ends one named role period without deleting Counterparty history', () validTo: '2027-01-31T23:59:59.000Z', }); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleEndResultSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleEndResultSchema)({ counterpartyRef, rolePeriodRef, roleType: 'SUPPLIER', @@ -347,7 +340,7 @@ it.effect('ends one named role period without deleting Counterparty history', () })).validTo, ).toBe('2027-01-31T23:59:59.000Z'); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleEndPayloadSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleEndPayloadSchema)({ counterpartyRef, provenance: { evidenceReference: provenance.evidenceReference, @@ -363,7 +356,7 @@ it.effect('ends one named role period without deleting Counterparty history', () it.effect('publishes a minimum Party projection and keeps full role history separate', () => Effect.gen(function* contractScenario7() { - const request = yield* Schema.decodeUnknownEffect(CounterpartyReadRequestSchema)({ + const request = yield* Schema.decodeEffect(CounterpartyReadRequestSchema)({ counterpartyRef, }); expect(request).toEqual({ counterpartyRef }); @@ -376,7 +369,7 @@ it.effect('publishes a minimum Party projection and keeps full role history sepa validFrom: '2026-09-03T10:00:00.000Z', validTo: null, } as const; - const result = yield* Schema.decodeUnknownEffect(CounterpartyReadResponseSchema, { + const result = yield* Schema.decodeEffect(CounterpartyReadResponseSchema, { onExcessProperty: 'error', })({ counterpartyRef, @@ -393,21 +386,23 @@ it.effect('publishes a minimum Party projection and keeps full role history sepa }); expect(result.party.displayName).toBe('ACME s.r.o.'); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyReadResponseSchema)({ + (yield* Schema.decodeEffect(CounterpartyReadResponseSchema)({ ...result, party: { ...result.party, displayName: null }, })).party.displayName, ).toBe(null); expect(result.currentRoles.map(({ roleType }) => roleType)).toEqual(['CUSTOMER']); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyReadResponseSchema)({ + (yield* Schema.decodeEffect(CounterpartyReadResponseSchema)({ ...result, currentRoles: [], })).currentRoles, ).toEqual([]); expect( yield* Effect.flip( - Schema.decodeUnknownEffect(CounterpartyReadResponseSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownEffect(CounterpartyReadResponseSchema, { + onExcessProperty: 'error', + })({ ...result, party: { ...result.party, contactPoints: [] }, }), @@ -415,12 +410,20 @@ it.effect('publishes a minimum Party projection and keeps full role history sepa ).toBeDefined(); expect( - yield* Schema.decodeUnknownEffect(CounterpartyRoleHistoryRequestSchema)({ counterpartyRef }), + yield* Schema.decodeEffect(CounterpartyRoleHistoryRequestSchema)({ + counterpartyRef, + }), ).toEqual({ counterpartyRef }); expect( - (yield* Schema.decodeUnknownEffect(CounterpartyRoleHistoryResponseSchema)({ + (yield* Schema.decodeEffect(CounterpartyRoleHistoryResponseSchema)({ counterpartyRef, - roles: [{ ...currentRole, state: 'ENDED', validTo: '2027-01-31T23:59:59.000Z' }], + roles: [ + { + ...currentRole, + state: 'ENDED', + validTo: '2027-01-31T23:59:59.000Z', + }, + ], })).roles[0]?.state, ).toBe('ENDED'); expect(counterpartyReadRead.descriptor.permissionTarget).toBe('resource'); diff --git a/app/verticals/party-registry/tests/unit/counterparty-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/counterparty-persistence.service.test.ts index 13268de0f..681f686ef 100644 --- a/app/verticals/party-registry/tests/unit/counterparty-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/counterparty-persistence.service.test.ts @@ -1,9 +1,10 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; -import { DateTime, Effect, Predicate, Struct } from 'effect'; /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- This focused test harness models the narrow Drizzle native Effect query surface used by the owner-local service. expires: 2026-12-31. */ import type { Table } from 'drizzle-orm'; import { getTableName } from 'drizzle-orm'; +import { DateTime, Effect, Predicate, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + import { addCounterpartyRoleRecord, createCounterpartyRecord, @@ -80,19 +81,16 @@ const transactionHarness = ( const updateSets: Readonly>[] = []; const select = () => { const rows = selectQueue.shift() ?? []; - const chain = Object.assign( - Effect.sync(() => rows), - { - for: () => Effect.succeed(rows), - from: (table: Table) => { - selectedTables.push(getTableName(table)); - return chain; - }, - limit: () => chain, - orderBy: () => chain, - where: () => chain, + const chain = Object.assign(Effect.succeed(rows), { + for: () => Effect.succeed(rows), + from: (table: Table) => { + selectedTables.push(getTableName(table)); + return chain; }, - ); + limit: () => chain, + orderBy: () => chain, + where: () => chain, + }); return chain; }; const update = () => { @@ -123,10 +121,14 @@ const transactionHarness = ( return chain; }; // SAFETY: the harness implements precisely the select/update fluent methods exercised here. - const transaction = { insert, select, update } as unknown as Parameters< - typeof endCounterpartyRoleRecord - >[0]; - return { insertValues, insertedTables, selectedTables, transaction, updateSets }; + const transaction = { insert, select, update } as unknown as Parameters[0]; + return { + insertValues, + insertedTables, + selectedTables, + transaction, + updateSets, + }; }; const endInput = (validTo: string, method: string) => ({ @@ -147,9 +149,7 @@ const endInput = (validTo: string, method: string) => ({ it.effect('keeps a future-ended role active until its exclusive effective end', () => Effect.gen(function* testScenario1() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const futureEnd = '2099-01-01T00:00:00.000Z'; const updated = roleRow({ endEvidenceRefs: ['contract:end'], @@ -175,21 +175,15 @@ it.effect('keeps a future-ended role active until its exclusive effective end', expect(harness.updateSets[0]?.['state']).toBe('ACTIVE'); expect(harness.updateSets[0]?.['isCurrent']).toBe(true); expect(harness.updateSets[0]?.['endProvenanceSource']).toBe('contracts.core'); - expect(harness.updateSets[0]?.['endProvenanceMethod']).toBe( - 'CONFIRMED_CUSTOMER_RELATIONSHIP_END', - ); + expect(harness.updateSets[0]?.['endProvenanceMethod']).toBe('CONFIRMED_CUSTOMER_RELATIONSHIP_END'); expect(harness.insertValues.length).toBe(2); - expect(harness.insertValues[1]?.['endProvenanceMethod']).toBe( - 'CONFIRMED_CUSTOMER_RELATIONSHIP_END', - ); + expect(harness.insertValues[1]?.['endProvenanceMethod']).toBe('CONFIRMED_CUSTOMER_RELATIONSHIP_END'); }), ); it.effect('records a retrospective end as historical without deleting the role period', () => Effect.gen(function* testScenario2() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const pastEnd = '2021-01-01T00:00:00.000Z'; const updated = roleRow({ endEvidenceRefs: ['contract:end'], @@ -201,10 +195,7 @@ it.effect('records a retrospective end as historical without deleting the role p }); const harness = transactionHarness([[counterpartyRow], [roleRow()]], [[updated]]); - yield* endCounterpartyRoleRecord( - harness.transaction, - endInput(pastEnd, 'CONFIRMED_CUSTOMER_RELATIONSHIP_END'), - ); + yield* endCounterpartyRoleRecord(harness.transaction, endInput(pastEnd, 'CONFIRMED_CUSTOMER_RELATIONSHIP_END')); expect(harness.updateSets[0]?.['state']).toBe('ENDED'); expect(harness.updateSets[0]?.['isCurrent']).toBe(false); @@ -213,9 +204,7 @@ it.effect('records a retrospective end as historical without deleting the role p it.effect('rejects inactivity evidence before persisting a CUSTOMER end', () => Effect.gen(function* testScenario3() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const harness = transactionHarness([[counterpartyRow], [roleRow()]]); const result = yield* endCounterpartyRoleRecord( @@ -234,9 +223,7 @@ it.effect('rejects inactivity evidence before persisting a CUSTOMER end', () => it.effect('reuses an exactly repeated end without another write', () => Effect.gen(function* testScenario4() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const validTo = '2025-01-01T00:00:00.000Z'; const ended = roleRow({ endEvidenceRefs: ['contract:end'], @@ -264,9 +251,7 @@ it.effect('reuses an exactly repeated end without another write', () => it.effect('reads end provenance independently from the role-add provenance', () => Effect.gen(function* testScenario5() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const ended = roleRow({ endEvidenceRefs: ['contract:end'], endProvenanceMethod: 'CONFIRMED_CUSTOMER_RELATIONSHIP_END', @@ -277,12 +262,7 @@ it.effect('reads end provenance independently from the role-add provenance', () }); const harness = transactionHarness([[counterpartyRow], [ended]]); - const result = yield* listCounterpartyRoleHistory( - harness.transaction, - tenantId, - legalEntityId, - counterpartyId, - ); + const result = yield* listCounterpartyRoleHistory(harness.transaction, tenantId, legalEntityId, counterpartyId); expect(Predicate.isTagged(result, 'found')).toBe(true); if (!Predicate.isTagged(result, 'found')) { @@ -297,42 +277,25 @@ it.effect('reads end provenance independently from the role-add provenance', () }), ); -it.effect( - 'allows the authorized tenant-admin path to read history without payload Legal Entity data', - () => - Effect.gen(function* testScenario6() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); - const harness = transactionHarness([ - [{ ...counterpartyRow, storedPartyId: partyId }], - [roleRow()], - ]); - - const result = yield* listCounterpartyRoleHistory( - harness.transaction, - tenantId, - undefined, - counterpartyId, - ); - - expect(Predicate.isTagged(result, 'found')).toBe(true); - if (!Predicate.isTagged(result, 'found')) { - return yield* Effect.die(new Error('Unexpected result variant')); - } - expect(result.value[0]?.roleType).toBe('CUSTOMER'); - expect(harness.selectedTables).toEqual([ - 'counterparty_admin_read_models', - 'counterparty_role_admin_read_models', - ]); - }), +it.effect('allows the authorized tenant-admin path to read history without payload Legal Entity data', () => + Effect.gen(function* testScenario6() { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); + const harness = transactionHarness([[{ ...counterpartyRow, storedPartyId: partyId }], [roleRow()]]); + + const result = yield* listCounterpartyRoleHistory(harness.transaction, tenantId, undefined, counterpartyId); + + expect(Predicate.isTagged(result, 'found')).toBe(true); + if (!Predicate.isTagged(result, 'found')) { + return yield* Effect.die(new Error('Unexpected result variant')); + } + expect(result.value[0]?.roleType).toBe('CUSTOMER'); + expect(harness.selectedTables).toEqual(['counterparty_admin_read_models', 'counterparty_role_admin_read_models']); + }), ); it.effect('rejects an alias Party create target with canonical survivor guidance', () => Effect.gen(function* testScenario7() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const survivorId = '40000000-0000-4000-8000-000000000002'; const harness = transactionHarness([ [{ aliasPartyId: partyId, canonicalPartyId: survivorId, tenantId }], @@ -364,63 +327,48 @@ it.effect('rejects an alias Party create target with canonical survivor guidance }), ); -it.effect( - 'admin detail follows a complete Party alias chain while retaining the stored reference', - () => - Effect.gen(function* testScenario8() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); - const middleId = '40000000-0000-4000-8000-000000000002'; - const survivorId = '40000000-0000-4000-8000-000000000003'; - const harness = transactionHarness([ - [{ ...counterpartyRow, storedPartyId: partyId }], - [{ aliasPartyId: partyId, canonicalPartyId: middleId, tenantId }], - [{ aliasPartyId: middleId, canonicalPartyId: survivorId, tenantId }], - [], - [{ partyId: survivorId }], - [ - { - archivedAt: null, - currentDisplayName: 'Survivor', - currentType: 'ORGANIZATION', - partyId: survivorId, - tenantId, - }, - ], - [], - ]); - - const result = yield* findCounterpartyRecord( - harness.transaction, - tenantId, - undefined, - counterpartyId, - ); - - expect(Predicate.isTagged(result, 'found')).toBe(true); - if (!Predicate.isTagged(result, 'found')) { - return yield* Effect.die(new Error('Unexpected result variant')); - } - expect(result.value.party.storedPartyRef.resourceId).toBe(partyId); - expect(result.value.party.canonicalPartyRef.resourceId).toBe(survivorId); - expect(result.value.legalEntityRef.resourceId).toBe(legalEntityId); - expect(harness.selectedTables.includes('counterparties')).toBe(false); - expect(harness.selectedTables.includes('counterparty_role_periods')).toBe(false); - }), +it.effect('admin detail follows a complete Party alias chain while retaining the stored reference', () => + Effect.gen(function* testScenario8() { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); + const middleId = '40000000-0000-4000-8000-000000000002'; + const survivorId = '40000000-0000-4000-8000-000000000003'; + const harness = transactionHarness([ + [{ ...counterpartyRow, storedPartyId: partyId }], + [{ aliasPartyId: partyId, canonicalPartyId: middleId, tenantId }], + [{ aliasPartyId: middleId, canonicalPartyId: survivorId, tenantId }], + [], + [{ partyId: survivorId }], + [ + { + archivedAt: null, + currentDisplayName: 'Survivor', + currentType: 'ORGANIZATION', + partyId: survivorId, + tenantId, + }, + ], + [], + ]); + + const result = yield* findCounterpartyRecord(harness.transaction, tenantId, undefined, counterpartyId); + + expect(Predicate.isTagged(result, 'found')).toBe(true); + if (!Predicate.isTagged(result, 'found')) { + return yield* Effect.die(new Error('Unexpected result variant')); + } + expect(result.value.party.storedPartyRef.resourceId).toBe(partyId); + expect(result.value.party.canonicalPartyRef.resourceId).toBe(survivorId); + expect(result.value.legalEntityRef.resourceId).toBe(legalEntityId); + expect(harness.selectedTables.includes('counterparties')).toBe(false); + expect(harness.selectedTables.includes('counterparty_role_periods')).toBe(false); + }), ); it.effect('creates the tenant-admin snapshot atomically without creating an implicit role', () => Effect.gen(function* testScenario9() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const party = { archivedAt: null, partyId, tenantId }; - const harness = transactionHarness( - [[], [{ partyId }], [], [{ partyId }], [party]], - [], - [[counterpartyRow]], - ); + const harness = transactionHarness([[], [{ partyId }], [], [{ partyId }], [party]], [], [[counterpartyRow]]); const result = yield* createCounterpartyRecord(harness.transaction, { actionInvocationId, @@ -448,11 +396,12 @@ it.effect('creates the tenant-admin snapshot atomically without creating an impl it.effect('adds a future role and its admin history projection in the same transaction seam', () => Effect.gen(function* testScenario10() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z'))); const futureStart = '2099-01-01T00:00:00.000Z'; - const futureRole = roleRow({ isCurrent: false, validFrom: date(futureStart) }); + const futureRole = roleRow({ + isCurrent: false, + validFrom: date(futureStart), + }); const harness = transactionHarness( [[counterpartyRow], [], [{ partyId }], [{ archivedAt: null, partyId, tenantId }], []], [], diff --git a/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts b/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts index c1fac147e..a522d4de3 100644 --- a/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts +++ b/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts @@ -1,6 +1,7 @@ import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; import { assert, it } from 'effect-rstest'; + import { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { resolveCounterpartyRead } from '../../src/api/counterparty-read-support.ts'; diff --git a/app/verticals/party-registry/tests/unit/counterparty-role-lifecycle.test.ts b/app/verticals/party-registry/tests/unit/counterparty-role-lifecycle.test.ts index cfc552631..c2adc29f0 100644 --- a/app/verticals/party-registry/tests/unit/counterparty-role-lifecycle.test.ts +++ b/app/verticals/party-registry/tests/unit/counterparty-role-lifecycle.test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'effect-rstest'; + import { counterpartyContextEvidenceIsSufficient, roleEvidenceIsSufficient, @@ -54,17 +55,12 @@ it('derives current role state from lifecycle and effective time', () => { '2026-06-01T00:00:00.000Z', ), ).toBe(false); - expect(rolePeriodIsCurrentAt({ ...active, state: 'ENDED' }, '2026-06-01T00:00:00.000Z')).toBe( - false, - ); + expect(rolePeriodIsCurrentAt({ ...active, state: 'ENDED' }, '2026-06-01T00:00:00.000Z')).toBe(false); }); it('stores future, current, future-ended, and historical periods by their interval', () => { expect( - rolePeriodStorageStateAt( - { validFrom: '2027-01-01T00:00:00.000Z', validTo: null }, - '2026-06-01T00:00:00.000Z', - ), + rolePeriodStorageStateAt({ validFrom: '2027-01-01T00:00:00.000Z', validTo: null }, '2026-06-01T00:00:00.000Z'), ).toEqual({ isCurrent: false, state: 'ACTIVE' }); expect( rolePeriodStorageStateAt( diff --git a/app/verticals/party-registry/tests/unit/database-client.test.ts b/app/verticals/party-registry/tests/unit/database-client.test.ts index cf35cf86b..71006813b 100644 --- a/app/verticals/party-registry/tests/unit/database-client.test.ts +++ b/app/verticals/party-registry/tests/unit/database-client.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { acquirePoolResource, makePartyDatabase } from '../../src/db/client.ts'; it.effect('finalizes the Party Registry pool when its Effect scope closes', () => diff --git a/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts b/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts index 1edc58fce..9613f4b84 100644 --- a/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts @@ -1,8 +1,6 @@ import { assert, expect, it } from 'effect-rstest'; -import { - compareContactsCatalog, - expectedContactsTableCatalog, -} from '../../src/db/engagement-catalog.ts'; + +import { compareContactsCatalog, expectedContactsTableCatalog } from '../../src/db/engagement-catalog.ts'; it('reports exact Contacts table catalog differences', () => { expect(expectedContactsTableCatalog).toEqual([ @@ -18,11 +16,7 @@ it('reports exact Contacts table catalog differences', () => { it('keeps Contacts inventory separate while rejecting duplicate unknown tables once', () => { assert.deepEqual( - compareContactsCatalog([ - ...expectedContactsTableCatalog, - 'party.counterparties', - 'party.counterparties', - ]), + compareContactsCatalog([...expectedContactsTableCatalog, 'party.counterparties', 'party.counterparties']), { missing: [], unexpected: ['party.counterparties'] }, ); assert.deepEqual(compareContactsCatalog([]), { diff --git a/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts b/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts index f75baad79..624de3bd2 100644 --- a/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts @@ -1,27 +1,31 @@ -import { assert, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { assert, it } from 'effect-rstest'; + import { EngagementProfilePersistenceUnavailable } from '../../shared/domain/engagement-profile.ts'; import { handleEngagementLifecycle } from '../../src/actions/engagement-lifecycle-handler.ts'; -const payload = { profileRef: { resourceId: '10000000-0000-4000-8000-000000000001' } }; +const payload = { + profileRef: { resourceId: '10000000-0000-4000-8000-000000000001' }, +}; for (const state of ['active', 'archived'] as const) { const handle = handleEngagementLifecycle(state); - it.effect( - `engagement transition to ${state} forwards the reference and returns the persisted value`, - () => - Effect.gen(function* verifyTransition() { - const value = yield* handle(payload, { - services: { - transition: (profileId) => { - assert.equal(profileId, payload.profileRef.resourceId); - return Effect.succeed({ _tag: 'found', value: 'persisted-profile' }); - }, + it.effect(`engagement transition to ${state} forwards the reference and returns the persisted value`, () => + Effect.gen(function* verifyTransition() { + const value = yield* handle(payload, { + services: { + transition: (profileId) => { + assert.equal(profileId, payload.profileRef.resourceId); + return Effect.succeed({ + _tag: 'found', + value: 'persisted-profile', + }); }, - }); - assert.equal(value, 'persisted-profile'); - }), + }, + }); + assert.equal(value, 'persisted-profile'); + }), ); it.effect(`engagement conflict reports the requested ${state} state`, () => @@ -30,9 +34,7 @@ for (const state of ['active', 'archived'] as const) { services: { transition: () => Effect.succeed({ _tag: 'conflict', value: 'existing-profile' }), }, - }).pipe( - Effect.catchTag('EngagementProfileConflict', (error) => Effect.succeed(error.reason)), - ); + }).pipe(Effect.catchTag('EngagementProfileConflict', (error) => Effect.succeed(error.reason))); assert.equal(reason, `The engagement profile is already ${state}`); }), ); @@ -41,9 +43,7 @@ for (const state of ['active', 'archived'] as const) { Effect.gen(function* verifyMissing() { const profileId = yield* handle(payload, { services: { transition: () => Effect.succeed({ _tag: 'not_found' }) }, - }).pipe( - Effect.catchTag('EngagementProfileNotFound', (error) => Effect.succeed(error.profileId)), - ); + }).pipe(Effect.catchTag('EngagementProfileNotFound', (error) => Effect.succeed(error.profileId))); assert.equal(profileId, payload.profileRef.resourceId); }), ); @@ -56,11 +56,7 @@ for (const state of ['active', 'archived'] as const) { }); const result = yield* handle(payload, { services: { transition: () => Effect.fail(failure) }, - }).pipe( - Effect.catchTag('EngagementProfilePersistenceUnavailable', (error) => - Effect.succeed(error), - ), - ); + }).pipe(Effect.catchTag('EngagementProfilePersistenceUnavailable', (error) => Effect.succeed(error))); assert.equal(result, failure); }), ); diff --git a/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts b/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts index 7bd73d239..291e93b6b 100644 --- a/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts @@ -1,4 +1,5 @@ import { assert, it } from 'effect-rstest'; + import { OrganizationEngagementLifecyclePayloadSchema, OrganizationEngagementProfileSchema, diff --git a/app/verticals/party-registry/tests/unit/engagement-profile-api-contract.test.ts b/app/verticals/party-registry/tests/unit/engagement-profile-api-contract.test.ts index dcf8c2f5a..77a8d4cb7 100644 --- a/app/verticals/party-registry/tests/unit/engagement-profile-api-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-profile-api-contract.test.ts @@ -1,8 +1,10 @@ -import { expect, it } from 'effect-rstest'; // @effect-diagnostics nodeBuiltinImport:off -- Source-contract test reads actual module files; expires: 2026-12-31. import { readFile } from 'node:fs/promises'; + import { Effect, Option, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; import { FetchHttpClient } from 'effect/unstable/http'; + import { AttachOrganizationEngagementPayloadSchema, AttachPersonEngagementPayloadSchema, @@ -26,9 +28,7 @@ const counterpartyRef = { } as const; it('publishes engagement operations from the Party Registry API boundary', () => { - expect(partyRegistryApiContract.readinessPath).toBe( - '/party-registry-api/party-registry/readiness', - ); + expect(partyRegistryApiContract.readinessPath).toBe('/party-registry-api/party-registry/readiness'); expect( Object.values(engagementProfileOperationContexts) .map(({ routePath }) => routePath) @@ -48,22 +48,19 @@ it('publishes engagement operations from the Party Registry API boundary', () => it.effect('attach contracts accept only public Party Registry refs', () => Effect.gen(function* decodeContracts() { const payload = { counterpartyRef, partyRef }; - expect( - yield* Schema.decodeUnknownEffect(AttachOrganizationEngagementPayloadSchema)(payload), - ).toEqual(payload); - expect(yield* Schema.decodeUnknownEffect(AttachPersonEngagementPayloadSchema)(payload)).toEqual( - payload, - ); + expect(yield* Schema.decodeEffect(AttachOrganizationEngagementPayloadSchema)(payload)).toEqual(payload); + expect(yield* Schema.decodeEffect(AttachPersonEngagementPayloadSchema)(payload)).toEqual(payload); - for (const schema of [ - AttachOrganizationEngagementPayloadSchema, - AttachPersonEngagementPayloadSchema, - ] as const) { + for (const schema of [AttachOrganizationEngagementPayloadSchema, AttachPersonEngagementPayloadSchema] as const) { expect( - yield* Schema.decodeUnknownEffect(schema, { onExcessProperty: 'error' })({ partyRef }), + yield* Schema.decodeEffect(schema, { + onExcessProperty: 'error', + })({ partyRef }), ).toEqual({ partyRef }); expect( - yield* Schema.decodeUnknownEffect(schema, { onExcessProperty: 'error' })({ + yield* Schema.decodeUnknownEffect(schema, { + onExcessProperty: 'error', + })({ ...payload, customerId: 'd4000000-0000-4000-8000-000000000001', }).pipe(Effect.isFailure), @@ -119,9 +116,7 @@ it.effect('public engagement mutations preserve owner request context at the HTT const gatewayRequest = requests.find(({ url }) => url.endsWith('/auth/gateway-context')); expect(gatewayRequest).toBeDefined(); expect(mutationRequest).toBeDefined(); - const gatewayPayload = yield* Effect.promise(() => - Option.getOrThrow(Option.fromNullishOr(gatewayRequest)).json(), - ); + const gatewayPayload = yield* Effect.promise(() => Option.getOrThrow(Option.fromNullishOr(gatewayRequest)).json()); expect(gatewayPayload).toEqual({ audience: 'party-registry' }); expect(mutationRequest?.headers.get('authorization')).toBe('Bearer test-gateway-token'); expect(mutationRequest?.headers.get('accept-language')).toBe('cs'); @@ -132,7 +127,7 @@ it.effect('public engagement mutations preserve owner request context at the HTT engagementProfileOperationContexts.attachOrganizationEngagement.operationId, ); expect( - yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( mutationRequest?.headers.get('x-modernjs-bff-operation-context') ?? '', ), ).toEqual(engagementProfileOperationContexts.attachOrganizationEngagement); @@ -142,21 +137,13 @@ it.effect('public engagement mutations preserve owner request context at the HTT it.effect('public Party Registry engagement API does not expose legacy identity operations', () => Effect.gen(function* verifyCase4() { const [apiSource, clientSource] = yield* Effect.all([ - Effect.promise(() => - readFile(new URL('../../shared/engagement-profile-api.ts', import.meta.url), 'utf-8'), - ), - Effect.promise(() => - readFile(new URL('../../src/api/engagement-profile-client.ts', import.meta.url), 'utf-8'), - ), + Effect.promise(() => readFile(new URL('../../shared/engagement-profile-api.ts', import.meta.url), 'utf-8')), + Effect.promise(() => readFile(new URL('../../src/api/engagement-profile-client.ts', import.meta.url), 'utf-8')), ]); for (const source of [apiSource, clientSource]) { - expect(source).not.toMatch( - /\b(?:createCustomer|editCustomer|archiveCustomer|unarchiveCustomer)\b/u, - ); - expect(source).not.toMatch( - /\b(?:createContact|editContact|archiveContact|unarchiveContact)\b/u, - ); + expect(source).not.toMatch(/\b(?:createCustomer|editCustomer|archiveCustomer|unarchiveCustomer)\b/u); + expect(source).not.toMatch(/\b(?:createContact|editContact|archiveContact|unarchiveContact)\b/u); expect(source).not.toMatch(/CustomerAresLookup|customerId|contactId/u); } }), diff --git a/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts b/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts index 5eed5a832..1ce882903 100644 --- a/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts @@ -1,12 +1,10 @@ import type { SQL } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; -import { - organizationEngagementProfiles, - personEngagementProfiles, -} from '../../src/db/engagement-schema.ts'; -import { assert, expect, it } from 'effect-rstest'; /* eslint-disable anti-slop/no-chained-type-assertions -- Focused harness implements only the mutation insert's Drizzle seam. expires: 2026-12-31. */ import { DateTime, Effect, Match, Predicate } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; + +import { organizationEngagementProfiles, personEngagementProfiles } from '../../src/db/engagement-schema.ts'; import type { OrganizationEngagementProfileRecord } from '../../src/db/engagement-schema.ts'; import { createOrganizationEngagementProfile, @@ -60,8 +58,10 @@ it('reconstructs typed references from the owner-local persistence record', () = expect('name' in result).toBe(false); expect('ico' in result).toBe(false); expect( - organizationEngagementProfileFromRecord({ ...row, counterpartyResourceId: null }) - .counterpartyRef, + organizationEngagementProfileFromRecord({ + ...row, + counterpartyResourceId: null, + }).counterpartyRef, ).toBe(null); }); @@ -70,7 +70,10 @@ it.effect('fails closed when a caller-supplied ref crosses the trusted tenant', const failure = yield* Effect.flip( ensureReferencesBelongToTenant(tenantId, { ...refs, - partyRef: { ...refs.partyRef, tenantId: 'c9000000-0000-4000-8000-000000000001' }, + partyRef: { + ...refs.partyRef, + tenantId: 'c9000000-0000-4000-8000-000000000001', + }, }), ); expect(Predicate.isTagged(failure, 'EngagementProfileConflict')).toBe(true); @@ -96,9 +99,7 @@ it.effect('maps a wrapped owner uniqueness constraint to the declared engagement expect(Predicate.isTagged(failure, 'EngagementProfileConflict')).toBe(true); expect(failure.code).toBe('contacts_engagement_profile_already_exists'); - expect(failure.reason).toBe( - 'An engagement profile already exists for these canonical references', - ); + expect(failure.reason).toBe('An engagement profile already exists for these canonical references'); }), ); @@ -136,9 +137,7 @@ it.effect('maps an unrelated uniqueness constraint to the existing persistence f expect(Predicate.isTagged(failure, 'EngagementProfilePersistenceUnavailable')).toBe(true); expect(failure.code).toBe('contacts_engagement_profile_persistence_unavailable'); - expect(failure.reason).toBe( - 'Contacts engagement profile persistence is temporarily unavailable', - ); + expect(failure.reason).toBe('Contacts engagement profile persistence is temporarily unavailable'); }), ); @@ -160,126 +159,103 @@ const profileKinds = [ ] as const; for (const kind of profileKinds) { - it.effect( - `${kind.resourceType} binds creation, lookup and lifecycle to its own tenant-qualified table`, - () => - Effect.gen(function* verifyProfilePersistence() { - let current: OrganizationEngagementProfileRecord | undefined = row; - let writes = 0; - let locks = 0; - const rows = () => (current === undefined ? [] : [current]); - const where = (predicate: SQL) => { - assert.deepEqual(new PgDialect().sqlToQuery(predicate).params, [ - tenantId, - row.engagementProfileId, - ]); - }; - // SAFETY: This focused double implements the factory's insert/select/update query chains. - const transaction = { - insert: (table: typeof kind.table) => { + it.effect(`${kind.resourceType} binds creation, lookup and lifecycle to its own tenant-qualified table`, () => + Effect.gen(function* verifyProfilePersistence() { + let current: OrganizationEngagementProfileRecord | undefined = row; + let writes = 0; + let locks = 0; + const rows = () => (current === undefined ? [] : [current]); + const where = (predicate: SQL) => { + assert.deepEqual(new PgDialect().sqlToQuery(predicate).params, [tenantId, row.engagementProfileId]); + }; + // SAFETY: This focused double implements the factory's insert/select/update query chains. + const transaction = { + insert: (table: typeof kind.table) => { + assert.equal(table, kind.table); + return { + values: (values: typeof organizationEngagementProfiles.$inferInsert) => { + assert.deepEqual(values, { + counterpartyResourceId: refs.counterpartyRef.resourceId, + partyResourceId: refs.partyRef.resourceId, + tenantId, + }); + return { returning: () => Effect.succeed(rows()) }; + }, + }; + }, + select: () => ({ + from: (table: typeof kind.table) => { assert.equal(table, kind.table); return { - values: (values: typeof organizationEngagementProfiles.$inferInsert) => { - assert.deepEqual(values, { - counterpartyResourceId: refs.counterpartyRef.resourceId, - partyResourceId: refs.partyRef.resourceId, - tenantId, - }); - return { returning: () => Effect.succeed(rows()) }; + where: (predicate: SQL) => { + where(predicate); + return { + limit: () => + Object.assign(Effect.succeed(rows()), { + for: (mode: string) => { + assert.equal(mode, 'update'); + locks += 1; + return Effect.succeed(rows()); + }, + }), + }; }, }; }, - select: () => ({ - from: (table: typeof kind.table) => { - assert.equal(table, kind.table); + }), + update: (table: typeof kind.table) => { + assert.equal(table, kind.table); + return { + set: (values: Pick) => { + writes += 1; + current = { ...row, ...values }; return { where: (predicate: SQL) => { where(predicate); - return { - limit: () => - Object.assign(Effect.succeed(rows()), { - for: (mode: string) => { - assert.equal(mode, 'update'); - locks += 1; - return Effect.succeed(rows()); - }, - }), - }; + return { returning: () => Effect.succeed(rows()) }; }, }; }, - }), - update: (table: typeof kind.table) => { - assert.equal(table, kind.table); - return { - set: ( - values: Pick, - ) => { - writes += 1; - current = { ...row, ...values }; - return { - where: (predicate: SQL) => { - where(predicate); - return { returning: () => Effect.succeed(rows()) }; - }, - }; - }, - }; - }, - } as unknown as Parameters[0]; - const created = yield* kind.create(transaction, { ...refs, tenantId }); - assert.equal(created.profileRef.resourceType, kind.resourceType); - const found = yield* kind.find(transaction, tenantId, row.engagementProfileId); - assert.deepEqual( - Match.value(found).pipe( - Match.tag('found', ({ value }) => value), - Match.orElse(() => expect.unreachable('Expected found profile')), - ), - created, - ); - const conflict = yield* kind.transition( - transaction, - tenantId, - row.engagementProfileId, - 'active', - ); - assert.deepEqual( - Match.value(conflict).pipe( - Match.tag('conflict', ({ value }) => value), - Match.orElse(() => expect.unreachable('Expected conflicting profile')), - ), - created, - ); - assert.equal(writes, 0); - const archived = yield* kind.transition( - transaction, - tenantId, - row.engagementProfileId, - 'archived', - ); - assert.deepEqual( - archived, - yield* kind.find(transaction, tenantId, row.engagementProfileId), - ); - assert.notEqual(current?.archivedAt, null); - yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'active'); - assert.equal(current?.archivedAt, null); - assert.equal(writes, 2); - current = undefined; - expect( - Predicate.isTagged( - yield* kind.find(transaction, tenantId, row.engagementProfileId), - 'not_found', - ), - ).toBe(true); - expect( - Predicate.isTagged( - yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'archived'), - 'not_found', - ), - ).toBe(true); - assert.equal(writes, 2); - assert.equal(locks, 4); - }), + }; + }, + } as unknown as Parameters[0]; + const created = yield* kind.create(transaction, { ...refs, tenantId }); + assert.equal(created.profileRef.resourceType, kind.resourceType); + const found = yield* kind.find(transaction, tenantId, row.engagementProfileId); + assert.deepEqual( + Match.value(found).pipe( + Match.tag('found', ({ value }) => value), + Match.orElse(() => expect.unreachable('Expected found profile')), + ), + created, + ); + const conflict = yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'active'); + assert.deepEqual( + Match.value(conflict).pipe( + Match.tag('conflict', ({ value }) => value), + Match.orElse(() => expect.unreachable('Expected conflicting profile')), + ), + created, + ); + assert.equal(writes, 0); + const archived = yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'archived'); + assert.deepEqual(archived, yield* kind.find(transaction, tenantId, row.engagementProfileId)); + assert.notEqual(current?.archivedAt, null); + yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'active'); + assert.equal(current?.archivedAt, null); + assert.equal(writes, 2); + current = undefined; + expect(Predicate.isTagged(yield* kind.find(transaction, tenantId, row.engagementProfileId), 'not_found')).toBe( + true, + ); + expect( + Predicate.isTagged( + yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'archived'), + 'not_found', + ), + ).toBe(true); + assert.equal(writes, 2); + assert.equal(locks, 4); + }), ); } diff --git a/app/verticals/party-registry/tests/unit/engagement-reference-validation.test.ts b/app/verticals/party-registry/tests/unit/engagement-reference-validation.test.ts index a35807d88..ec3331dad 100644 --- a/app/verticals/party-registry/tests/unit/engagement-reference-validation.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-reference-validation.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; + import type { PartyRef } from '../../shared/party-registry-references.ts'; import { validatePartyRegistryReferences } from '../../src/services/engagement-reference-validation.service.ts'; import type { PartyRegistryReferenceOperations } from '../../src/services/engagement-reference-validation.service.ts'; diff --git a/app/verticals/party-registry/tests/unit/engagement-schema-contract.test.ts b/app/verticals/party-registry/tests/unit/engagement-schema-contract.test.ts index 0f33a27cc..fd805ab45 100644 --- a/app/verticals/party-registry/tests/unit/engagement-schema-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-schema-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { getTableConfig } from 'drizzle-orm/pg-core'; +import { expect, it } from 'effect-rstest'; + import { CONTACTS_SCHEMA_NAME, CONTACTS_TABLE_INVENTORY, @@ -20,10 +21,7 @@ it('owns two engagement profile tables plus gateway replay protection', () => { 'organization_engagement_profiles', 'person_engagement_profiles', ]); - expect(qualifiedNames).toEqual([ - 'contacts.organization_engagement_profiles', - 'contacts.person_engagement_profiles', - ]); + expect(qualifiedNames).toEqual(['contacts.organization_engagement_profiles', 'contacts.person_engagement_profiles']); }); it('stores references and profile lifecycle, never Party identity facts', () => { @@ -41,18 +39,10 @@ it('stores references and profile lifecycle, never Party identity facts', () => for (const forbidden of ['customer_id', 'contact_id', 'name', 'ico', 'dic', 'email', 'phone']) { expect(config.columns.some((column) => column.name === forbidden)).toBe(false); } - for (const required of [ - 'engagement_profile_id', - 'tenant_id', - 'party_resource_id', - 'created_at', - 'updated_at', - ]) { + for (const required of ['engagement_profile_id', 'tenant_id', 'party_resource_id', 'created_at', 'updated_at']) { expect(config.columns.find((column) => column.name === required)?.notNull).toBe(true); } - expect( - config.columns.find((column) => column.name === 'counterparty_resource_id')?.notNull, - ).toBe(false); + expect(config.columns.find((column) => column.name === 'counterparty_resource_id')?.notNull).toBe(false); } }); @@ -68,12 +58,7 @@ it('forces tenant RLS with complete CRUD policies on both profile tables', () => `${prefix}_update`, `${prefix}_delete`, ]); - expect(config.policies.map((policy) => policy.for)).toEqual([ - 'select', - 'insert', - 'update', - 'delete', - ]); + expect(config.policies.map((policy) => policy.for)).toEqual(['select', 'insert', 'update', 'delete']); for (const policy of config.policies) { expect(policy.to).toBe('ontos_runtime'); } diff --git a/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts b/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts index 59ab59855..986685711 100644 --- a/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts +++ b/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts @@ -1,16 +1,14 @@ -import { assert, expect, it } from 'effect-rstest'; -import { - GatewayAssertionRedemptionUnavailableError, - GatewayAssertionReplayError, -} from '@app/core-runtime'; +import { GatewayAssertionRedemptionUnavailableError, GatewayAssertionReplayError } from '@app/core-runtime'; import { GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS } from '@app/shared-contracts'; import { PgClient } from '@effect/sql-pg'; import { makeWithDefaults } from 'drizzle-orm/effect-postgres'; import { Cause, Clock, Effect, Exit, Schema } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; import { TestClock } from 'effect/testing'; import { Reactivity } from 'effect/unstable/reactivity'; -import { testSqlConnection } from '../../../../packages/core-runtime/tests/support/sql-connection.ts'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; + +import { testSqlConnection } from '../../../../packages/core-runtime/tests/support/sql-connection.ts'; import { makeGatewayAssertionRedemption } from '../../src/auth/gateway-assertion-redemption-runtime.ts'; import { partyRelations } from '../../src/db/schema.ts'; @@ -20,8 +18,7 @@ const assertion = { issuer: 'https://shell.ontos.test', jti: '60000000-0000-4000-8000-000000000001', }; -const expiryWithSkewMs = - (assertion.expiresAtEpochSeconds + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS) * 1000; +const expiryWithSkewMs = (assertion.expiresAtEpochSeconds + GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS) * 1000; // Native Drizzle and SqlClient own transaction settlement; only the wire connection is replaced. const makeRedemptionFixture = ( @@ -36,9 +33,9 @@ const makeRedemptionFixture = ( listenAcquirer: Effect.die('The fixture does not support notifications'), transactionAcquirer: Effect.succeed(connection), }).pipe(Effect.provideService(Reactivity.Reactivity, reactivity)); - const executor = yield* makeWithDefaults({ relations: partyRelations }).pipe( - Effect.provideService(PgClient.PgClient, client), - ); + const executor = yield* makeWithDefaults({ + relations: partyRelations, + }).pipe(Effect.provideService(PgClient.PgClient, client)); const clock = yield* TestClock.make(); yield* clock.setTime(expiryWithSkewMs - 1); return { clock, redemption: makeGatewayAssertionRedemption(executor) }; @@ -66,9 +63,7 @@ for (const settlement of ['COMMIT', 'ROLLBACK']) { .consume(assertion) .pipe(Effect.provideService(Clock.Clock, fixture.clock), Effect.flip); expect(Schema.is(GatewayAssertionRedemptionUnavailableError)(failure)).toBe(true); - const serializedFailure = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( - failure, - ); + const serializedFailure = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(failure); expect(serializedFailure).not.toMatch(/private fixture/u); expect(statements.includes(settlement)).toBe(true); }), @@ -79,43 +74,33 @@ it.effect('preserves unrelated transaction defects', () => Effect.gen(function* preserveProgrammingDefect() { const defect = new Error('fixture programming defect'); const fixture = yield* makeRedemptionFixture((sql) => - sql === 'COMMIT' - ? Effect.die(defect) - : Effect.succeed(sql.startsWith('insert') ? [{ jti: assertion.jti }] : []), + sql === 'COMMIT' ? Effect.die(defect) : Effect.succeed(sql.startsWith('insert') ? [{ jti: assertion.jti }] : []), ); const exit = yield* fixture.redemption .consume(assertion) .pipe(Effect.provideService(Clock.Clock, fixture.clock), Effect.exit); assert.isOk(Exit.isFailure(exit)); - expect( - exit.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === defect), - ).toBe(true); + expect(exit.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === defect)).toBe(true); }), ); -it.effect( - 'rejects assertions crossing expiry before redemption without deleting replay evidence', - () => - Effect.gen(function* preserveExpiredReplayEvidence() { - const statements: string[] = []; - const fixture = yield* makeRedemptionFixture((sql) => { - statements.push(sql); - return Effect.succeed(sql.startsWith('insert') ? [{ jti: assertion.jti }] : []); - }); - yield* fixture.redemption +it.effect('rejects assertions crossing expiry before redemption without deleting replay evidence', () => + Effect.gen(function* preserveExpiredReplayEvidence() { + const statements: string[] = []; + const fixture = yield* makeRedemptionFixture((sql) => { + statements.push(sql); + return Effect.succeed(sql.startsWith('insert') ? [{ jti: assertion.jti }] : []); + }); + yield* fixture.redemption.consume(assertion).pipe(Effect.provideService(Clock.Clock, fixture.clock)); + expect(statements.includes('COMMIT'), 'the last millisecond of skew remains usable').toBe(true); + statements.length = 0; + for (const now of [expiryWithSkewMs, expiryWithSkewMs + 1]) { + yield* fixture.clock.setTime(now); + const failure = yield* fixture.redemption .consume(assertion) - .pipe(Effect.provideService(Clock.Clock, fixture.clock)); - expect(statements.includes('COMMIT'), 'the last millisecond of skew remains usable').toBe( - true, - ); - statements.length = 0; - for (const now of [expiryWithSkewMs, expiryWithSkewMs + 1]) { - yield* fixture.clock.setTime(now); - const failure = yield* fixture.redemption - .consume(assertion) - .pipe(Effect.provideService(Clock.Clock, fixture.clock), Effect.flip); - expect(Schema.is(GatewayAssertionReplayError)(failure)).toBe(true); - } - expect(statements, 'expired assertions cannot run replay-evidence cleanup').toEqual([]); - }), + .pipe(Effect.provideService(Clock.Clock, fixture.clock), Effect.flip); + expect(Schema.is(GatewayAssertionReplayError)(failure)).toBe(true); + } + expect(statements, 'expired assertions cannot run replay-evidence cleanup').toEqual([]); + }), ); diff --git a/app/verticals/party-registry/tests/unit/identifier-contract.test.ts b/app/verticals/party-registry/tests/unit/identifier-contract.test.ts index f5008dc59..933c82b51 100644 --- a/app/verticals/party-registry/tests/unit/identifier-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/identifier-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { OfficialIdentifierInputSchema, normalizeOfficialIdentifier, @@ -16,7 +17,7 @@ import { const decode = Schema.decodeUnknownSync; it('Official Identifier V1 accepts only IČO and Czech DIČ', () => { - const ico = decode(OfficialIdentifierInputSchema)({ + const ico = Schema.decodeSync(OfficialIdentifierInputSchema)({ identifierType: 'ICO', value: '27074358', verification: 'VERIFIED', @@ -24,14 +25,14 @@ it('Official Identifier V1 accepts only IČO and Czech DIČ', () => { expect(normalizeOfficialIdentifier(ico).normalizedValue).toBe('27074358'); expect(normalizeOfficialIdentifier(ico).namespace).toBe('CZ:ICO'); - const legacyShortIco = decode(OfficialIdentifierInputSchema)({ + const legacyShortIco = Schema.decodeSync(OfficialIdentifierInputSchema)({ identifierType: 'ICO', value: '1000004', verification: 'VERIFIED', }); expect(normalizeOfficialIdentifier(legacyShortIco).normalizedValue).toBe('01000004'); - const dic = decode(OfficialIdentifierInputSchema)({ + const dic = Schema.decodeSync(OfficialIdentifierInputSchema)({ identifierType: 'CZ_DIC', value: 'cz27074358', verification: 'VERIFIED', @@ -49,7 +50,7 @@ it('Official Identifier V1 accepts only IČO and Czech DIČ', () => { ).toThrow(); expect(() => - decode(OfficialIdentifierInputSchema)({ + Schema.decodeSync(OfficialIdentifierInputSchema)({ identifierType: 'ICO', value: '270 74 358', verification: 'VERIFIED', @@ -150,11 +151,9 @@ it('Identifier Update is a closed evidence-backed metadata or validity command, officialIdentifierRef: identifierRef, reason: 'Registry confirmed the existing identifier', }; - expect(decode(UpdatePartyOfficialIdentifierPayloadSchema)(command).change.type).toBe( - 'SET_VERIFICATION', - ); + expect(decode(UpdatePartyOfficialIdentifierPayloadSchema)(command).change.type).toBe('SET_VERIFICATION'); expect( - decode(UpdatePartyOfficialIdentifierPayloadSchema)({ + Schema.decodeSync(UpdatePartyOfficialIdentifierPayloadSchema)({ ...command, change: { type: 'END_VALIDITY', validTo: '2026-01-01T00:00:00.000Z' }, }).change.type, @@ -168,7 +167,10 @@ it('Identifier Update is a closed evidence-backed metadata or validity command, ).toThrow(); } expect(() => - decode(UpdatePartyOfficialIdentifierPayloadSchema)({ ...command, evidenceRefs: [] }), + decode(UpdatePartyOfficialIdentifierPayloadSchema)({ + ...command, + evidenceRefs: [], + }), ).toThrow(); expect(() => decode(UpdatePartyOfficialIdentifierPayloadSchema)({ diff --git a/app/verticals/party-registry/tests/unit/identifier-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/identifier-persistence.service.test.ts index 7fa3a1bc6..5c594132b 100644 --- a/app/verticals/party-registry/tests/unit/identifier-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/identifier-persistence.service.test.ts @@ -1,8 +1,9 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; -import { DateTime, Effect, Match, Schema, Predicate } from 'effect'; /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- Focused harness implements only the owner service's Drizzle seam. expires: 2026-12-31. */ import type { SQL } from 'drizzle-orm'; +import { DateTime, Effect, Match, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; import type { partyAliases } from '../../src/db/schema.ts'; import { parties, partyIdentifierClaims, partyOfficialIdentifiers } from '../../src/db/schema.ts'; @@ -125,10 +126,9 @@ const harness = ( const insert = (table: HarnessTable) => ({ values: (values: Readonly>) => { inserts.push({ table, values }); - return Object.assign( - Effect.sync(() => null), - { returning: () => Effect.succeed([{ ...current, ...values }]) }, - ); + return Object.assign(Effect.succeed(null), { + returning: () => Effect.succeed([{ ...current, ...values }]), + }); }, }); // SAFETY: this harness implements precisely the Drizzle fluent operations used by the tested service. @@ -143,91 +143,89 @@ const harness = ( select, update, } as unknown as Parameters[0]; - return { deleted: () => deletes, inserts, lockedTables, transaction, updates }; + return { + deleted: () => deletes, + inserts, + lockedTables, + transaction, + updates, + }; }; it.effect('Add reuses a current same-Party identifier instead of duplicating an assertion', () => Effect.gen(function* verifyCase1() { const db = harness(); - const result = yield* addOfficialIdentifierRecord( - db.transaction, - tenantId, - partyId, - identifier, - { - actionInvocationId: 'invocation', - matchRuleVersion: 'party-exact-claims.v1', - partyType: 'ORGANIZATION', - principalId, - provenanceMethod: 'MANUAL', - provenanceSource: 'USER', - validFrom: '2026-01-01T00:00:00.000Z', - }, - ); + const result = yield* addOfficialIdentifierRecord(db.transaction, tenantId, partyId, identifier, { + actionInvocationId: 'invocation', + matchRuleVersion: 'party-exact-claims.v1', + partyType: 'ORGANIZATION', + principalId, + provenanceMethod: 'MANUAL', + provenanceSource: 'USER', + validFrom: '2026-01-01T00:00:00.000Z', + }); expect(result.officialIdentifierId).toBe(officialIdentifierId); expect(db.inserts.length).toBe(0); }), ); -it.effect( - 'Add retains ARES evidence separately from the accepting actor and only claims eligible Party types', - () => - Effect.gen(function* verifyCase2() { - const externalEvidenceWire = { - authorityPolicyKey: 'party_registry.ares_enrichment', - authorityPolicyVersion: '1', - cacheAgeSeconds: 0, - decidedAt: '2026-01-01T00:00:00.000Z', - evidenceRef: 'ares:27074358:confirmation', - fact: 'ICO', - observedAt: '2026-01-01T00:00:00.000Z', - outcome: 'APPLY_ENRICHMENT', - provider: 'ares', - providerChangedOn: null, - providerRecordRef: null, - queryIco: '27074358', - reasonCode: 'authoritative_ico', - servedAt: '2026-01-01T00:00:00.000Z', - } as const; - const externalEvidence = - yield* Schema.decodeUnknownEffect(AresAppliedEvidenceSchema)(externalEvidenceWire); - yield* Effect.all( - (['ORGANIZATION', 'PERSON'] as const).map((partyType) => - Effect.gen(function* verifyCase3() { - const db = harness({ absent: true }); - yield* addOfficialIdentifierRecord(db.transaction, tenantId, partyId, identifier, { - actionInvocationId: 'invocation', - externalEvidence, - matchRuleVersion: 'party-exact-claims.v1', - partyType, - principalId, - provenanceMethod: 'REGISTRY_CONFIRMATION', - provenanceSource: 'ARES', - validFrom: '2026-01-01T00:00:00.000Z', - }); - const storedEvidence = db.inserts[0]?.values['externalEvidence']; - expect(storedEvidence).toEqual(externalEvidenceWire); - expect( - yield* Schema.decodeUnknownEffect(AresAppliedEvidenceSchema)(storedEvidence), - ).toEqual(externalEvidence); - expect(db.inserts[0]?.values['acceptedByPrincipalId']).toBe(principalId); - expect(db.inserts.filter((entry) => entry.table === partyIdentifierClaims).length).toBe( - partyType === 'ORGANIZATION' ? 1 : 0, - ); - }), - ), - ); - }), +it.effect('Add retains ARES evidence separately from the accepting actor and only claims eligible Party types', () => + Effect.gen(function* verifyCase2() { + const externalEvidenceWire = { + authorityPolicyKey: 'party_registry.ares_enrichment', + authorityPolicyVersion: '1', + cacheAgeSeconds: 0, + decidedAt: '2026-01-01T00:00:00.000Z', + evidenceRef: 'ares:27074358:confirmation', + fact: 'ICO', + observedAt: '2026-01-01T00:00:00.000Z', + outcome: 'APPLY_ENRICHMENT', + provider: 'ares', + providerChangedOn: null, + providerRecordRef: null, + queryIco: '27074358', + reasonCode: 'authoritative_ico', + servedAt: '2026-01-01T00:00:00.000Z', + } as const; + const externalEvidence = yield* Schema.decodeEffect(AresAppliedEvidenceSchema)(externalEvidenceWire); + yield* Effect.all( + (['ORGANIZATION', 'PERSON'] as const).map((partyType) => + Effect.gen(function* verifyCase3() { + const db = harness({ absent: true }); + yield* addOfficialIdentifierRecord(db.transaction, tenantId, partyId, identifier, { + actionInvocationId: 'invocation', + externalEvidence, + matchRuleVersion: 'party-exact-claims.v1', + partyType, + principalId, + provenanceMethod: 'REGISTRY_CONFIRMATION', + provenanceSource: 'ARES', + validFrom: '2026-01-01T00:00:00.000Z', + }); + const storedEvidence = db.inserts[0]?.values['externalEvidence']; + expect(storedEvidence).toEqual(externalEvidenceWire); + expect(yield* Schema.decodeUnknownEffect(AresAppliedEvidenceSchema)(storedEvidence)).toEqual( + externalEvidence, + ); + expect(db.inserts[0]?.values['acceptedByPrincipalId']).toBe(principalId); + expect(db.inserts.filter((entry) => entry.table === partyIdentifierClaims).length).toBe( + partyType === 'ORGANIZATION' ? 1 : 0, + ); + }), + ), + ); + }), ); it.effect('ending an identifier preserves its fact and releases its current claim', () => Effect.gen(function* verifyCase4() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z'))); const db = harness({ claimOwner: partyId, - current: row({ verificationState: 'VERIFIED', verifiedAt: date('2026-01-01T00:00:00.000Z') }), + current: row({ + verificationState: 'VERIFIED', + verifiedAt: date('2026-01-01T00:00:00.000Z'), + }), }); const result = yield* endOfficialIdentifierRecord( db.transaction, @@ -245,9 +243,7 @@ it.effect('ending an identifier preserves its fact and releases its current clai it.effect('a future end does not release a presently valid claim', () => Effect.gen(function* verifyCase5() { - yield* TestClock.setTime( - DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z')), - ); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-07T00:00:00.000Z'))); const db = harness(); const result = yield* endOfficialIdentifierRecord( db.transaction, @@ -329,59 +325,49 @@ it.effect('PERSON verification cannot acquire an implicit strong identifier clai }), ); -it.effect( - 'verification downgrade releases its claim without erasing the previous verification evidence', - () => - Effect.gen(function* verifyCase9() { - const verifiedAt = date('2026-01-01T00:00:00.000Z'); - const db = harness({ - claimOwner: partyId, - current: row({ - verificationState: 'VERIFIED', - verifiedAt, - verifiedByPrincipalId: principalId, - }), - }); +it.effect('verification downgrade releases its claim without erasing the previous verification evidence', () => + Effect.gen(function* verifyCase9() { + const verifiedAt = date('2026-01-01T00:00:00.000Z'); + const db = harness({ + claimOwner: partyId, + current: row({ + verificationState: 'VERIFIED', + verifiedAt, + verifiedByPrincipalId: principalId, + }), + }); + const result = yield* updateOfficialIdentifierVerificationRecord(db.transaction, tenantId, officialIdentifierId, { + ...verificationCommand, + expectedVerification: 'VERIFIED', + verification: 'REJECTED', + }); + expect(Predicate.isTagged(result, 'found')).toBe(true); + const found = Match.value(result).pipe( + Match.tag('found', (value) => value), + Match.orElse(() => + (() => { + throw new Error('Expected the identifier verification downgrade to succeed'); + })(), + ), + ); + expect(found.previous.verifiedAt).toBe(verifiedAt); + expect(found.previous.verifiedByPrincipalId).toBe(principalId); + expect(found.value.verifiedAt).toBe(null); + expect(db.deleted()).toBe(1); + }), +); + +it.effect('archived Party and stale verification updates are rejected before mutation', () => + Effect.forEach([harness({ archived: true }), harness({ current: row({ verificationState: 'REJECTED' }) })], (db) => + Effect.gen(function* verifyCase11() { const result = yield* updateOfficialIdentifierVerificationRecord( db.transaction, tenantId, officialIdentifierId, - { - ...verificationCommand, - expectedVerification: 'VERIFIED', - verification: 'REJECTED', - }, - ); - expect(Predicate.isTagged(result, 'found')).toBe(true); - const found = Match.value(result).pipe( - Match.tag('found', (value) => value), - Match.orElse(() => - (() => { - throw new Error('Expected the identifier verification downgrade to succeed'); - })(), - ), + verificationCommand, ); - expect(found.previous.verifiedAt).toBe(verifiedAt); - expect(found.previous.verifiedByPrincipalId).toBe(principalId); - expect(found.value.verifiedAt).toBe(null); - expect(db.deleted()).toBe(1); + expect(Predicate.isTagged(result, 'conflict')).toBe(true); + expect(db.updates.length).toBe(0); }), -); - -it.effect('archived Party and stale verification updates are rejected before mutation', () => - Effect.all( - [harness({ archived: true }), harness({ current: row({ verificationState: 'REJECTED' }) })].map( - (db) => - Effect.gen(function* verifyCase11() { - const result = yield* updateOfficialIdentifierVerificationRecord( - db.transaction, - tenantId, - officialIdentifierId, - verificationCommand, - ); - expect(Predicate.isTagged(result, 'conflict')).toBe(true); - expect(db.updates.length).toBe(0); - }), - ), ), ); diff --git a/app/verticals/party-registry/tests/unit/identifier-update-outbox.test.ts b/app/verticals/party-registry/tests/unit/identifier-update-outbox.test.ts index eb33a04f7..f829f5d6d 100644 --- a/app/verticals/party-registry/tests/unit/identifier-update-outbox.test.ts +++ b/app/verticals/party-registry/tests/unit/identifier-update-outbox.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect, Option, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { createActionCollector } from '../../../../packages/core-runtime/src/actions/collector.ts'; import { getActionHandler } from '../../../../packages/core-runtime/src/actions/definition.ts'; import { UpdatePartyOfficialIdentifierResultSchema } from '../../shared/actions/update-party-official-identifier.ts'; @@ -29,91 +30,96 @@ const before = { verifiedByPrincipalId: null, } as const; const changes: readonly UpdatePartyOfficialIdentifierPayload['change'][] = [ - { expectedVerification: 'UNVERIFIED', type: 'SET_VERIFICATION', verification: 'VERIFIED' }, - { type: 'END_VALIDITY', validTo: DateTime.makeUnsafe('2026-01-02T00:00:00.000Z') }, + { + expectedVerification: 'UNVERIFIED', + type: 'SET_VERIFICATION', + verification: 'VERIFIED', + }, + { + type: 'END_VALIDITY', + validTo: DateTime.makeUnsafe('2026-01-02T00:00:00.000Z'), + }, ]; for (const change of changes) { - it.effect( - `${change.type} links one stable-reference outbox message to its committed Domain Event`, - () => - Effect.gen(function* successfulUpdate() { - const collector = createActionCollector( - updatePartyOfficialIdentifierAction.descriptor.domainEvents, - 'party.registry', - updatePartyOfficialIdentifierAction.descriptor.accessEvidencePolicy, - ); - const handler = getActionHandler(updatePartyOfficialIdentifierAction); - const encodedValidTo = - change.type === 'END_VALIDITY' - ? yield* Schema.encodeEffect(Schema.DateTimeUtcFromString)(change.validTo) - : null; - const after = - change.type === 'SET_VERIFICATION' - ? { - ...before, - verification: 'VERIFIED' as const, - verifiedAt: '2026-01-02T00:00:00.000Z', - verifiedByPrincipalId: '40000000-0000-4000-8000-000000000001', - } - : { - ...before, - state: 'ENDED' as const, - validTo: encodedValidTo, - }; - const result = { - officialIdentifierRef, - partyRef, - state: after.state, - validTo: change.type === 'END_VALIDITY' ? Option.some(change.validTo) : Option.none(), - verification: after.verification, - }; - yield* handler( - { - change, - evidenceRefs: ['evidence:identifier-update'], - officialIdentifierRef, - reason: 'Accepted registry evidence', - }, - { - actionInvocationId: '50000000-0000-4000-8000-000000000001', - addDomainEvent: collector.addDomainEvent, - addOutboxMessage: collector.addOutboxMessage, - recordAuditEvidence: collector.recordAuditEvidence, - recordDataAccess: collector.recordDataAccess, - scope: { - authMethod: 'system', - correlationId: 'identifier-outbox-test', - principalId: '40000000-0000-4000-8000-000000000001', - tenantId, - }, - services: { update: () => Effect.succeed({ after, before, result }) }, - }, - ); - const snapshot = collector.snapshot(); - expect(snapshot.domainEvents.length).toBe(1); - expect(snapshot.outboxMessages.length).toBe(1); - expect(snapshot.outboxMessages[0]?.domainEventIndex).toBe(0); - expect(snapshot.outboxMessages[0]?.message.topic).toBe( - 'party.registry.official-identifier-updated.v1', - ); - expect(snapshot.outboxMessages[0]?.message.payloadJson).toEqual({ - officialIdentifierRef, - partyRef, - }); - expect(snapshot.domainEvents[0]?.subjectResourceId).toBe(officialIdentifierRef.resourceId); - const event = snapshot.domainEvents[0]?.payloadJson; - expect(event !== undefined).toBe(true); - expect(event).toEqual({ - after, - before, - changeType: change.type, + it.effect(`${change.type} links one stable-reference outbox message to its committed Domain Event`, () => + Effect.gen(function* successfulUpdate() { + const collector = createActionCollector( + updatePartyOfficialIdentifierAction.descriptor.domainEvents, + 'party.registry', + updatePartyOfficialIdentifierAction.descriptor.accessEvidencePolicy, + ); + const handler = getActionHandler(updatePartyOfficialIdentifierAction); + const encodedValidTo = + change.type === 'END_VALIDITY' + ? yield* Schema.encodeEffect(Schema.DateTimeUtcFromString)(change.validTo) + : null; + const after = + change.type === 'SET_VERIFICATION' + ? { + ...before, + verification: 'VERIFIED' as const, + verifiedAt: '2026-01-02T00:00:00.000Z', + verifiedByPrincipalId: '40000000-0000-4000-8000-000000000001', + } + : { + ...before, + state: 'ENDED' as const, + validTo: encodedValidTo, + }; + const result = { + officialIdentifierRef, + partyRef, + state: after.state, + validTo: change.type === 'END_VALIDITY' ? Option.some(change.validTo) : Option.none(), + verification: after.verification, + }; + yield* handler( + { + change, evidenceRefs: ['evidence:identifier-update'], officialIdentifierRef, - partyRef, reason: 'Accepted registry evidence', - }); - }), + }, + { + actionInvocationId: '50000000-0000-4000-8000-000000000001', + addDomainEvent: collector.addDomainEvent, + addOutboxMessage: collector.addOutboxMessage, + recordAuditEvidence: collector.recordAuditEvidence, + recordDataAccess: collector.recordDataAccess, + scope: { + authMethod: 'system', + correlationId: 'identifier-outbox-test', + principalId: '40000000-0000-4000-8000-000000000001', + tenantId, + }, + services: { + update: () => Effect.succeed({ after, before, result }), + }, + }, + ); + const snapshot = collector.snapshot(); + expect(snapshot.domainEvents.length).toBe(1); + expect(snapshot.outboxMessages.length).toBe(1); + expect(snapshot.outboxMessages[0]?.domainEventIndex).toBe(0); + expect(snapshot.outboxMessages[0]?.message.topic).toBe('party.registry.official-identifier-updated.v1'); + expect(snapshot.outboxMessages[0]?.message.payloadJson).toEqual({ + officialIdentifierRef, + partyRef, + }); + expect(snapshot.domainEvents[0]?.subjectResourceId).toBe(officialIdentifierRef.resourceId); + const event = snapshot.domainEvents[0]?.payloadJson; + expect(event !== undefined).toBe(true); + expect(event).toEqual({ + after, + before, + changeType: change.type, + evidenceRefs: ['evidence:identifier-update'], + officialIdentifierRef, + partyRef, + reason: 'Accepted registry evidence', + }); + }), ); } @@ -162,14 +168,14 @@ it.effect('rejected identifier updates publish neither Domain Event nor outbox m it.effect('published identifier update payload contains references only', () => Effect.gen(function* verifyOutboxPayload() { - const decode = Schema.decodeUnknownEffect(OutboxPayloadSchema, { onExcessProperty: 'error' }); + const decode = Schema.decodeUnknownEffect(OutboxPayloadSchema, { + onExcessProperty: 'error', + }); expect(yield* decode({ officialIdentifierRef, partyRef })).toEqual({ officialIdentifierRef, partyRef, }); - const error = yield* Effect.flip( - decode({ officialIdentifierRef, partyRef, verification: 'VERIFIED' }), - ); + const error = yield* Effect.flip(decode({ officialIdentifierRef, partyRef, verification: 'VERIFIED' })); expect(error).toBeDefined(); }), ); @@ -183,9 +189,7 @@ it.effect('identifier update results keep DateTime and Option internally with nu validTo: '2026-01-02T00:00:00.000Z', verification: 'VERIFIED', } as const; - const decoded = yield* Schema.decodeUnknownEffect(UpdatePartyOfficialIdentifierResultSchema)( - wire, - ); + const decoded = yield* Schema.decodeEffect(UpdatePartyOfficialIdentifierResultSchema)(wire); expect(Option.isSome(decoded.validTo)).toBe(true); expect( Option.match(decoded.validTo, { @@ -193,8 +197,6 @@ it.effect('identifier update results keep DateTime and Option internally with nu onSome: DateTime.formatIso, }), ).toBe(wire.validTo); - expect(yield* Schema.encodeEffect(UpdatePartyOfficialIdentifierResultSchema)(decoded)).toEqual( - wire, - ); + expect(yield* Schema.encodeEffect(UpdatePartyOfficialIdentifierResultSchema)(decoded)).toEqual(wire); }), ); diff --git a/app/verticals/party-registry/tests/unit/identity-action-evidence.test.ts b/app/verticals/party-registry/tests/unit/identity-action-evidence.test.ts index 5bfb9cd90..a777c2fcc 100644 --- a/app/verticals/party-registry/tests/unit/identity-action-evidence.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-action-evidence.test.ts @@ -1,15 +1,16 @@ -import { expect, it } from 'effect-rstest'; -import { DateTime, Effect, Option, Schema, Predicate } from 'effect'; import { bindActionTestServices, makeActionTestHarness } from '@app/core-runtime/testing/actions'; +import { DateTime, Effect, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { createActionCollector } from '../../../../packages/core-runtime/src/actions/collector.ts'; import { getActionHandler } from '../../../../packages/core-runtime/src/actions/definition.ts'; import type { ActionEvidenceSnapshot } from '../../../../packages/core-runtime/src/actions/events.ts'; import { PartySchema, makePartyRef } from '../../shared/domain/identity-contracts.ts'; +import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; +import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; import { archivePartyAction } from '../../src/actions/archive-party.action.ts'; import { unarchivePartyAction } from '../../src/actions/unarchive-party.action.ts'; import { updatePartyAction } from '../../src/actions/update-party.action.ts'; -import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; -import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; const tenantId = '11111111-1111-4111-8111-111111111111'; const partyId = '22222222-2222-4222-8222-222222222222'; @@ -20,7 +21,7 @@ const scope = { principalId: '44444444-4444-4444-8444-444444444444', tenantId, }; -const party = Schema.decodeUnknownSync(PartySchema)({ +const party = Schema.decodeSync(PartySchema)({ archivedAt: null, createdAt: '2026-01-01T00:00:00.000Z', displayName: 'Example organization', @@ -62,7 +63,9 @@ it.effect('Update Party records metadata-only invariant evidence with its event ...collector, actionInvocationId, scope, - services: { update: () => Effect.succeed({ _tag: 'found', value: party }) }, + services: { + update: () => Effect.succeed({ _tag: 'found', value: party }), + }, }, ); assertInvariantEvidence(collector.snapshot()); @@ -77,89 +80,104 @@ it.effect('Archive Party records metadata-only invariant evidence with its event archivePartyAction.descriptor.accessEvidencePolicy, ); yield* getActionHandler(archivePartyAction)( - { expectedRevision: 1, partyRef: party.partyRef, reason: 'No longer active' }, + { + expectedRevision: 1, + partyRef: party.partyRef, + reason: 'No longer active', + }, { ...collector, actionInvocationId, scope, - services: { transition: () => Effect.succeed({ _tag: 'found', value: party }) }, + services: { + transition: () => Effect.succeed({ _tag: 'found', value: party }), + }, }, ); assertInvariantEvidence(collector.snapshot()); }), ); -it.effect( - 'Unarchive Party records metadata-only invariant evidence with its event and outbox', - () => - Effect.gen(function* verifyUnarchiveEvidence() { - const collector = createActionCollector( - unarchivePartyAction.descriptor.domainEvents, - 'party.registry', - unarchivePartyAction.descriptor.accessEvidencePolicy, - ); - yield* getActionHandler(unarchivePartyAction)( - { expectedRevision: 1, partyRef: party.partyRef, reason: 'Active again' }, - { - ...collector, - actionInvocationId, - scope, - services: { unarchive: () => Effect.succeed({ _tag: 'found', value: party }) }, +it.effect('Unarchive Party records metadata-only invariant evidence with its event and outbox', () => + Effect.gen(function* verifyUnarchiveEvidence() { + const collector = createActionCollector( + unarchivePartyAction.descriptor.domainEvents, + 'party.registry', + unarchivePartyAction.descriptor.accessEvidencePolicy, + ); + yield* getActionHandler(unarchivePartyAction)( + { + expectedRevision: 1, + partyRef: party.partyRef, + reason: 'Active again', + }, + { + ...collector, + actionInvocationId, + scope, + services: { + unarchive: () => Effect.succeed({ _tag: 'found', value: party }), }, - ); - assertInvariantEvidence(collector.snapshot()); - }), + }, + ); + assertInvariantEvidence(collector.snapshot()); + }), ); -it.effect( - 'Unarchive review outcome commits once and replays without an unarchive event or outbox', - () => - Effect.gen(function* verifyUnarchiveConflictEvidence() { - let calls = 0; - const blocked = { - caseRef: makeDuplicateCandidateCaseRef(tenantId, partyId), - decisionRef: makePartyMatchDecisionRef(tenantId, actionInvocationId), - outcome: 'BLOCKED' as const, - party: { - ...party, - archivedAt: Option.some(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), - }, - reasonCode: 'EXACT_CLAIM_CONFLICT' as const, - }; - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - services: [ - bindActionTestServices(unarchivePartyAction, { - unarchive: () => - Effect.sync(() => { - calls += 1; - return { _tag: 'blocked' as const, value: blocked }; - }), - }), - ], - tenantPermission: 'allowed', - }); - const request = { - payload: { expectedRevision: 1, partyRef: party.partyRef, reason: 'Active again' }, - principal: { - authBindingId: '60000000-0000-4000-8000-000000000001', - authContextRef: 'better-auth-session:unarchive-review-test', - authMethod: 'session' as const, - principalId: scope.principalId, - tenantId, - }, - registration: unarchivePartyAction, - transport: { correlationId: 'unarchive-review', idempotencyKey: 'unarchive-once' }, - }; - expect(yield* harness.runtime.runAction(request)).toEqual(blocked); - const replay = yield* harness.runtime.runAction(request).pipe(Effect.flip); - expect(Predicate.isTagged(replay, 'ActionAlreadyCommitted')).toBe(true); - expect(calls).toBe(1); - const snapshot = harness.snapshot(); - expect(snapshot.committed.length).toBe(1); - expect(snapshot.invocations[0]?.status).toBe('succeeded'); - expect(snapshot.committed[0]?.evidence.dataAccessEvents.length).toBe(1); - expect(snapshot.committed[0]?.evidence.domainEvents).toEqual([]); - expect(snapshot.committed[0]?.evidence.outboxMessages).toEqual([]); - }), +it.effect('Unarchive review outcome commits once and replays without an unarchive event or outbox', () => + Effect.gen(function* verifyUnarchiveConflictEvidence() { + let calls = 0; + const blocked = { + caseRef: makeDuplicateCandidateCaseRef(tenantId, partyId), + decisionRef: makePartyMatchDecisionRef(tenantId, actionInvocationId), + outcome: 'BLOCKED' as const, + party: { + ...party, + archivedAt: Option.some(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), + }, + reasonCode: 'EXACT_CLAIM_CONFLICT' as const, + }; + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + services: [ + bindActionTestServices(unarchivePartyAction, { + unarchive: () => + Effect.sync(() => { + calls += 1; + return { _tag: 'blocked' as const, value: blocked }; + }), + }), + ], + tenantPermission: 'allowed', + }); + const request = { + payload: { + expectedRevision: 1, + partyRef: party.partyRef, + reason: 'Active again', + }, + principal: { + authBindingId: '60000000-0000-4000-8000-000000000001', + authContextRef: 'better-auth-session:unarchive-review-test', + authMethod: 'session' as const, + principalId: scope.principalId, + tenantId, + }, + registration: unarchivePartyAction, + transport: { + correlationId: 'unarchive-review', + idempotencyKey: 'unarchive-once', + }, + }; + expect(yield* harness.runtime.runAction(request)).toEqual(blocked); + const replay = yield* harness.runtime.runAction(request).pipe(Effect.flip); + expect(Predicate.isTagged(replay, 'ActionAlreadyCommitted')).toBe(true); + expect(calls).toBe(1); + const snapshot = harness.snapshot(); + expect(snapshot.committed.length).toBe(1); + expect(snapshot.invocations[0]?.status).toBe('succeeded'); + expect(snapshot.committed[0]?.evidence.dataAccessEvents.length).toBe(1); + expect(snapshot.committed[0]?.evidence.domainEvents).toEqual([]); + expect(snapshot.committed[0]?.evidence.outboxMessages).toEqual([]); + }), ); diff --git a/app/verticals/party-registry/tests/unit/identity-contract.test.ts b/app/verticals/party-registry/tests/unit/identity-contract.test.ts index 55893aa80..513c8b93c 100644 --- a/app/verticals/party-registry/tests/unit/identity-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, DateTime, Option, Schema, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { PartyCandidateSchema, IsoTimestampSchema, @@ -10,11 +11,11 @@ import { makePartyRef, partyIdFromString, } from '../../shared/domain/identity-contracts.ts'; +import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; +import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; import { createPartyAction } from '../../src/actions/create-party.action.ts'; import { unarchivePartyAction } from '../../src/actions/unarchive-party.action.ts'; import { updatePartyAction } from '../../src/actions/update-party.action.ts'; -import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; -import { makePartyMatchDecisionRef } from '../../shared/resources/party-match-decision.ts'; const decode = Schema.decodeUnknownSync; @@ -24,7 +25,7 @@ it('Party V1 admits only PERSON, ORGANIZATION, and evidenced UNRESOLVED identity } expect(() => decode(PartyTypeSchema)('OTHER')).toThrow(); expect(() => - decode(PartyCandidateSchema)({ + Schema.decodeSync(PartyCandidateSchema)({ displayName: ' ', evidenceRefs: [], officialIdentifiers: [], @@ -43,8 +44,8 @@ it('Party Type update is enrichment-only; cross-kind changes require Correction' }); it('identity timestamps decode to canonical UTC values', () => { - expect(() => decode(IsoTimestampSchema)('not-a-timestamp')).toThrow(); - const leapDay = decode(IsoTimestampSchema)('2024-02-29T00:00:00Z'); + expect(() => Schema.decodeSync(IsoTimestampSchema)('not-a-timestamp')).toThrow(); + const leapDay = Schema.decodeSync(IsoTimestampSchema)('2024-02-29T00:00:00Z'); expect(DateTime.formatIso(leapDay)).toBe('2024-02-29T00:00:00.000Z'); }); @@ -54,15 +55,12 @@ it.effect('Party JSON round-trips timestamps as strings and absent values as nul archivedAt: null, createdAt: '2025-01-01T00:00:00.000Z', displayName: null, - partyRef: makePartyRef( - '11111111-1111-4111-8111-111111111111', - '22222222-2222-4222-8222-222222222222', - ), + partyRef: makePartyRef('11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'), partyType: 'UNRESOLVED' as const, revision: 1, updatedAt: '2026-01-01T00:00:00.000Z', }; - const decoded = decode(PartySchema)(encoded); + const decoded = yield* Schema.decodeEffect(PartySchema)(encoded); expect(Option.isNone(decoded.archivedAt)).toBe(true); expect(Option.isNone(decoded.displayName)).toBe(true); @@ -76,7 +74,7 @@ it.effect('Party JSON round-trips timestamps as strings and absent values as nul archivedAt: '2026-02-01T00:00:00.000Z', displayName: 'Example organization', }; - expect(yield* Schema.encodeEffect(PartySchema)(decode(PartySchema)(presentEncoded))).toEqual( + expect(yield* Schema.encodeEffect(PartySchema)(yield* Schema.decodeEffect(PartySchema)(presentEncoded))).toEqual( presentEncoded, ); }), @@ -86,12 +84,18 @@ it.effect('Party Candidate accepts an evidenced identifier without inventing a d Effect.gen(function* verifySchema2() { const encoded = { evidenceRefs: ['source:official-record'], - officialIdentifiers: [{ identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }], + officialIdentifiers: [ + { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, + ], partyType: 'ORGANIZATION' as const, provenance: { method: 'IMPORT', source: 'official-register' }, validFrom: '2026-01-01T00:00:00.000Z', }; - const candidate = decode(PartyCandidateSchema)(encoded); + const candidate = yield* Schema.decodeUnknownEffect(PartyCandidateSchema)(encoded); expect(candidate.displayName).toBe(undefined); expect(candidate.officialIdentifiers.length).toBe(1); expect(yield* Schema.encodeEffect(PartyCandidateSchema)(candidate)).toEqual(encoded); @@ -99,9 +103,7 @@ it.effect('Party Candidate accepts an evidenced identifier without inventing a d ); it('Party references retain tenant, module, resource type, and resource identity', () => { - expect( - makePartyRef('11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'), - ).toEqual({ + expect(makePartyRef('11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222')).toEqual({ moduleId: 'party.registry', resourceId: '22222222-2222-4222-8222-222222222222', resourceType: 'party.registry.party', diff --git a/app/verticals/party-registry/tests/unit/identity-create-without-strong-identifier.test.ts b/app/verticals/party-registry/tests/unit/identity-create-without-strong-identifier.test.ts index 33d4bf15f..d65736534 100644 --- a/app/verticals/party-registry/tests/unit/identity-create-without-strong-identifier.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-create-without-strong-identifier.test.ts @@ -1,10 +1,8 @@ -import { expect, it } from 'effect-rstest'; import { DateTime } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { partySubjectKeyFromString } from '../../shared/domain/identity-contracts.ts'; -import type { - PartyCandidate, - PartySubjectEvidence, -} from '../../shared/domain/identity-contracts.ts'; +import type { PartyCandidate, PartySubjectEvidence } from '../../shared/domain/identity-contracts.ts'; import { createPartyAction } from '../../src/actions/create-party.action.ts'; import { decideCreateWithoutStrongIdentifier, @@ -15,9 +13,7 @@ import { candidateFingerprint, } from '../../src/services/party-matching-persistence.service.ts'; -const evidence = ( - observedSubject: PartySubjectEvidence['observedSubject'], -): PartySubjectEvidence => ({ +const evidence = (observedSubject: PartySubjectEvidence['observedSubject']): PartySubjectEvidence => ({ basis: 'DIRECT_INTERACTION', evidenceRef: 'meeting/42', kind: 'ACTOR_ATTESTATION', @@ -34,8 +30,7 @@ const candidate = (overrides: Partial = {}): PartyCandidate => ( validFrom: DateTime.makeUnsafe('2020-01-01T00:00:00.000Z'), ...overrides, }); -const decide = (value: PartyCandidate) => - decideCreateWithoutStrongIdentifier(value, { requireIdentityReview: false }); +const decide = (value: PartyCandidate) => decideCreateWithoutStrongIdentifier(value, { requireIdentityReview: false }); it('concrete subject evidence needs neither a name nor an official ID', () => { expect(decide(candidate()).decision).toBe('ALLOW'); expect(decide(candidate({ displayName: 'A' })).decision).toBe('ALLOW'); @@ -61,13 +56,14 @@ it('names, reference prefixes and identifiers never substitute for subject evide it('type support is separate from evidence of a concrete subject', () => { for (const partyType of ['PERSON', 'ORGANIZATION'] as const) { expect(decide(candidate({ partyType })).reasonCode).toBe('party_type_evidence_required'); - expect(decide(candidate({ partyType, subjectEvidence: [evidence(partyType)] })).decision).toBe( - 'ALLOW', - ); + expect(decide(candidate({ partyType, subjectEvidence: [evidence(partyType)] })).decision).toBe('ALLOW'); } expect( - decide(candidate({ subjectEvidence: [evidence('PERSON'), evidence('ORGANIZATION')] })) - .reasonCode, + decide( + candidate({ + subjectEvidence: [evidence('PERSON'), evidence('ORGANIZATION')], + }), + ).reasonCode, ).toBe('conflicting_type_evidence'); }); @@ -80,7 +76,10 @@ it('technical records, managed Legal Entities and multiple subjects fail closed' candidate({ subjectEvidence: [ evidence('PERSON'), - { ...evidence('PERSON'), subjectKey: partySubjectKeyFromString('another') }, + { + ...evidence('PERSON'), + subjectKey: partySubjectKeyFromString('another'), + }, ], }), ).reasonCode, @@ -89,13 +88,9 @@ it('technical records, managed Legal Entities and multiple subjects fail closed' it('review configuration cannot waive evidence and eligible review remains atomic', () => { expect(createPartyAction.descriptor.policies).toEqual([]); - expect(decideAtomicCreateWithoutStrongIdentifier(candidate(), false).decision).toBe( - 'REVIEW_REQUIRED', - ); + expect(decideAtomicCreateWithoutStrongIdentifier(candidate(), false).decision).toBe('REVIEW_REQUIRED'); expect(decideAtomicCreateWithoutStrongIdentifier(candidate(), true).decision).toBe('ALLOW'); - expect( - decideAtomicCreateWithoutStrongIdentifier(candidate({ subjectEvidence: [] }), true).decision, - ).toBe('DENY'); + expect(decideAtomicCreateWithoutStrongIdentifier(candidate({ subjectEvidence: [] }), true).decision).toBe('DENY'); }); it('reference spelling is neutral; meaningful evidence and independent versions are retained', () => { diff --git a/app/verticals/party-registry/tests/unit/identity-party-detail-alias.test.ts b/app/verticals/party-registry/tests/unit/identity-party-detail-alias.test.ts index d2f23cc99..616ebffba 100644 --- a/app/verticals/party-registry/tests/unit/identity-party-detail-alias.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-party-detail-alias.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { DateTime, Effect, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { PartyDetailResponseSchema } from '../../shared/apis/party-detail.ts'; import { PartySchema } from '../../shared/domain/identity-contracts.ts'; import type { Party } from '../../shared/domain/identity-contracts.ts'; @@ -26,34 +27,31 @@ const canonicalPartyWire = { revision: 3, updatedAt: '2026-09-03T10:00:00.000Z', } as const; -const canonicalParty: Party = Schema.decodeUnknownSync(PartySchema)(canonicalPartyWire); +const canonicalParty: Party = Schema.decodeSync(PartySchema)(canonicalPartyWire); const alias = (aliasPartyId: string, canonicalPartyId: string): PartyAliasLookupRow => ({ aliasPartyId, canonicalPartyId, tenantId, }); -const makeServices = ( - aliases: readonly PartyAliasLookupRow[], - party: Party | null = canonicalParty, -) => { +const makeServices = (aliases: readonly PartyAliasLookupRow[], party: Party | null = canonicalParty) => { const lookups: string[] = []; const resolver = makePartyAliasResolutionService({ findAlias: (_tenantId, partyId) => - Effect.succeed( - Option.fromNullishOr(aliases.find(({ aliasPartyId }) => aliasPartyId === partyId)), - ), + Effect.succeed(Option.fromNullishOr(aliases.find(({ aliasPartyId }) => aliasPartyId === partyId))), partyExists: (_tenantId, partyId) => Effect.succeed(party?.partyRef.resourceId === partyId), }); return { lookups, services: { - facts: () => Effect.succeed({ currentFactAssertions: [], factHistory: Option.none() }), + facts: () => + Effect.succeed({ + currentFactAssertions: [], + factHistory: Option.none(), + }), find: (partyId: string): Effect.Effect => { lookups.push(partyId); return Effect.succeed( - party?.partyRef.resourceId === partyId - ? { _tag: 'found', value: party } - : { _tag: 'not_found' }, + party?.partyRef.resourceId === partyId ? { _tag: 'found', value: party } : { _tag: 'not_found' }, ); }, resolve: (partyId: string) => resolver.resolvePartyAlias(tenantId, partyId), @@ -61,82 +59,65 @@ const makeServices = ( }; }; -it.effect( - 'Party Detail reads the final canonical Party after the complete historical alias chain', - () => - Effect.gen(function* verifyPartyDetail1() { - const { lookups, services } = makeServices([ - alias('party-b', 'party-a'), - alias('party-a', 'party-c'), - ]); - const result = yield* readPartyDetailFromServices(partyRef('party-b'), tenantId, services); +it.effect('Party Detail reads the final canonical Party after the complete historical alias chain', () => + Effect.gen(function* verifyPartyDetail1() { + const { lookups, services } = makeServices([alias('party-b', 'party-a'), alias('party-a', 'party-c')]); + const result = yield* readPartyDetailFromServices(partyRef('party-b'), tenantId, services); - expect(result).toEqual({ - currentFactAssertions: [], - factHistory: Option.none(), - party: canonicalParty, - resolution: { - aliasChain: [partyRef('party-b'), partyRef('party-a')], - canonicalPartyRef: partyRef('party-c'), - kind: 'ALIAS', - requestedPartyRef: partyRef('party-b'), - }, - }); - expect(lookups).toEqual(['party-c']); - expect(Schema.is(PartyDetailResponseSchema)(result)).toBe(true); - const encoded = yield* Schema.encodeEffect(PartyDetailResponseSchema)(result); - expect(encoded.factHistory).toBe(null); - expect(encoded.party).toEqual(canonicalPartyWire); - }), + expect(result).toEqual({ + currentFactAssertions: [], + factHistory: Option.none(), + party: canonicalParty, + resolution: { + aliasChain: [partyRef('party-b'), partyRef('party-a')], + canonicalPartyRef: partyRef('party-c'), + kind: 'ALIAS', + requestedPartyRef: partyRef('party-b'), + }, + }); + expect(lookups).toEqual(['party-c']); + expect(Schema.is(PartyDetailResponseSchema)(result)).toBe(true); + const encoded = yield* Schema.encodeEffect(PartyDetailResponseSchema)(result); + expect(encoded.factHistory).toBe(null); + expect(encoded.party).toEqual(canonicalPartyWire); + }), ); -it.effect( - 'Party Detail preserves archived lifecycle independently of direct resolution metadata', - () => - Effect.gen(function* verifyPartyDetail2() { - const archivedAt = '2026-09-02T10:00:00.000Z'; - const archivedParty = yield* Schema.decodeUnknownEffect(PartySchema)({ - ...canonicalPartyWire, - archivedAt, - }); - const { services } = makeServices([], archivedParty); - const result = yield* readPartyDetailFromServices(partyRef('party-c'), tenantId, services); +it.effect('Party Detail preserves archived lifecycle independently of direct resolution metadata', () => + Effect.gen(function* verifyPartyDetail2() { + const archivedAt = '2026-09-02T10:00:00.000Z'; + const archivedParty = yield* Schema.decodeEffect(PartySchema)({ + ...canonicalPartyWire, + archivedAt, + }); + const { services } = makeServices([], archivedParty); + const result = yield* readPartyDetailFromServices(partyRef('party-c'), tenantId, services); - expect(result.party.archivedAt).toEqual(Option.some(DateTime.makeUnsafe(archivedAt))); - expect(result.resolution).toEqual({ - aliasChain: [], - canonicalPartyRef: partyRef('party-c'), - kind: 'DIRECT', - requestedPartyRef: partyRef('party-c'), - }); - }), + expect(result.party.archivedAt).toEqual(Option.some(DateTime.makeUnsafe(archivedAt))); + expect(result.resolution).toEqual({ + aliasChain: [], + canonicalPartyRef: partyRef('party-c'), + kind: 'DIRECT', + requestedPartyRef: partyRef('party-c'), + }); + }), ); -it.effect( - 'Party Detail fails closed for cycles and broken historical chains without reading an alias Party', - () => - Effect.gen(function* verifyPartyDetail3() { - for (const aliases of [ - [alias('party-a', 'party-b'), alias('party-b', 'party-a')], - [alias('party-a', 'missing')], - ]) { - const { lookups, services } = makeServices(aliases); - const error = yield* Effect.flip( - readPartyDetailFromServices(partyRef('party-a'), tenantId, services), - ); - expect(Predicate.isTagged(error, 'ReadHandlerUnavailable')).toBe(true); - expect(lookups).toEqual([]); - } - }), +it.effect('Party Detail fails closed for cycles and broken historical chains without reading an alias Party', () => + Effect.gen(function* verifyPartyDetail3() { + for (const aliases of [[alias('party-a', 'party-b'), alias('party-b', 'party-a')], [alias('party-a', 'missing')]]) { + const { lookups, services } = makeServices(aliases); + const error = yield* Effect.flip(readPartyDetailFromServices(partyRef('party-a'), tenantId, services)); + expect(Predicate.isTagged(error, 'ReadHandlerUnavailable')).toBe(true); + expect(lookups).toEqual([]); + } + }), ); it.effect('Party Detail hides a missing direct Party and a cross-tenant requested reference', () => Effect.gen(function* verifyPartyDetail4() { const { lookups, services } = makeServices([]); - for (const requested of [ - partyRef('missing'), - { ...partyRef('party-c'), tenantId: otherTenantId }, - ]) { + for (const requested of [partyRef('missing'), { ...partyRef('party-c'), tenantId: otherTenantId }]) { const error = yield* Effect.flip(readPartyDetailFromServices(requested, tenantId, services)); expect(Predicate.isTagged(error, 'ReadHandlerNotFound')).toBe(true); } diff --git a/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts b/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts index 93865eaef..62bec8e53 100644 --- a/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts @@ -1,12 +1,10 @@ -import { expect, it } from 'effect-rstest'; import { Effect, Option, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makeTestDatabase } from '../../../../packages/core-runtime/tests/support/database.ts'; import { PartyFactAssertionSchema } from '../../shared/apis/party-detail.ts'; import { PartySchema } from '../../shared/domain/identity-contracts.ts'; -import { - partyDetailPermissionTarget, - readPartyDetailFromServices, -} from '../../src/api/party-detail.read.ts'; +import { partyDetailPermissionTarget, readPartyDetailFromServices } from '../../src/api/party-detail.read.ts'; import { findPartyDetailAssertions } from '../../src/services/party-detail-persistence.service.ts'; const tenantId = '11111111-1111-4111-8111-111111111111'; @@ -32,40 +30,30 @@ const wireFact = { validTo: null, value: 'Corrected name', } as const; -const fact = Schema.decodeUnknownSync(PartyFactAssertionSchema)(wireFact); +const fact = Schema.decodeSync(PartyFactAssertionSchema)(wireFact); -it.effect( - 'Party fact assertion contract exposes usable correction identities without sensitive evidence', - () => - Effect.gen(function* verifySchema1() { - expect(yield* Schema.encodeEffect(PartyFactAssertionSchema)(fact)).toEqual(wireFact); - expect(() => - Schema.decodeUnknownSync(PartyFactAssertionSchema)({ - ...wireFact, - assertionId: 'not-a-uuid', - }), - ).toThrow(); - const decodedWithSensitiveFields = yield* Schema.decodeUnknownEffect( - PartyFactAssertionSchema, - )({ +it.effect('Party fact assertion contract exposes usable correction identities without sensitive evidence', () => + Effect.gen(function* verifySchema1() { + expect(yield* Schema.encodeEffect(PartyFactAssertionSchema)(fact)).toEqual(wireFact); + expect(() => + Schema.decodeSync(PartyFactAssertionSchema)({ ...wireFact, - evidenceRefs: ['secret'], - provenance: { source: 'secret' }, - }); - expect( - yield* Schema.encodeEffect(PartyFactAssertionSchema)(decodedWithSensitiveFields), - ).toEqual(wireFact); - }), + assertionId: 'not-a-uuid', + }), + ).toThrow(); + const decodedWithSensitiveFields = yield* Schema.decodeUnknownEffect(PartyFactAssertionSchema)({ + ...wireFact, + evidenceRefs: ['secret'], + provenance: { source: 'secret' }, + }); + expect(yield* Schema.encodeEffect(PartyFactAssertionSchema)(decodedWithSensitiveFields)).toEqual(wireFact); + }), ); it('Party Detail history derives reviewer authority while current fact targets retain normal read authority', () => { expect(partyDetailPermissionTarget({ partyRef }).permission).toBe('read_party_identity'); - expect(partyDetailPermissionTarget({ includeFactHistory: false, partyRef }).permission).toBe( - 'read_party_identity', - ); - expect(partyDetailPermissionTarget({ includeFactHistory: true, partyRef }).permission).toBe( - 'review_party_identity', - ); + expect(partyDetailPermissionTarget({ includeFactHistory: false, partyRef }).permission).toBe('read_party_identity'); + expect(partyDetailPermissionTarget({ includeFactHistory: true, partyRef }).permission).toBe('review_party_identity'); }); it.effect( @@ -106,9 +94,7 @@ it.effect( Effect.sync(() => { queries.push(text); values.push(parameters); - return rows.map((row) => - Object.fromEntries(row.map((value, index) => [String(index), value])), - ); + return rows.map((row) => Object.fromEntries(row.map((value, index) => [String(index), value]))); }), ); @@ -119,7 +105,10 @@ it.effect( expect(history[0]?.value).toBe('Original name'); expect(history[0]?.state).toBe('SUPERSEDED'); const current = yield* findPartyDetailAssertions(database, tenantId, partyId, false); - expect(current).toEqual({ currentFactAssertions: [fact], factHistory: Option.none() }); + expect(current).toEqual({ + currentFactAssertions: [fact], + factHistory: Option.none(), + }); expect(values).toEqual([ [tenantId, partyId], [tenantId, partyId, 'ACTIVE', true], @@ -140,7 +129,7 @@ it.effect( find: () => Effect.succeed({ _tag: 'found' as const, - value: Schema.decodeUnknownSync(PartySchema)({ + value: Schema.decodeSync(PartySchema)({ archivedAt: null, createdAt: '2026-09-01T10:00:00.000Z', displayName: 'Corrected name', diff --git a/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts index 69760a87e..0d663c900 100644 --- a/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts @@ -1,9 +1,8 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; - import type { SQL } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; import { DateTime, Effect, Layer, Match, Option, Result, Schema, Predicate, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; import type { AresAppliedEvidence } from '../../shared/domain/ares-application.ts'; import { AresAppliedEvidenceSchema } from '../../shared/domain/ares-application.ts'; @@ -36,7 +35,7 @@ const secondOwnerId = '44444444-4444-4444-8444-444444444444'; const officialIdentifierId = '55555555-5555-4555-8555-555555555555'; const instantAsDate = (instant: string): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); const appliedEvidence = Result.getOrThrow( - Schema.decodeUnknownResult(AresAppliedEvidenceSchema)({ + Schema.decodeResult(AresAppliedEvidenceSchema)({ authorityPolicyKey: 'party_registry.ares_enrichment', authorityPolicyVersion: '1', cacheAgeSeconds: 0, @@ -127,10 +126,9 @@ const transactionHarness = ( }), select: (selection: unknown) => { selectSelections.push(selection); - // eslint-disable-next-line anti-slop/no-runtime-typeof -- The overloaded local Drizzle test double distinguishes SQL lock selections from ordinary query selections. - if (selection !== null && typeof selection === 'object' && 'lock' in selection) { + if (Predicate.isObject(selection) && 'lock' in selection) { // SAFETY: All lock selections emitted by these owner services contain a Drizzle SQL expression. - const lockQuery = new PgDialect().sqlToQuery(selection.lock as SQL); + const lockQuery = new PgDialect().sqlToQuery(selection['lock'] as SQL); if (lockQuery.params[0] === tenantIdentityWriteLockKey(tenantId)) { return query(() => []); } @@ -139,7 +137,13 @@ const transactionHarness = ( }, update: () => query(() => queuedUpdates.shift() ?? []), } as unknown as Parameters[0]; - return { deletedTargets, insertedValues, selectSelections, transaction, updateSets }; + return { + deletedTargets, + insertedValues, + selectSelections, + transaction, + updateSets, + }; }; /* eslint-enable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-chained-type-assertions */ @@ -158,12 +162,13 @@ const assertTenantLockIsFirst = (harness: ReturnType) }; it.layer( - Layer.effectDiscard( - TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-01T00:00:00.000Z'))), - ), + Layer.effectDiscard(TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-01T00:00:00.000Z')))), )('identity-persistence.service', (testIt) => { it('ended Party facts are made non-current as part of the same transition', () => { - expect(endedPartyFactTransition).toEqual({ isCurrent: false, state: 'ENDED' }); + expect(endedPartyFactTransition).toEqual({ + isCurrent: false, + state: 'ENDED', + }); }); testIt.effect('unnamed Party insertion persists no fabricated display-name assertion', () => @@ -172,20 +177,19 @@ it.layer( ...appliedEvidence, fact: 'PARTY_CANDIDATE', }; - const encodedCandidateEvidence = - yield* Schema.encodeEffect(AresAppliedEvidenceSchema)(candidateEvidence); - const harness = transactionHarness( - [], - [], - [[partyRow({ archivedAt: null, currentDisplayName: null })], []], - ); + const encodedCandidateEvidence = yield* Schema.encodeEffect(AresAppliedEvidenceSchema)(candidateEvidence); + const harness = transactionHarness([], [], [[partyRow({ archivedAt: null, currentDisplayName: null })], []]); const result = yield* insertPartyRecord( harness.transaction, tenantId, { evidenceRefs: ['source:official-record'], officialIdentifiers: [ - { identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }, + { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, ], partyType: 'ORGANIZATION', provenance: { @@ -214,53 +218,46 @@ it.layer( expect(Option.isNone(result.displayName)).toBe(true); assertTenantLockIsFirst(harness); // SAFETY: The first insert captured by insertPartyRecord targets the parties table. - expect((harness.insertedValues[0] as typeof parties.$inferInsert).currentDisplayName).toBe( - null, - ); + expect((harness.insertedValues[0] as typeof parties.$inferInsert).currentDisplayName).toBe(null); // SAFETY: The second insert captured by insertPartyRecord targets the typed fact-assertion table. - const assertions = harness - .insertedValues[1] as readonly (typeof partyFactAssertions.$inferInsert)[]; + const assertions = harness.insertedValues[1] as readonly (typeof partyFactAssertions.$inferInsert)[]; expect(assertions.map((assertion) => assertion.factKind)).toEqual(['PARTY_TYPE']); expect(assertions[0]?.externalEvidence).toEqual(encodedCandidateEvidence); }), ); - testIt.effect( - 'identity updates close the preceding assertion before accepting its replacement', - () => - Effect.gen(function* verifyIdentityPersistence() { - const current = partyRow({ archivedAt: null }); - const harness = transactionHarness( - [[current], [], [{ partyId }]], - [[{ ...current, currentDisplayName: 'New name', revision: 5 }], []], - ); - const encodedAppliedEvidence = - yield* Schema.encodeEffect(AresAppliedEvidenceSchema)(appliedEvidence); + testIt.effect('identity updates close the preceding assertion before accepting its replacement', () => + Effect.gen(function* verifyIdentityPersistence() { + const current = partyRow({ archivedAt: null }); + const harness = transactionHarness( + [[current], [], [{ partyId }]], + [[{ ...current, currentDisplayName: 'New name', revision: 5 }], []], + ); + const encodedAppliedEvidence = yield* Schema.encodeEffect(AresAppliedEvidenceSchema)(appliedEvidence); - const result = yield* updatePartyIdentityRecord(harness.transaction, tenantId, { - actionInvocationId: '66666666-6666-4666-8666-666666666666', - displayName: 'New name', - expectedRevision: 4, - externalEvidence: appliedEvidence, - partyId, - principalId: '77777777-7777-4777-8777-777777777777', - provenanceMethod: 'MANUAL', - provenanceSource: 'test', - validFrom: '2026-01-01T00:00:00.000Z', - }); - expect(Predicate.isTagged(result, 'found')).toBe(true); - assertTenantLockIsFirst(harness); - expect(harness.updateSets[1]).toEqual({ - isCurrent: false, - state: 'ENDED', - validTo: instantAsDate('2026-01-01T00:00:00.000Z'), - }); - expect(harness.insertedValues.length).toBe(1); - // SAFETY: The update service inserts only the replacement fact assertions captured here. - const assertions = harness - .insertedValues[0] as readonly (typeof partyFactAssertions.$inferInsert)[]; - expect(assertions[0]?.externalEvidence).toEqual(encodedAppliedEvidence); - }), + const result = yield* updatePartyIdentityRecord(harness.transaction, tenantId, { + actionInvocationId: '66666666-6666-4666-8666-666666666666', + displayName: 'New name', + expectedRevision: 4, + externalEvidence: appliedEvidence, + partyId, + principalId: '77777777-7777-4777-8777-777777777777', + provenanceMethod: 'MANUAL', + provenanceSource: 'test', + validFrom: '2026-01-01T00:00:00.000Z', + }); + expect(Predicate.isTagged(result, 'found')).toBe(true); + assertTenantLockIsFirst(harness); + expect(harness.updateSets[1]).toEqual({ + isCurrent: false, + state: 'ENDED', + validTo: instantAsDate('2026-01-01T00:00:00.000Z'), + }); + expect(harness.insertedValues.length).toBe(1); + // SAFETY: The update service inserts only the replacement fact assertions captured here. + const assertions = harness.insertedValues[0] as readonly (typeof partyFactAssertions.$inferInsert)[]; + expect(assertions[0]?.externalEvidence).toEqual(encodedAppliedEvidence); + }), ); it('unarchive owner classification distinguishes conflict from ambiguity deterministically', () => { @@ -302,30 +299,28 @@ it.layer( }), ); - testIt.effect( - 'unarchive keeps the Party archived when an exact claim belongs to another Party', - () => - Effect.gen(function* verifyIdentityPersistence() { - const harness = transactionHarness([ - [partyRow()], - [], - [{ partyId }], - [], - [identifierRow()], - [{}], - [{ partyId: firstOwnerId }], - ]); + testIt.effect('unarchive keeps the Party archived when an exact claim belongs to another Party', () => + Effect.gen(function* verifyIdentityPersistence() { + const harness = transactionHarness([ + [partyRow()], + [], + [{ partyId }], + [], + [identifierRow()], + [{}], + [{ partyId: firstOwnerId }], + ]); - const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); + const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); - expect(Predicate.isTagged(result, 'identity_conflict')).toBe(true); - expect(Struct.omit(result, ['_tag'])).toEqual({ - conflictingPartyId: firstOwnerId, - }); - assertTenantLockIsFirst(harness); - expect(harness.insertedValues).toEqual([]); - expect(harness.updateSets).toEqual([]); - }), + expect(Predicate.isTagged(result, 'identity_conflict')).toBe(true); + expect(Struct.omit(result, ['_tag'])).toEqual({ + conflictingPartyId: firstOwnerId, + }); + assertTenantLockIsFirst(harness); + expect(harness.insertedValues).toEqual([]); + expect(harness.updateSets).toEqual([]); + }), ); testIt.effect( @@ -363,13 +358,7 @@ it.layer( [], [[caseRow], [], [{ matchDecisionId: decisionId }]], ); - const result = yield* unarchivePartyWithReview( - harness.transaction, - tenantId, - partyId, - 4, - decisionId, - ); + const result = yield* unarchivePartyWithReview(harness.transaction, tenantId, partyId, 4, decisionId); expect(Predicate.isTagged(result, 'blocked')).toBe(true); const blocked = Match.value(result).pipe( Match.tag('blocked', ({ value }) => value), @@ -384,18 +373,14 @@ it.layer( expect(harness.deletedTargets).toEqual([]); expect(harness.insertedValues.length).toBe(3); // SAFETY: These are precisely the case, membership and decision inserts captured from the owner service. - const persistedCase = harness - .insertedValues[0] as typeof duplicateCandidateCases.$inferInsert; + const persistedCase = harness.insertedValues[0] as typeof duplicateCandidateCases.$inferInsert; // SAFETY: The second insert is the case's deterministic membership set. - const members = harness - .insertedValues[1] as readonly (typeof duplicateCandidateCaseParties.$inferInsert)[]; + const members = harness.insertedValues[1] as readonly (typeof duplicateCandidateCaseParties.$inferInsert)[]; // SAFETY: The third insert is the durable Action-linked match decision. const decision = harness.insertedValues[2] as typeof partyMatchDecisions.$inferInsert; expect(persistedCase.candidateSnapshot.intent).toBe('UNARCHIVE'); expect(persistedCase.candidateSnapshot.names).toEqual(['Archived organization']); - expect(persistedCase.candidateSnapshot.officialIdentifiers?.[0]?.normalizedValue).toBe( - '27074358', - ); + expect(persistedCase.candidateSnapshot.officialIdentifiers?.[0]?.normalizedValue).toBe('27074358'); expect(persistedCase.evaluationFingerprint).toMatch(/^[0-9a-f]{64}$/u); expect(members.map((member) => member.partyId)).toEqual([partyId, firstOwnerId]); expect(decision.actionInvocationId).toBe(decisionId); @@ -403,9 +388,7 @@ it.layer( expect(decision.outcome).toBe('AMBIGUOUS'); expect(blocked.reasonCode).toBe('EXACT_CLAIM_CONFLICT'); expect(Option.isSome(blocked.party.archivedAt)).toBe(true); - expect(DateTime.formatIso(Option.getOrThrow(blocked.party.archivedAt))).toBe( - '2026-01-01T00:00:00.000Z', - ); + expect(DateTime.formatIso(Option.getOrThrow(blocked.party.archivedAt))).toBe('2026-01-01T00:00:00.000Z'); expect(blocked.party.revision).toBe(4); const secondDecisionId = '88888888-8888-4888-8888-888888888888'; @@ -414,13 +397,7 @@ it.layer( [], [[{ matchDecisionId: secondDecisionId }]], ); - const retry = yield* unarchivePartyWithReview( - retryHarness.transaction, - tenantId, - partyId, - 4, - secondDecisionId, - ); + const retry = yield* unarchivePartyWithReview(retryHarness.transaction, tenantId, partyId, 4, secondDecisionId); expect(Predicate.isTagged(retry, 'blocked')).toBe(true); const retryBlocked = Match.value(retry).pipe( Match.tag('blocked', ({ value }) => value), @@ -438,98 +415,82 @@ it.layer( }), ); - testIt.effect( - 'unresolved unnamed unarchive review persists no invented display-name evidence', - () => - Effect.gen(function* verifyUnresolvedUnarchiveReview() { - const current = partyRow({ currentDisplayName: null, currentType: 'UNRESOLVED' }); - const caseRow = { - candidateCaseId: firstOwnerId, - candidateFingerprint: 'b'.repeat(64), - evaluatedEvidence: [], - matchRuleVersion: 'party-exact-claims.v1', - }; - const harness = transactionHarness( - [[current], [], [{ partyId }], [], [], [current], [], [], []], - [], - [[caseRow], [], [{ matchDecisionId: secondOwnerId }]], - ); - const result = yield* unarchivePartyWithReview( - harness.transaction, - tenantId, - partyId, - 4, - secondOwnerId, - ); - expect(Predicate.isTagged(result, 'blocked')).toBe(true); - const blocked = Match.value(result).pipe( - Match.tag('blocked', ({ value }) => value), - Match.orElse(() => - (() => { - throw new Error('Expected unarchive to be blocked'); - })(), - ), - ); - // SAFETY: The first captured insert is the immutable candidate case. - const persistedCase = harness - .insertedValues[0] as typeof duplicateCandidateCases.$inferInsert; - expect(persistedCase.candidateSnapshot.names).toEqual([]); - expect(persistedCase.candidateSnapshot.officialIdentifiers).toEqual([]); - expect(harness.updateSets).toEqual([]); - expect(blocked.reasonCode).toBe('UNRESOLVED_IDENTITY'); - }), + testIt.effect('unresolved unnamed unarchive review persists no invented display-name evidence', () => + Effect.gen(function* verifyUnresolvedUnarchiveReview() { + const current = partyRow({ + currentDisplayName: null, + currentType: 'UNRESOLVED', + }); + const caseRow = { + candidateCaseId: firstOwnerId, + candidateFingerprint: 'b'.repeat(64), + evaluatedEvidence: [], + matchRuleVersion: 'party-exact-claims.v1', + }; + const harness = transactionHarness( + [[current], [], [{ partyId }], [], [], [current], [], [], []], + [], + [[caseRow], [], [{ matchDecisionId: secondOwnerId }]], + ); + const result = yield* unarchivePartyWithReview(harness.transaction, tenantId, partyId, 4, secondOwnerId); + expect(Predicate.isTagged(result, 'blocked')).toBe(true); + const blocked = Match.value(result).pipe( + Match.tag('blocked', ({ value }) => value), + Match.orElse(() => + (() => { + throw new Error('Expected unarchive to be blocked'); + })(), + ), + ); + // SAFETY: The first captured insert is the immutable candidate case. + const persistedCase = harness.insertedValues[0] as typeof duplicateCandidateCases.$inferInsert; + expect(persistedCase.candidateSnapshot.names).toEqual([]); + expect(persistedCase.candidateSnapshot.officialIdentifiers).toEqual([]); + expect(harness.updateSets).toEqual([]); + expect(blocked.reasonCode).toBe('UNRESOLVED_IDENTITY'); + }), ); testIt.effect('archive acquires the tenant identity lock before any Party row lock', () => Effect.gen(function* verifyIdentityPersistence() { const harness = transactionHarness([[]]); - const result = yield* transitionPartyRecord( - harness.transaction, - tenantId, - partyId, - 4, - 'ARCHIVED', - ); + const result = yield* transitionPartyRecord(harness.transaction, tenantId, partyId, 4, 'ARCHIVED'); expect(Predicate.isTagged(result, 'not_found')).toBe(true); assertTenantLockIsFirst(harness); }), ); - testIt.effect( - 'unarchive restores an unclaimed eligible identifier before activating the Party', - () => - Effect.gen(function* verifyIdentityPersistence() { - const activeParty = partyRow({ archivedAt: null, revision: 5 }); - const harness = transactionHarness( - [[partyRow()], [], [{ partyId }], [], [identifierRow()], [{}], []], - [[activeParty]], - ); + testIt.effect('unarchive restores an unclaimed eligible identifier before activating the Party', () => + Effect.gen(function* verifyIdentityPersistence() { + const activeParty = partyRow({ archivedAt: null, revision: 5 }); + const harness = transactionHarness( + [[partyRow()], [], [{ partyId }], [], [identifierRow()], [{}], []], + [[activeParty]], + ); - const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); + const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); - expect(Predicate.isTagged(result, 'found')).toBe(true); - expect(harness.insertedValues).toEqual([ - [ - { - identifierTypeKey: 'ICO', - namespace: 'CZ:ICO', - normalizedValue: '27074358', - officialIdentifierId, - partyId, - tenantId, - }, - ], - ]); - expect(harness.updateSets.length).toBe(1); - // SAFETY: Unarchive's only update targets the parties table; the harness captures its exact set value. - expect( - Object.fromEntries( - Object.entries(harness.updateSets[0] as Partial).filter( - ([key]) => key !== 'updatedAt', - ), - ), - ).toEqual({ archivedAt: null, revision: 5 }); - }), + expect(Predicate.isTagged(result, 'found')).toBe(true); + expect(harness.insertedValues).toEqual([ + [ + { + identifierTypeKey: 'ICO', + namespace: 'CZ:ICO', + normalizedValue: '27074358', + officialIdentifierId, + partyId, + tenantId, + }, + ], + ]); + expect(harness.updateSets.length).toBe(1); + // SAFETY: Unarchive's only update targets the parties table; the harness captures its exact set value. + expect( + Object.fromEntries( + Object.entries(harness.updateSets[0] as Partial).filter(([key]) => key !== 'updatedAt'), + ), + ).toEqual({ archivedAt: null, revision: 5 }); + }), ); testIt.effect('unarchive rejects an alias rather than forwarding the write to its survivor', () => @@ -541,9 +502,7 @@ it.layer( [{ partyId: firstOwnerId }], ]); - const error = yield* Effect.flip( - unarchivePartyRecord(harness.transaction, tenantId, partyId, 4), - ); + const error = yield* Effect.flip(unarchivePartyRecord(harness.transaction, tenantId, partyId, 4)); expect(Predicate.isTagged(error, 'PartyAliasWriteRejected')).toBe(true); expect(harness.insertedValues).toEqual([]); expect(harness.updateSets).toEqual([]); @@ -596,77 +555,55 @@ it.layer( }), ); - testIt.effect( - 'unarchive requires review while a duplicate case involving the Party remains open', - () => - Effect.gen(function* verifyIdentityPersistence() { - const caseId = '88888888-8888-4888-8888-888888888888'; - const harness = transactionHarness([ - [partyRow()], - [], - [{ partyId }], - [{ candidateCaseId: caseId }], - ]); - const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); - expect(Predicate.isTagged(result, 'review_required')).toBe(true); - expect(Struct.omit(result, ['_tag'])).toEqual({ - caseIds: [caseId], - reasonCode: 'OPEN_DUPLICATE_CASE', - }); - expect(harness.insertedValues).toEqual([]); - expect(harness.updateSets).toEqual([]); - }), + testIt.effect('unarchive requires review while a duplicate case involving the Party remains open', () => + Effect.gen(function* verifyIdentityPersistence() { + const caseId = '88888888-8888-4888-8888-888888888888'; + const harness = transactionHarness([[partyRow()], [], [{ partyId }], [{ candidateCaseId: caseId }]]); + const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); + expect(Predicate.isTagged(result, 'review_required')).toBe(true); + expect(Struct.omit(result, ['_tag'])).toEqual({ + caseIds: [caseId], + reasonCode: 'OPEN_DUPLICATE_CASE', + }); + expect(harness.insertedValues).toEqual([]); + expect(harness.updateSets).toEqual([]); + }), ); - testIt.effect( - 'unarchive requires review for unresolved identity without any eligible strong claim', - () => - Effect.gen(function* verifyIdentityPersistence() { - const harness = transactionHarness([ - [partyRow({ currentType: 'UNRESOLVED' })], - [], - [{ partyId }], - [], - [], - ]); - const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); - expect(Predicate.isTagged(result, 'review_required')).toBe(true); - expect(Struct.omit(result, ['_tag'])).toEqual({ - caseIds: [], - reasonCode: 'UNRESOLVED_IDENTITY', - }); - expect(harness.insertedValues).toEqual([]); - expect(harness.updateSets).toEqual([]); - }), + testIt.effect('unarchive requires review for unresolved identity without any eligible strong claim', () => + Effect.gen(function* verifyIdentityPersistence() { + const harness = transactionHarness([[partyRow({ currentType: 'UNRESOLVED' })], [], [{ partyId }], [], []]); + const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); + expect(Predicate.isTagged(result, 'review_required')).toBe(true); + expect(Struct.omit(result, ['_tag'])).toEqual({ + caseIds: [], + reasonCode: 'UNRESOLVED_IDENTITY', + }); + expect(harness.insertedValues).toEqual([]); + expect(harness.updateSets).toEqual([]); + }), ); - testIt.effect( - 'reviewed UNRESOLVED Party can unarchive using retained accepted creation evidence', - () => - Effect.gen(function* restoreReviewedUnresolved() { - const current = partyRow({ currentType: 'UNRESOLVED' }); - const harness = transactionHarness( - [ - [current], - [], - [{ partyId }], - [], - [{ candidateCaseId: '88888888-8888-4888-8888-888888888888' }], - [], - [], - ], - [[{ ...current, archivedAt: null, revision: 5 }]], - ); - const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); - expect(Predicate.isTagged(result, 'found')).toBe(true); - expect(harness.updateSets.length).toBe(1); - expect(harness.insertedValues).toEqual([]); - }), + testIt.effect('reviewed UNRESOLVED Party can unarchive using retained accepted creation evidence', () => + Effect.gen(function* restoreReviewedUnresolved() { + const current = partyRow({ currentType: 'UNRESOLVED' }); + const harness = transactionHarness( + [[current], [], [{ partyId }], [], [{ candidateCaseId: '88888888-8888-4888-8888-888888888888' }], [], []], + [[{ ...current, archivedAt: null, revision: 5 }]], + ); + const result = yield* unarchivePartyRecord(harness.transaction, tenantId, partyId, 4); + expect(Predicate.isTagged(result, 'found')).toBe(true); + expect(harness.updateSets.length).toBe(1); + expect(harness.insertedValues).toEqual([]); + }), ); testIt.effect('Party type enrichment refuses another owner of a newly eligible identifier', () => Effect.gen(function* preventEnrichmentCollision() { - const current = partyRow({ archivedAt: null, currentType: 'UNRESOLVED' }); + const current = partyRow({ + archivedAt: null, + currentType: 'UNRESOLVED', + }); const harness = transactionHarness([ [current], [], @@ -703,7 +640,10 @@ it.layer( testIt.effect('Party type enrichment atomically claims identifiers that newly qualify', () => Effect.gen(function* claimEnrichedIdentifier() { - const current = partyRow({ archivedAt: null, currentType: 'UNRESOLVED' }); + const current = partyRow({ + archivedAt: null, + currentType: 'UNRESOLVED', + }); const harness = transactionHarness( [[current], [], [{ partyId }], [], [identifierRow()], [{}], [], []], [[{ ...current, currentType: 'ORGANIZATION', revision: 5 }], []], @@ -743,66 +683,62 @@ it.layer( }), ); - testIt.effect( - 'type correction reconciliation releases an ICO claim no longer eligible for a PERSON', - () => - Effect.gen(function* releaseIneligibleClaim() { - const harness = transactionHarness([ - [identifierRow()], - [ - { - identifierClaimId: '99999999-9999-4999-8999-999999999999', - identifierTypeKey: 'ICO', - namespace: 'CZ:ICO', - normalizedValue: '27074358', - officialIdentifierId, - partyId, - tenantId, - }, - ], - ]); - const result = yield* reconcilePartyIdentifierClaims( - harness.transaction, - tenantId, - partyId, - 'PERSON', - ); - expect(Predicate.isTagged(result, 'available')).toBe(true); - expect(Struct.omit(result, ['_tag'])).toEqual({ eligibleClaimCount: 0 }); - expect(harness.deletedTargets.length).toBe(1); - expect(harness.insertedValues).toEqual([]); - assertTenantLockIsFirst(harness); - }), + testIt.effect('type correction reconciliation releases an ICO claim no longer eligible for a PERSON', () => + Effect.gen(function* releaseIneligibleClaim() { + const harness = transactionHarness([ + [identifierRow()], + [ + { + identifierClaimId: '99999999-9999-4999-8999-999999999999', + identifierTypeKey: 'ICO', + namespace: 'CZ:ICO', + normalizedValue: '27074358', + officialIdentifierId, + partyId, + tenantId, + }, + ], + ]); + const result = yield* reconcilePartyIdentifierClaims(harness.transaction, tenantId, partyId, 'PERSON'); + expect(Predicate.isTagged(result, 'available')).toBe(true); + expect(Struct.omit(result, ['_tag'])).toEqual({ + eligibleClaimCount: 0, + }); + expect(harness.deletedTargets.length).toBe(1); + expect(harness.insertedValues).toEqual([]); + assertTenantLockIsFirst(harness); + }), ); - testIt.effect( - 'identity updates reject a historical end earlier than the assertion being replaced', - () => - Effect.gen(function* rejectInvalidHistoricalInterval() { - const harness = transactionHarness([ - [partyRow({ archivedAt: null })], - [], - [{ partyId }], - [{ validFrom: instantAsDate('2026-05-01T00:00:00.000Z') }], - ]); - const result = yield* updatePartyIdentityRecord(harness.transaction, tenantId, { - actionInvocationId: '66666666-6666-4666-8666-666666666666', - displayName: 'Historical name', - expectedRevision: 4, - partyId, - principalId: '77777777-7777-4777-8777-777777777777', - provenanceMethod: 'MANUAL', - provenanceSource: 'test', - validFrom: '2026-01-01T00:00:00.000Z', - }); - expect(Predicate.isTagged(result, 'conflict')).toBe(true); - assertNoIdentityWrites(harness); - }), + testIt.effect('identity updates reject a historical end earlier than the assertion being replaced', () => + Effect.gen(function* rejectInvalidHistoricalInterval() { + const harness = transactionHarness([ + [partyRow({ archivedAt: null })], + [], + [{ partyId }], + [{ validFrom: instantAsDate('2026-05-01T00:00:00.000Z') }], + ]); + const result = yield* updatePartyIdentityRecord(harness.transaction, tenantId, { + actionInvocationId: '66666666-6666-4666-8666-666666666666', + displayName: 'Historical name', + expectedRevision: 4, + partyId, + principalId: '77777777-7777-4777-8777-777777777777', + provenanceMethod: 'MANUAL', + provenanceSource: 'test', + validFrom: '2026-01-01T00:00:00.000Z', + }); + expect(Predicate.isTagged(result, 'conflict')).toBe(true); + assertNoIdentityWrites(harness); + }), ); testIt.effect('type enrichment rejects unevidenced type before accepting facts or claims', () => Effect.gen(function* rejectUnsupportedType() { - const current = partyRow({ archivedAt: null, currentType: 'UNRESOLVED' }); + const current = partyRow({ + archivedAt: null, + currentType: 'UNRESOLVED', + }); const harness = transactionHarness([[current], [], [{ partyId }]]); const error = yield* Effect.flip( updatePartyIdentityRecord(harness.transaction, tenantId, { diff --git a/app/verticals/party-registry/tests/unit/matching-contract.test.ts b/app/verticals/party-registry/tests/unit/matching-contract.test.ts index 9493e2ac6..c4988375a 100644 --- a/app/verticals/party-registry/tests/unit/matching-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/matching-contract.test.ts @@ -1,17 +1,17 @@ +import { Effect } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect } from 'effect'; import { createActionCollector } from '../../../../packages/core-runtime/src/actions/collector.ts'; import { getActionHandler } from '../../../../packages/core-runtime/src/actions/definition.ts'; -import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; import { makePartyRef } from '../../shared/domain/identity-contracts.ts'; import { evaluateExactClaims, sortClaimKeys } from '../../shared/domain/matching-contracts.ts'; -import { tenantClaimLockKeys } from '../../src/services/party-identifier-claim.service.ts'; +import { makeDuplicateCandidateCaseRef } from '../../shared/resources/duplicate-candidate-case.ts'; import { confirmDuplicatePartiesAction } from '../../src/actions/confirm-duplicate-parties.action.ts'; import { dismissDuplicateCandidateAction } from '../../src/actions/dismiss-duplicate-candidate.action.ts'; import { markDuplicateCandidateNeedsEvidenceAction } from '../../src/actions/mark-duplicate-candidate-needs-evidence.action.ts'; import { resolveDuplicateCandidateCreateAction } from '../../src/actions/resolve-duplicate-candidate-create.action.ts'; import { resolveDuplicateCandidateMatchAction } from '../../src/actions/resolve-duplicate-candidate-match.action.ts'; +import { tenantClaimLockKeys } from '../../src/services/party-identifier-claim.service.ts'; const evidenceTenantId = '10000000-0000-4000-8000-000000000001'; const evidencePayload = { @@ -26,70 +26,67 @@ const evidenceScope = { tenantId: evidenceTenantId, }; -it.effect( - 'reviewed Create records metadata-only invariant evidence and commits its created event', - () => - Effect.gen(function* reviewedCreateEvidence() { - const collector = createActionCollector( - resolveDuplicateCandidateCreateAction.descriptor.domainEvents, - 'party.registry', - resolveDuplicateCandidateCreateAction.descriptor.accessEvidencePolicy, - ); - yield* getActionHandler(resolveDuplicateCandidateCreateAction)(evidencePayload, { - ...collector, - actionInvocationId: '40000000-0000-4000-8000-000000000001', - scope: evidenceScope, - services: { - resolve: () => - Effect.succeed({ - caseRef: evidencePayload.caseRef, - decisionRef: null, - lifecycleState: 'RESOLVED', - outcome: 'CREATE_NEW', - partyRef: makePartyRef(evidenceTenantId, '50000000-0000-4000-8000-000000000001'), - }), - }, - }); - expect(collector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe('metadata_only'); - expect(collector.snapshot().domainEvents.length).toBe(1); - expect(collector.snapshot().outboxMessages.length).toBe(1); - }), +it.effect('reviewed Create records metadata-only invariant evidence and commits its created event', () => + Effect.gen(function* reviewedCreateEvidence() { + const collector = createActionCollector( + resolveDuplicateCandidateCreateAction.descriptor.domainEvents, + 'party.registry', + resolveDuplicateCandidateCreateAction.descriptor.accessEvidencePolicy, + ); + yield* getActionHandler(resolveDuplicateCandidateCreateAction)(evidencePayload, { + ...collector, + actionInvocationId: '40000000-0000-4000-8000-000000000001', + scope: evidenceScope, + services: { + resolve: () => + Effect.succeed({ + caseRef: evidencePayload.caseRef, + decisionRef: null, + lifecycleState: 'RESOLVED', + outcome: 'CREATE_NEW', + partyRef: makePartyRef(evidenceTenantId, '50000000-0000-4000-8000-000000000001'), + }), + }, + }); + expect(collector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe('metadata_only'); + expect(collector.snapshot().domainEvents.length).toBe(1); + expect(collector.snapshot().outboxMessages.length).toBe(1); + }), ); -it.effect( - 'reviewed duplicate confirmation records safe invariant evidence without executing merge', - () => - Effect.gen(function* confirmationEvidence() { - const collector = createActionCollector( - confirmDuplicatePartiesAction.descriptor.domainEvents, - 'party.registry', - confirmDuplicatePartiesAction.descriptor.accessEvidencePolicy, - ); - yield* getActionHandler(confirmDuplicatePartiesAction)(evidencePayload, { - ...collector, - actionInvocationId: '40000000-0000-4000-8000-000000000001', - scope: evidenceScope, - services: { - resolve: () => - Effect.succeed({ - caseRef: evidencePayload.caseRef, - decisionRef: null, - lifecycleState: 'RESOLVED', - outcome: 'CONFIRMED_DUPLICATE_PARTIES', - partyRef: null, - }), - }, - }); - expect(collector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe('metadata_only'); - expect(collector.snapshot().domainEvents).toEqual([]); - expect(collector.snapshot().outboxMessages).toEqual([]); - }), +it.effect('reviewed duplicate confirmation records safe invariant evidence without executing merge', () => + Effect.gen(function* confirmationEvidence() { + const collector = createActionCollector( + confirmDuplicatePartiesAction.descriptor.domainEvents, + 'party.registry', + confirmDuplicatePartiesAction.descriptor.accessEvidencePolicy, + ); + yield* getActionHandler(confirmDuplicatePartiesAction)(evidencePayload, { + ...collector, + actionInvocationId: '40000000-0000-4000-8000-000000000001', + scope: evidenceScope, + services: { + resolve: () => + Effect.succeed({ + caseRef: evidencePayload.caseRef, + decisionRef: null, + lifecycleState: 'RESOLVED', + outcome: 'CONFIRMED_DUPLICATE_PARTIES', + partyRef: null, + }), + }, + }); + expect(collector.snapshot().dataAccessEvents[0]?.evidenceCaptureMode).toBe('metadata_only'); + expect(collector.snapshot().domainEvents).toEqual([]); + expect(collector.snapshot().outboxMessages).toEqual([]); + }), ); it('claim locks are acquired in one deterministic order', () => { - expect( - sortClaimKeys(['CZ_DIC\u0000cz:dic\u0000CZ27074358', 'ICO\u0000cz:ico\u000027074358']), - ).toEqual(['CZ_DIC\u0000cz:dic\u0000CZ27074358', 'ICO\u0000cz:ico\u000027074358']); + expect(sortClaimKeys(['CZ_DIC\u0000cz:dic\u0000CZ27074358', 'ICO\u0000cz:ico\u000027074358'])).toEqual([ + 'CZ_DIC\u0000cz:dic\u0000CZ27074358', + 'ICO\u0000cz:ico\u000027074358', + ]); }); it('all Duplicate Candidate resolutions are reviewed, idempotent tenant Actions', () => { @@ -129,10 +126,7 @@ it('claim lock identity is tenant-qualified, normalized, sorted, and deduplicate verification: 'VERIFIED', }, ]), - ).toEqual([ - '["tenant-b","CZ_DIC","CZ:DIC","CZ27074358"]', - '["tenant-b","ICO","CZ:ICO","27074358"]', - ]); + ).toEqual(['["tenant-b","CZ_DIC","CZ:DIC","CZ27074358"]', '["tenant-b","ICO","CZ:ICO","27074358"]']); expect( tenantClaimLockKeys('tenant-a', [ { @@ -165,18 +159,18 @@ it('claim lock identity is tenant-qualified, normalized, sorted, and deduplicate }); it('authoritative exact claims cannot be outvoted by weak evidence', () => { - expect(evaluateExactClaims([])).toEqual({ outcome: 'NO_MATCH', partyIds: [] }); + expect(evaluateExactClaims([])).toEqual({ + outcome: 'NO_MATCH', + partyIds: [], + }); expect(evaluateExactClaims(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'])).toEqual({ outcome: 'MATCHED', partyIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], }); - expect( - evaluateExactClaims([ - 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - ]), - ).toEqual({ - outcome: 'AMBIGUOUS', - partyIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'], - }); + expect(evaluateExactClaims(['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'])).toEqual( + { + outcome: 'AMBIGUOUS', + partyIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'], + }, + ); }); diff --git a/app/verticals/party-registry/tests/unit/matching-persistence.test.ts b/app/verticals/party-registry/tests/unit/matching-persistence.test.ts index edbdffead..6ea6ce2b2 100644 --- a/app/verticals/party-registry/tests/unit/matching-persistence.test.ts +++ b/app/verticals/party-registry/tests/unit/matching-persistence.test.ts @@ -1,9 +1,8 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; - /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- This harness implements the narrow Drizzle Effect boundary exercised by the owner-local matching service. expires: 2026-12-31. */ import type { SQL } from 'drizzle-orm'; import { DateTime, Effect, Layer, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; import { createActionCollector } from '../../../../packages/core-runtime/src/actions/collector.ts'; import { getActionHandler } from '../../../../packages/core-runtime/src/actions/definition.ts'; @@ -180,9 +179,7 @@ const harness = (queues: ReadonlyMap = new Map()) => { return chain; }; // SAFETY: this test double implements exactly the fluent methods called by the persistence seam. - const transaction = { insert, select, update } as unknown as Parameters< - typeof resolveDuplicateCandidateMatch - >[0]; + const transaction = { insert, select, update } as unknown as Parameters[0]; return { inserts, reads, transaction, updates }; }; @@ -198,9 +195,7 @@ const ambiguousHarness = (existingCase: boolean) => ); it.layer( - Layer.effectDiscard( - TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-01T00:00:00.000Z'))), - ), + Layer.effectDiscard(TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-01T00:00:00.000Z')))), )('matching-persistence', (testIt) => { testIt.effect( 'durable Party Match commits an ambiguity decision, complete case references, and original evidence without mutating a Party', @@ -233,10 +228,9 @@ it.layer( ]); expect(recordedRow(subject.inserts[2]?.values)['candidateCaseId']).toBe(candidateCaseId); expect(subject.updates.length).toBe(0); - expect( - subject.reads[0]?.table, - 'tenant serialization lock precedes row/claim reads', - ).not.toBe(partyIdentifierClaims); + expect(subject.reads[0]?.table, 'tenant serialization lock precedes row/claim reads').not.toBe( + partyIdentifierClaims, + ); }), ); @@ -264,7 +258,11 @@ it.layer( actionInvocationId, candidate: candidate({ officialIdentifiers: [ - { identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }, + { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, ], partyType: 'PERSON', subjectEvidence: [ @@ -281,9 +279,7 @@ it.layer( tenantId, }); expect(result.outcome).toBe('NO_MATCH'); - const durable = recordedRow( - subject.inserts.find(({ table }) => table === partyMatchDecisions)?.values, - ); + const durable = recordedRow(subject.inserts.find(({ table }) => table === partyMatchDecisions)?.values); expect(durable['operation']).toBe('MATCH'); expect(durable['committedCreateOutcome']).toBe(null); expect(result.candidateParties).toEqual([]); @@ -317,7 +313,10 @@ it.layer( [ { ...original, - candidateSnapshot: { ...original.candidateSnapshot, intent: 'UNARCHIVE' }, + candidateSnapshot: { + ...original.candidateSnapshot, + intent: 'UNARCHIVE', + }, }, ], ], @@ -326,12 +325,8 @@ it.layer( ); const error = resolution === 'CREATE' - ? yield* Effect.flip( - resolveDuplicateCandidateCreate(subject.transaction, resolutionInput), - ) - : yield* Effect.flip( - resolveDuplicateCandidateMatch(subject.transaction, resolutionInput), - ); + ? yield* Effect.flip(resolveDuplicateCandidateCreate(subject.transaction, resolutionInput)) + : yield* Effect.flip(resolveDuplicateCandidateMatch(subject.transaction, resolutionInput)); expect(Predicate.isTagged(error, 'DuplicateCandidateConflict')).toBe(true); expect(error.reason).toMatch(/unarchive/iu); expect(subject.inserts).toEqual([]); @@ -391,15 +386,10 @@ it.layer( }, ); expect(result.outcome).toBe('MATCHED_EXISTING'); - const durable = recordedRow( - subject.inserts.find(({ table }) => table === partyMatchDecisions)?.values, - ); + const durable = recordedRow(subject.inserts.find(({ table }) => table === partyMatchDecisions)?.values); expect(durable['operation']).toBe('CREATE'); expect(durable['committedCreateOutcome']).toBe('MATCHED_EXISTING'); - expect( - 'addedOfficialIdentifierRefs' in result, - 'mutation metadata stays private to the Action', - ).toBe(false); + expect('addedOfficialIdentifierRefs' in result, 'mutation metadata stays private to the Action').toBe(false); const evidence = collector.snapshot(); expect(evidence.domainEvents.map((event) => event.eventType)).toEqual([ 'party.registry.official-identifier-added.v1', @@ -466,159 +456,156 @@ it.layer( ]); expect(evidence.outboxMessages.length).toBe(1); expect(evidence.outboxMessages[0]?.domainEventIndex).toBe(0); - expect(evidence.outboxMessages[0]?.message.topic).toBe( - 'party.registry.official-identifier-added.v1', - ); - expect(evidence.outboxMessages[0]?.message.payloadJson).toEqual( - evidence.domainEvents[0]?.payloadJson, - ); + expect(evidence.outboxMessages[0]?.message.topic).toBe('party.registry.official-identifier-added.v1'); + expect(evidence.outboxMessages[0]?.message.payloadJson).toEqual(evidence.domainEvents[0]?.payloadJson); }), ); - testIt.effect( - 'matched Create reusing an existing identifier does not republish an acceptance event', - () => - Effect.gen(function* matchedCreateReusingAnExistingIdentifierDoes() { - const subject = harness( - new Map([ - [partyIdentifierClaims, [[{ partyId: partyA }]]], - [parties, [[activePartyRow(partyA)]]], + testIt.effect('matched Create reusing an existing identifier does not republish an acceptance event', () => + Effect.gen(function* matchedCreateReusingAnExistingIdentifierDoes() { + const subject = harness( + new Map([ + [partyIdentifierClaims, [[{ partyId: partyA }]]], + [parties, [[activePartyRow(partyA)]]], + [ + partyOfficialIdentifiers, [ - partyOfficialIdentifiers, [ - [ - { - acceptedByActionInvocationId: 'prior-acceptance', - officialIdentifierId, - partyId: partyA, - }, - ], + { + acceptedByActionInvocationId: 'prior-acceptance', + officialIdentifierId, + partyId: partyA, + }, ], ], - ]), - ); - const collector = createActionCollector( - createPartyAction.descriptor.domainEvents, - 'party.registry', - createPartyAction.descriptor.accessEvidencePolicy, - ); - const result = yield* getActionHandler(createPartyAction)( - { - candidate: candidate({ - officialIdentifiers: [ - { identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }, - { identifierType: 'CZ_DIC', value: 'CZ27074358', verification: 'UNVERIFIED' }, - ], - }), - }, - { - ...collector, - actionInvocationId, - scope: actionScope, - services: { - createOrMatch: (value, invocationId) => - createOrMatchParty(subject.transaction, { - actionInvocationId: invocationId, - candidate: value, - principalId, - tenantId, - }), - }, + ], + ]), + ); + const collector = createActionCollector( + createPartyAction.descriptor.domainEvents, + 'party.registry', + createPartyAction.descriptor.accessEvidencePolicy, + ); + const result = yield* getActionHandler(createPartyAction)( + { + candidate: candidate({ + officialIdentifiers: [ + { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, + { + identifierType: 'CZ_DIC', + value: 'CZ27074358', + verification: 'UNVERIFIED', + }, + ], + }), + }, + { + ...collector, + actionInvocationId, + scope: actionScope, + services: { + createOrMatch: (value, invocationId) => + createOrMatchParty(subject.transaction, { + actionInvocationId: invocationId, + candidate: value, + principalId, + tenantId, + }), }, - ); - expect(result.outcome).toBe('MATCHED_EXISTING'); - expect(collector.snapshot().domainEvents).toEqual([]); - expect(collector.snapshot().outboxMessages).toEqual([]); - expect(subject.inserts.some(({ table }) => table === partyOfficialIdentifiers)).toBe(false); - }), + }, + ); + expect(result.outcome).toBe('MATCHED_EXISTING'); + expect(collector.snapshot().domainEvents).toEqual([]); + expect(collector.snapshot().outboxMessages).toEqual([]); + expect(subject.inserts.some(({ table }) => table === partyOfficialIdentifiers)).toBe(false); + }), ); - testIt.effect( - 'repeated Candidate facts accepted in one matching transaction publish one identifier event', - () => - Effect.gen(function* repeatedCandidateFactsAcceptedInOneMatching() { - const subject = harness( - new Map([ - [partyIdentifierClaims, [[{ partyId: partyA }]]], - [parties, [[activePartyRow(partyA)]]], + testIt.effect('repeated Candidate facts accepted in one matching transaction publish one identifier event', () => + Effect.gen(function* repeatedCandidateFactsAcceptedInOneMatching() { + const subject = harness( + new Map([ + [partyIdentifierClaims, [[{ partyId: partyA }]]], + [parties, [[activePartyRow(partyA)]]], + [ + partyOfficialIdentifiers, [ - partyOfficialIdentifiers, + [], [ - [], - [ - { - acceptedByActionInvocationId: actionInvocationId, - officialIdentifierId, - partyId: partyA, - }, - ], + { + acceptedByActionInvocationId: actionInvocationId, + officialIdentifierId, + partyId: partyA, + }, ], ], - ]), - ); - const identifier = { - identifierType: 'CZ_DIC' as const, - value: 'CZ27074358', - verification: 'UNVERIFIED' as const, - }; - const collector = createActionCollector( - createPartyAction.descriptor.domainEvents, - 'party.registry', - createPartyAction.descriptor.accessEvidencePolicy, - ); - yield* getActionHandler(createPartyAction)( - { - candidate: candidate({ - officialIdentifiers: [ - { identifierType: 'ICO', value: '27074358', verification: 'VERIFIED' }, - identifier, - identifier, - ], - }), - }, - { - ...collector, - actionInvocationId, - scope: actionScope, - services: { - createOrMatch: (value, invocationId) => - createOrMatchParty(subject.transaction, { - actionInvocationId: invocationId, - candidate: value, - principalId, - tenantId, - }), - }, + ], + ]), + ); + const identifier = { + identifierType: 'CZ_DIC' as const, + value: 'CZ27074358', + verification: 'UNVERIFIED' as const, + }; + const collector = createActionCollector( + createPartyAction.descriptor.domainEvents, + 'party.registry', + createPartyAction.descriptor.accessEvidencePolicy, + ); + yield* getActionHandler(createPartyAction)( + { + candidate: candidate({ + officialIdentifiers: [ + { + identifierType: 'ICO', + value: '27074358', + verification: 'VERIFIED', + }, + identifier, + identifier, + ], + }), + }, + { + ...collector, + actionInvocationId, + scope: actionScope, + services: { + createOrMatch: (value, invocationId) => + createOrMatchParty(subject.transaction, { + actionInvocationId: invocationId, + candidate: value, + principalId, + tenantId, + }), }, - ); - expect( - subject.inserts.filter(({ table }) => table === partyOfficialIdentifiers).length, - ).toBe(1); - expect(collector.snapshot().domainEvents.length).toBe(1); - expect(collector.snapshot().outboxMessages.length).toBe(1); - }), + }, + ); + expect(subject.inserts.filter(({ table }) => table === partyOfficialIdentifiers).length).toBe(1); + expect(collector.snapshot().domainEvents.length).toBe(1); + expect(collector.snapshot().outboxMessages.length).toBe(1); + }), ); - testIt.effect( - 'reviewed matching with already-owned claims creates no duplicate identifier notifications', - () => - Effect.gen(function* reviewedMatchingWithAlreadyOwnedClaimsCreates() { - const subject = harness( - new Map([ - [duplicateCandidateCases, [[caseRow()]]], - [partyAliases, [[], []]], - [ - parties, - [[activePartyRow(partyC)], [activePartyRow(partyC)], [activePartyRow(partyC)]], - ], - [partyIdentifierClaims, [[{ officialIdentifierId, partyId: partyC }]]], - ]), - ); - const { collector, result } = yield* invokeReviewedMatch(subject); - expect(result.outcome).toBe('MATCH_EXISTING'); - expect(collector.snapshot().domainEvents).toEqual([]); - expect(collector.snapshot().outboxMessages).toEqual([]); - }), + testIt.effect('reviewed matching with already-owned claims creates no duplicate identifier notifications', () => + Effect.gen(function* reviewedMatchingWithAlreadyOwnedClaimsCreates() { + const subject = harness( + new Map([ + [duplicateCandidateCases, [[caseRow()]]], + [partyAliases, [[], []]], + [parties, [[activePartyRow(partyC)], [activePartyRow(partyC)], [activePartyRow(partyC)]]], + [partyIdentifierClaims, [[{ officialIdentifierId, partyId: partyC }]]], + ]), + ); + const { collector, result } = yield* invokeReviewedMatch(subject); + expect(result.outcome).toBe('MATCH_EXISTING'); + expect(collector.snapshot().domainEvents).toEqual([]); + expect(collector.snapshot().outboxMessages).toEqual([]); + }), ); testIt.effect( @@ -644,9 +631,7 @@ it.layer( ], ]), ); - const failure = yield* Effect.flip( - resolveDuplicateCandidateMatch(subject.transaction, resolutionInput), - ); + const failure = yield* Effect.flip(resolveDuplicateCandidateMatch(subject.transaction, resolutionInput)); expect(Predicate.isTagged(failure, 'DuplicateCandidateConflict')).toBe(true); expect(subject.reads.some(({ table, locked }) => table === parties && locked)).toBe(true); expect(subject.inserts).toEqual([]); @@ -654,119 +639,113 @@ it.layer( }), ); - testIt.effect( - 'reviewed matching rejects a cross-tenant selected reference without resolving its identity', - () => - Effect.gen(function* reviewedMatchingRejectsACrossTenantSelected() { - const subject = harness(); - const failure = yield* Effect.flip( - resolveDuplicateCandidateMatch(subject.transaction, { - ...resolutionInput, - selectedPartyTenantId: '90000000-0000-4000-8000-000000000001', - }), - ); - expect(Predicate.isTagged(failure, 'DuplicateCandidateConflict')).toBe(true); - expect(subject.reads.length, 'only the trusted tenant serialization lock is acquired').toBe( - 1, - ); - expect(subject.inserts).toEqual([]); - }), + testIt.effect('reviewed matching rejects a cross-tenant selected reference without resolving its identity', () => + Effect.gen(function* reviewedMatchingRejectsACrossTenantSelected() { + const subject = harness(); + const failure = yield* Effect.flip( + resolveDuplicateCandidateMatch(subject.transaction, { + ...resolutionInput, + selectedPartyTenantId: '90000000-0000-4000-8000-000000000001', + }), + ); + expect(Predicate.isTagged(failure, 'DuplicateCandidateConflict')).toBe(true); + expect(subject.reads.length, 'only the trusted tenant serialization lock is acquired').toBe(1); + expect(subject.inserts).toEqual([]); + }), ); - testIt.effect( - 'reviewed matching rejects an absorbed target with the full-chain canonical survivor reference', - () => - Effect.gen(function* reviewedMatchingRejectsAnAbsorbedTargetWith() { - const subject = harness( - new Map([ - [duplicateCandidateCases, [[caseRow()]]], + testIt.effect('reviewed matching rejects an absorbed target with the full-chain canonical survivor reference', () => + Effect.gen(function* reviewedMatchingRejectsAnAbsorbedTargetWith() { + const subject = harness( + new Map([ + [duplicateCandidateCases, [[caseRow()]]], + [ + partyAliases, [ - partyAliases, - [ - [{ aliasPartyId: partyB, canonicalPartyId: partyA, tenantId }], - [{ aliasPartyId: partyA, canonicalPartyId: partyC, tenantId }], - [], - ], + [{ aliasPartyId: partyB, canonicalPartyId: partyA, tenantId }], + [{ aliasPartyId: partyA, canonicalPartyId: partyC, tenantId }], + [], ], - [parties, [[{ partyId: partyC }]]], - ]), - ); - const failure = yield* Effect.flip( - resolveDuplicateCandidateMatch(subject.transaction, { - ...resolutionInput, - selectedPartyId: partyB, - }), - ); - expect(Schema.is(PartyAliasWriteRejected)(failure)).toBe(true); - const rejection = yield* Schema.decodeUnknownEffect(PartyAliasWriteRejected)(failure); - expect(rejection.canonicalPartyRef.resourceId).toBe(partyC); - expect(subject.inserts).toEqual([]); - expect(subject.updates).toEqual([]); - }), + ], + [parties, [[{ partyId: partyC }]]], + ]), + ); + const failure = yield* Effect.flip( + resolveDuplicateCandidateMatch(subject.transaction, { + ...resolutionInput, + selectedPartyId: partyB, + }), + ); + expect(Schema.is(PartyAliasWriteRejected)(failure)).toBe(true); + const rejection = yield* Schema.decodeUnknownEffect(PartyAliasWriteRejected)(failure); + expect(rejection.canonicalPartyRef.resourceId).toBe(partyC); + expect(subject.inserts).toEqual([]); + expect(subject.updates).toEqual([]); + }), ); - testIt.effect( - 'future-effective evidence is rejected before a current decision or Party can be persisted', - () => - Effect.gen(function* futureEffectiveEvidenceIsRejectedBeforeA() { - const subject = harness(); - const failure = yield* Effect.flip( - matchParty(subject.transaction, { - actionInvocationId, - candidate: candidate({ validFrom: DateTime.makeUnsafe('2099-01-01T00:00:00.000Z') }), - tenantId, + testIt.effect('future-effective evidence is rejected before a current decision or Party can be persisted', () => + Effect.gen(function* futureEffectiveEvidenceIsRejectedBeforeA() { + const subject = harness(); + const failure = yield* Effect.flip( + matchParty(subject.transaction, { + actionInvocationId, + candidate: candidate({ + validFrom: DateTime.makeUnsafe('2099-01-01T00:00:00.000Z'), }), - ); - expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); - expect(subject.inserts).toEqual([]); - }), + tenantId, + }), + ); + expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); + expect(subject.inserts).toEqual([]); + }), ); it('durable matching is an idempotent identity Action and the separate UX preview remains a governed read', () => { expect(matchPartyAction.descriptor.idempotency).toBe('required'); - expect(matchPartyAction.descriptor.tenantPermission?.({ candidate: candidate() })).toBe( - 'manage_party_identity', - ); + expect(matchPartyAction.descriptor.tenantPermission?.({ candidate: candidate() })).toBe('manage_party_identity'); expect(matchPartyAction.descriptor.legalEntityScope).toBe('optional'); expect(partyMatchRead.descriptor.accessKind).toBe('detail'); }); - testIt.effect( - 'weak exact canonical evidence produces review rather than automatic identity or NO_MATCH', - () => - Effect.gen(function* weakExactCanonicalEvidenceProducesReviewRather() { - const subject = harness( - new Map([ - [partyOfficialIdentifiers, [[{ partyId: partyA }]]], - [partyAliases, [[]]], - [parties, [[{ partyId: partyA }]]], - ]), - ); - const result = yield* matchParty(subject.transaction, { - actionInvocationId, - candidate: candidate({ - officialIdentifiers: [ - { identifierType: 'ICO', value: '27074358', verification: 'UNVERIFIED' }, - ], - partyType: 'PERSON', - subjectEvidence: [ - { - basis: 'DIRECT_INTERACTION', - evidenceRef: 'meeting/42', - kind: 'ACTOR_ATTESTATION', - observedSubject: 'PERSON', - statement: 'Met this human', - subjectKey: partySubjectKeyFromString('one-subject'), - }, - ], - }), - tenantId, - }); - expect(result.outcome).toBe('AMBIGUOUS'); - expect(result.caseRef?.resourceId).toBe(candidateCaseId); - expect(result.candidateParties.map((ref) => ref.resourceId)).toEqual([partyA]); - expect(subject.inserts.some(({ table }) => table === parties)).toBe(false); - }), + testIt.effect('weak exact canonical evidence produces review rather than automatic identity or NO_MATCH', () => + Effect.gen(function* weakExactCanonicalEvidenceProducesReviewRather() { + const subject = harness( + new Map([ + [partyOfficialIdentifiers, [[{ partyId: partyA }]]], + [partyAliases, [[]]], + [parties, [[{ partyId: partyA }]]], + ]), + ); + const result = yield* matchParty(subject.transaction, { + actionInvocationId, + candidate: candidate({ + officialIdentifiers: [ + { + identifierType: 'ICO', + value: '27074358', + verification: 'UNVERIFIED', + }, + ], + partyType: 'PERSON', + subjectEvidence: [ + { + basis: 'DIRECT_INTERACTION', + evidenceRef: 'meeting/42', + kind: 'ACTOR_ATTESTATION', + observedSubject: 'PERSON', + statement: 'Met this human', + subjectKey: partySubjectKeyFromString('one-subject'), + }, + ], + }), + tenantId, + }); + expect(result.outcome).toBe('AMBIGUOUS'); + expect(result.caseRef?.resourceId).toBe(candidateCaseId); + expect(result.candidateParties.map((ref) => ref.resourceId)).toEqual([partyA]); + expect(subject.inserts.some(({ table }) => table === parties)).toBe(false); + }), ); testIt.effect( @@ -799,69 +778,77 @@ it.layer( }), ); - testIt.effect( - 'a new material evaluation creates a linked successor without rewriting the prior case', - () => - Effect.gen(function* aNewMaterialEvaluationCreatesALinked() { - const priorId = '30000000-0000-4000-8000-000000000099'; - const subject = harness( - new Map([ + testIt.effect('a new material evaluation creates a linked successor without rewriting the prior case', () => + Effect.gen(function* aNewMaterialEvaluationCreatesALinked() { + const priorId = '30000000-0000-4000-8000-000000000099'; + const subject = harness( + new Map([ + [ + partyIdentifierClaims, [ - partyIdentifierClaims, [ - [{ officialIdentifierId: '70000000-0000-4000-8000-000000000001', partyId: partyA }], - [{ partyId: partyB }], + { + officialIdentifierId: '70000000-0000-4000-8000-000000000001', + partyId: partyA, + }, ], + [{ partyId: partyB }], ], - [partyAliases, [[], []]], - [parties, [[{ partyId: partyA }], [{ partyId: partyB }]]], + ], + [partyAliases, [[], []]], + [parties, [[{ partyId: partyA }], [{ partyId: partyB }]]], + [ + duplicateCandidateCases, [ - duplicateCandidateCases, - [[], [{ ...caseRow(), candidateCaseId: priorId, lifecycleState: 'RESOLVED' }]], + [], + [ + { + ...caseRow(), + candidateCaseId: priorId, + lifecycleState: 'RESOLVED', + }, + ], ], - ]), - ); - const result = yield* matchParty(subject.transaction, { - actionInvocationId, - candidate: candidate(), - tenantId, - }); - const insertedCase = recordedRow( - subject.inserts.find(({ table }) => table === duplicateCandidateCases)?.values, - ); - expect(insertedCase['priorCandidateCaseId']).toBe(priorId); - expect(String(insertedCase['evaluationFingerprint'])).toMatch(/^[0-9a-f]{64}$/u); - expect(result.evidenceExplanation[0]?.officialIdentifierRef?.resourceId).toBe( - '70000000-0000-4000-8000-000000000001', - ); - expect(result.evidenceExplanation[0]?.identifierType).toBe('ICO'); - expect(result.evidenceExplanation[0]?.normalizedValue).toBe('27074358'); - expect(subject.updates).toEqual([]); - }), + ], + ]), + ); + const result = yield* matchParty(subject.transaction, { + actionInvocationId, + candidate: candidate(), + tenantId, + }); + const insertedCase = recordedRow(subject.inserts.find(({ table }) => table === duplicateCandidateCases)?.values); + expect(insertedCase['priorCandidateCaseId']).toBe(priorId); + expect(String(insertedCase['evaluationFingerprint'])).toMatch(/^[0-9a-f]{64}$/u); + expect(result.evidenceExplanation[0]?.officialIdentifierRef?.resourceId).toBe( + '70000000-0000-4000-8000-000000000001', + ); + expect(result.evidenceExplanation[0]?.identifierType).toBe('ICO'); + expect(result.evidenceExplanation[0]?.normalizedValue).toBe('27074358'); + expect(subject.updates).toEqual([]); + }), ); - testIt.effect( - 'explicit prior-case continuation rejects foreign or missing review references', - () => - Effect.forEach( - [tenantId, '90000000-0000-4000-8000-000000000001'], - (priorCaseTenantId) => - Effect.gen(function* rejectInvalidPriorCaseReference() { - const subject = harness(); - const failure = yield* Effect.flip( - matchParty(subject.transaction, { - actionInvocationId, - candidate: candidate(), - priorCandidateCaseId: candidateCaseId, - priorCaseTenantId, - tenantId, - }), - ); - expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); - expect(subject.inserts).toEqual([]); - }), - { concurrency: 'unbounded', discard: true }, - ), + testIt.effect('explicit prior-case continuation rejects foreign or missing review references', () => + Effect.forEach( + [tenantId, '90000000-0000-4000-8000-000000000001'], + (priorCaseTenantId) => + Effect.gen(function* rejectInvalidPriorCaseReference() { + const subject = harness(); + const failure = yield* Effect.flip( + matchParty(subject.transaction, { + actionInvocationId, + candidate: candidate(), + priorCandidateCaseId: candidateCaseId, + priorCaseTenantId, + tenantId, + }), + ); + expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); + expect(subject.inserts).toEqual([]); + }), + { concurrency: 'unbounded', discard: true }, + ), ); it('equivalent Candidate property and evidence ordering has one deterministic fingerprint', () => { @@ -874,62 +861,71 @@ it.layer( evidenceRefs: original.evidenceRefs.toReversed(), officialIdentifiers: original.officialIdentifiers.toReversed(), partyType: original.partyType, - provenance: { method: original.provenance.method, source: original.provenance.source }, + provenance: { + method: original.provenance.method, + source: original.provenance.source, + }, subjectEvidence: original.subjectEvidence ?? [], validFrom: original.validFrom, }; expect(candidateFingerprint(original)).toBe(candidateFingerprint(reordered)); }); - testIt.effect( - 'insufficient typed evidence cannot persist a case or decision even with a verified identifier', - () => - Effect.gen(function* denyUnevidencedSubject() { - for (const operation of ['CREATE', 'MATCH'] as const) { - const subject = harness(); - const input = { - actionInvocationId, - candidate: candidate({ subjectEvidence: [] }), - principalId, - tenantId, - }; - const failure = yield* operation === 'CREATE' - ? Effect.flip(createOrMatchParty(subject.transaction, input)) - : Effect.flip(matchParty(subject.transaction, input)); - expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); - expect(subject.inserts.length).toBe(0); - } - }), + testIt.effect('insufficient typed evidence cannot persist a case or decision even with a verified identifier', () => + Effect.gen(function* denyUnevidencedSubject() { + for (const operation of ['CREATE', 'MATCH'] as const) { + const subject = harness(); + const input = { + actionInvocationId, + candidate: candidate({ subjectEvidence: [] }), + principalId, + tenantId, + }; + const failure = yield* operation === 'CREATE' + ? Effect.flip(createOrMatchParty(subject.transaction, input)) + : Effect.flip(matchParty(subject.transaction, input)); + expect(Predicate.isTagged(failure, 'PartyEvidenceInsufficient')).toBe(true); + expect(subject.inserts.length).toBe(0); + } + }), ); - testIt.effect( - 'reviewer selection cannot waive missing subject/type evidence from a retained case', - () => - Effect.gen(function* denyUnevidencedReview() { - const row = caseRow(); - const subject = harness( - new Map([ + testIt.effect('reviewer selection cannot waive missing subject/type evidence from a retained case', () => + Effect.gen(function* denyUnevidencedReview() { + const row = caseRow(); + const subject = harness( + new Map([ + [ + duplicateCandidateCases, [ - duplicateCandidateCases, - [[{ ...row, candidateSnapshot: { ...row.candidateSnapshot, subjectEvidence: [] } }]], + [ + { + ...row, + candidateSnapshot: { + ...row.candidateSnapshot, + subjectEvidence: [], + }, + }, + ], ], - ]), - ); - const failure = yield* Effect.flip( - resolveDuplicateCandidateMatch(subject.transaction, { - actionInvocationId, - candidateCaseId, - expectedRevision: 1, - principalId, - reason: 'reviewed', - selectedPartyId: partyA, - selectedPartyTenantId: tenantId, - tenantId, - }), - ); - expect(Predicate.isTagged(failure, 'DuplicateCandidateConflict')).toBe(true); - expect(subject.inserts.length).toBe(0); - expect(subject.updates.length).toBe(0); - }), + ], + ]), + ); + const failure = yield* Effect.flip( + resolveDuplicateCandidateMatch(subject.transaction, { + actionInvocationId, + candidateCaseId, + expectedRevision: 1, + principalId, + reason: 'reviewed', + selectedPartyId: partyA, + selectedPartyTenantId: tenantId, + tenantId, + }), + ); + expect(Predicate.isTagged(failure, 'DuplicateCandidateConflict')).toBe(true); + expect(subject.inserts.length).toBe(0); + expect(subject.updates.length).toBe(0); + }), ); }); diff --git a/app/verticals/party-registry/tests/unit/merge-alias-resolution-service.test.ts b/app/verticals/party-registry/tests/unit/merge-alias-resolution-service.test.ts index b356f1577..843044687 100644 --- a/app/verticals/party-registry/tests/unit/merge-alias-resolution-service.test.ts +++ b/app/verticals/party-registry/tests/unit/merge-alias-resolution-service.test.ts @@ -1,6 +1,6 @@ +import { Effect, Match, Option, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Match, Option, Predicate } from 'effect'; import { makePartyAliasResolutionService } from '../../src/merge/party-alias-resolution.service.ts'; import type { PartyAliasLookup } from '../../src/merge/party-alias-resolution.service.ts'; @@ -19,67 +19,65 @@ const lookup = (overrides: Partial = {}): PartyAliasLookup => ...overrides, }); -it.effect( - 'central resolution service walks the complete canonical alias chain in one scoped transaction seam', - () => - Effect.gen(function* aliasResolution1() { - const service = makePartyAliasResolutionService(lookup()); - const result = yield* service.resolvePartyAlias(tenantId, 'party-b'); +it.effect('central resolution service walks the complete canonical alias chain in one scoped transaction seam', () => + Effect.gen(function* aliasResolution1() { + const service = makePartyAliasResolutionService(lookup()); + const result = yield* service.resolvePartyAlias(tenantId, 'party-b'); - expect(result).toEqual({ - canonicalPartyId: 'party-c', - requestedPartyId: 'party-b', - traversedAliasIds: ['party-b', 'party-a'], - wasAlias: true, - }); - }), + expect(result).toEqual({ + canonicalPartyId: 'party-c', + requestedPartyId: 'party-b', + traversedAliasIds: ['party-b', 'party-a'], + wasAlias: true, + }); + }), ); -it.effect( - 'central resolution fails closed for cycles, cross-tenant targets, and broken chains', - () => - Effect.gen(function* aliasResolution2() { - const cycle = makePartyAliasResolutionService( - lookup({ - findAlias: (_requestedTenantId, aliasPartyId) => - Effect.succeed( - Option.some( - aliasPartyId === 'party-a' - ? { aliasPartyId: 'party-a', canonicalPartyId: 'party-b', tenantId } - : { aliasPartyId: 'party-b', canonicalPartyId: 'party-a', tenantId }, - ), - ), - }), - ); - const cycleError = yield* Effect.flip(cycle.resolvePartyAlias(tenantId, 'party-a')); - expect(Predicate.isTagged(cycleError, 'PartyAliasResolutionCycle')).toBe(true); +it.effect('central resolution fails closed for cycles, cross-tenant targets, and broken chains', () => + Effect.gen(function* aliasResolution2() { + const cycle = makePartyAliasResolutionService( + lookup({ + findAlias: (_requestedTenantId, aliasPartyId) => + Effect.succeedSome( + aliasPartyId === 'party-a' + ? { + aliasPartyId: 'party-a', + canonicalPartyId: 'party-b', + tenantId, + } + : { + aliasPartyId: 'party-b', + canonicalPartyId: 'party-a', + tenantId, + }, + ), + }), + ); + const cycleError = yield* Effect.flip(cycle.resolvePartyAlias(tenantId, 'party-a')); + expect(Predicate.isTagged(cycleError, 'PartyAliasResolutionCycle')).toBe(true); - const crossTenant = makePartyAliasResolutionService( - lookup({ - findAlias: () => - Effect.succeed( - Option.some({ - aliasPartyId: 'party-b', - canonicalPartyId: 'party-a', - tenantId: '22222222-2222-4222-8222-222222222222', - }), - ), - }), - ); - const crossTenantError = yield* Effect.flip( - crossTenant.resolvePartyAlias(tenantId, 'party-b'), - ); - expect(Predicate.isTagged(crossTenantError, 'PartyAliasResolutionCrossTenant')).toBe(true); + const crossTenant = makePartyAliasResolutionService( + lookup({ + findAlias: () => + Effect.succeedSome({ + aliasPartyId: 'party-b', + canonicalPartyId: 'party-a', + tenantId: '22222222-2222-4222-8222-222222222222', + }), + }), + ); + const crossTenantError = yield* Effect.flip(crossTenant.resolvePartyAlias(tenantId, 'party-b')); + expect(Predicate.isTagged(crossTenantError, 'PartyAliasResolutionCrossTenant')).toBe(true); - const broken = makePartyAliasResolutionService( - lookup({ - findAlias: () => Effect.succeed(Option.none()), - partyExists: () => Effect.succeed(false), - }), - ); - const brokenError = yield* Effect.flip(broken.resolvePartyAlias(tenantId, 'missing')); - expect(Predicate.isTagged(brokenError, 'PartyAliasResolutionBrokenChain')).toBe(true); - }), + const broken = makePartyAliasResolutionService( + lookup({ + findAlias: () => Effect.succeedNone, + partyExists: () => Effect.succeed(false), + }), + ); + const brokenError = yield* Effect.flip(broken.resolvePartyAlias(tenantId, 'missing')); + expect(Predicate.isTagged(brokenError, 'PartyAliasResolutionBrokenChain')).toBe(true); + }), ); it.effect('central write guard returns typed canonical-survivor guidance and never forwards', () => diff --git a/app/verticals/party-registry/tests/unit/merge-alias-resolution.test.ts b/app/verticals/party-registry/tests/unit/merge-alias-resolution.test.ts index 79be05b0f..5a470eb96 100644 --- a/app/verticals/party-registry/tests/unit/merge-alias-resolution.test.ts +++ b/app/verticals/party-registry/tests/unit/merge-alias-resolution.test.ts @@ -1,11 +1,8 @@ +import { DateTime, Predicate, Struct } from 'effect'; import { expect, it } from 'effect-rstest'; -import { DateTime, Predicate, Struct } from 'effect'; import type { PartyRef } from '../../shared/resources/party.ts'; -import { - assertCanonicalWriteTarget, - resolveCanonicalPartyRef, -} from '../../src/merge/party-alias-resolution.ts'; +import { assertCanonicalWriteTarget, resolveCanonicalPartyRef } from '../../src/merge/party-alias-resolution.ts'; const tenantId = '11111111-1111-4111-8111-111111111111'; const party = (resourceId: string, tenant = tenantId): PartyRef => ({ @@ -27,10 +24,7 @@ const alias = (aliasPartyId: string, survivorPartyId: string, tenant = tenantId) }); it('resolves an historical alias chain to one final canonical Party', () => { - const result = resolveCanonicalPartyRef(party('party-b'), [ - alias('party-b', 'party-a'), - alias('party-a', 'party-c'), - ]); + const result = resolveCanonicalPartyRef(party('party-b'), [alias('party-b', 'party-a'), alias('party-a', 'party-c')]); expect(Predicate.isTagged(result, 'CanonicalPartyResolved')).toBe(true); expect(Struct.omit(result, ['_tag'])).toEqual({ @@ -43,10 +37,7 @@ it('resolves an historical alias chain to one final canonical Party', () => { it('rejects alias cycles, self aliases, and cross-tenant targets', () => { expect( Predicate.isTagged( - resolveCanonicalPartyRef(party('party-a'), [ - alias('party-a', 'party-b'), - alias('party-b', 'party-a'), - ]), + resolveCanonicalPartyRef(party('party-a'), [alias('party-a', 'party-b'), alias('party-b', 'party-a')]), 'PartyAliasCycleRejected', ), ).toBe(true); @@ -70,18 +61,14 @@ it('rejects alias cycles, self aliases, and cross-tenant targets', () => { }); it('rejects new writes addressed to an absorbed alias instead of forwarding them', () => { - const aliasWriteRejection = assertCanonicalWriteTarget(party('party-b'), [ - alias('party-b', 'party-a'), - ]); + const aliasWriteRejection = assertCanonicalWriteTarget(party('party-b'), [alias('party-b', 'party-a')]); expect(Predicate.isTagged(aliasWriteRejection, 'AliasWriteRejected')).toBe(true); expect(Struct.omit(aliasWriteRejection, ['_tag'])).toEqual({ aliasPartyRef: party('party-b'), canonicalPartyRef: party('party-a'), code: 'ALIAS_WRITE_FORBIDDEN', }); - const canonicalWriteAcceptance = assertCanonicalWriteTarget(party('party-a'), [ - alias('party-b', 'party-a'), - ]); + const canonicalWriteAcceptance = assertCanonicalWriteTarget(party('party-a'), [alias('party-b', 'party-a')]); expect(Predicate.isTagged(canonicalWriteAcceptance, 'CanonicalWriteTargetAccepted')).toBe(true); expect(Struct.omit(canonicalWriteAcceptance, ['_tag'])).toEqual({ partyRef: party('party-a'), diff --git a/app/verticals/party-registry/tests/unit/merge-collision-reference.test.ts b/app/verticals/party-registry/tests/unit/merge-collision-reference.test.ts index 191755fb6..d32dc4156 100644 --- a/app/verticals/party-registry/tests/unit/merge-collision-reference.test.ts +++ b/app/verticals/party-registry/tests/unit/merge-collision-reference.test.ts @@ -1,6 +1,6 @@ +import { DateTime, Match, Struct, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { DateTime, Match, Struct, Predicate } from 'effect'; import type { PartyRef } from '../../shared/resources/party.ts'; import { analyzeMergeCollisions } from '../../src/merge/merge-collision-analysis.ts'; import { planReferencePreservation } from '../../src/merge/reference-preservation-plan.ts'; @@ -48,8 +48,16 @@ it('requires reconciliation for Counterparty and consumer uniqueness collisions' const collisions = analyzeMergeCollisions({ absorbedPartyRefs: [party('party-b')], connectorCorrelations: [ - { connectorKey: 'erp', externalSubjectId: 'erp-a', partyRef: party('party-a') }, - { connectorKey: 'erp', externalSubjectId: 'erp-b', partyRef: party('party-b') }, + { + connectorKey: 'erp', + externalSubjectId: 'erp-a', + partyRef: party('party-a'), + }, + { + connectorKey: 'erp', + externalSubjectId: 'erp-b', + partyRef: party('party-b'), + }, ], consumerProfiles: [ { @@ -66,8 +74,16 @@ it('requires reconciliation for Counterparty and consumer uniqueness collisions' }, ], counterparties: [ - { counterpartyId: 'cp-a', legalEntityId: 'le-1', partyRef: party('party-a') }, - { counterpartyId: 'cp-b', legalEntityId: 'le-1', partyRef: party('party-b') }, + { + counterpartyId: 'cp-a', + legalEntityId: 'le-1', + partyRef: party('party-a'), + }, + { + counterpartyId: 'cp-b', + legalEntityId: 'le-1', + partyRef: party('party-b'), + }, ], counterpartyRoles: [], officialIdentifiers: [], @@ -145,8 +161,14 @@ it('blocks strong identifier conflicts and flags forbidden relationship and role expect(collisions.map(({ code, resolution }) => ({ code, resolution }))).toEqual([ { code: 'STRONG_IDENTIFIER_CONFLICT', resolution: 'CORRECTION_REQUIRED' }, - { code: 'RELATIONSHIP_SELF_REFERENCE', resolution: 'RECONCILIATION_REQUIRED' }, - { code: 'COUNTERPARTY_ROLE_PERIOD_COLLISION', resolution: 'RECONCILIATION_REQUIRED' }, + { + code: 'RELATIONSHIP_SELF_REFERENCE', + resolution: 'RECONCILIATION_REQUIRED', + }, + { + code: 'COUNTERPARTY_ROLE_PERIOD_COLLISION', + resolution: 'RECONCILIATION_REQUIRED', + }, ]); }); @@ -169,26 +191,41 @@ it('plans canonical resolution for supported refs without rewriting historical s survivorPartyRef: party('party-a'), }, ], - consumerReconciliation: [ - 'core', - 'events', - 'engagement', - 'commerce', - 'connector.registry', - 'invoicing', - ].map((consumerKey) => ({ - collisionBehaviorTested: true, - consumerKey, - evidenceRefs: [`test:${consumerKey}`], - idempotent: true, - partialRetrySupported: true, - })), + consumerReconciliation: ['core', 'events', 'engagement', 'commerce', 'connector.registry', 'invoicing'].map( + (consumerKey) => ({ + collisionBehaviorTested: true, + consumerKey, + evidenceRefs: [`test:${consumerKey}`], + idempotent: true, + partialRetrySupported: true, + }), + ), references: [ - { class: 'DIRECT_RESOURCE_REF', ownerKey: 'core', partyRef: party('party-b') }, - { class: 'EVENT_OR_OUTBOX_PAYLOAD', ownerKey: 'events', partyRef: party('party-b') }, - { class: 'COUNTERPARTY', ownerKey: 'party.registry', partyRef: party('party-b') }, - { class: 'ENGAGEMENT_PROFILE', ownerKey: 'engagement', partyRef: party('party-b') }, - { class: 'COMMERCE_PROFILE', ownerKey: 'commerce', partyRef: party('party-b') }, + { + class: 'DIRECT_RESOURCE_REF', + ownerKey: 'core', + partyRef: party('party-b'), + }, + { + class: 'EVENT_OR_OUTBOX_PAYLOAD', + ownerKey: 'events', + partyRef: party('party-b'), + }, + { + class: 'COUNTERPARTY', + ownerKey: 'party.registry', + partyRef: party('party-b'), + }, + { + class: 'ENGAGEMENT_PROFILE', + ownerKey: 'engagement', + partyRef: party('party-b'), + }, + { + class: 'COMMERCE_PROFILE', + ownerKey: 'commerce', + partyRef: party('party-b'), + }, { class: 'CONNECTOR_CORRELATION', ownerKey: 'connector.registry', @@ -212,9 +249,7 @@ it('plans canonical resolution for supported refs without rewriting historical s ), Match.exhaustive, ); - expect( - planned.references.every(({ canonicalPartyRef }) => canonicalPartyRef.resourceId === 'party-a'), - ).toBe(true); + expect(planned.references.every(({ canonicalPartyRef }) => canonicalPartyRef.resourceId === 'party-a')).toBe(true); expect(planned.references.at(-1)?.historicalSnapshot).toEqual(snapshot); expect(planned.requiresPhysicalRewrite).toBe(false); }); @@ -290,8 +325,16 @@ it('blocks readiness for unsupported references and incomplete retry contracts', }, ], references: [ - { class: 'UNSUPPORTED', ownerKey: 'custom-module', partyRef: party('party-b') }, - { class: 'ENGAGEMENT_PROFILE', ownerKey: 'engagement', partyRef: party('party-b') }, + { + class: 'UNSUPPORTED', + ownerKey: 'custom-module', + partyRef: party('party-b'), + }, + { + class: 'ENGAGEMENT_PROFILE', + ownerKey: 'engagement', + partyRef: party('party-b'), + }, ], }); @@ -307,7 +350,13 @@ it('blocks readiness for unsupported references and incomplete retry contracts', it('blocks every external reference owner without reconciliation evidence', () => { const result = planReferencePreservation({ aliases: [], - references: [{ class: 'COMMERCE_PROFILE', ownerKey: 'commerce', partyRef: party('party-b') }], + references: [ + { + class: 'COMMERCE_PROFILE', + ownerKey: 'commerce', + partyRef: party('party-b'), + }, + ], }); expect(Predicate.isTagged(result, 'ReferencePreservationBlocked')).toBe(true); diff --git a/app/verticals/party-registry/tests/unit/merge-readiness-contract.test.ts b/app/verticals/party-registry/tests/unit/merge-readiness-contract.test.ts index 90652f36f..29d182483 100644 --- a/app/verticals/party-registry/tests/unit/merge-readiness-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/merge-readiness-contract.test.ts @@ -1,27 +1,23 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Source-only contract checks require reading TypeScript files; remove-when: manifests are importable without TSX loaders. +import { fileURLToPath } from 'node:url'; + +import { NodeFileSystem } from '@effect/platform-node'; +import { FileSystem, Effect, Match, Schema, Struct, Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { readFile } from 'node:fs/promises'; -import { Effect, Match, Schema, Struct, Predicate } from 'effect'; + +import { partyRegistryApi } from '../../shared/api.ts'; import { PartyMergeReadinessRequestSchema, PartyMergeReadinessResponseSchema, } from '../../shared/apis/party-merge-readiness.ts'; -import { - PartyAliasSchema, - partyAliasResourceDescriptor, -} from '../../shared/resources/party-alias.ts'; -import { - PartyMergeSchema, - partyMergeResourceDescriptor, -} from '../../shared/resources/party-merge.ts'; +import { PartyAliasSchema, partyAliasResourceDescriptor } from '../../shared/resources/party-alias.ts'; +import { PartyMergeSchema, partyMergeResourceDescriptor } from '../../shared/resources/party-merge.ts'; import { partyMergeReadinessRead } from '../../src/api/party-merge-readiness.read.ts'; +import { selectCanonicalSurvivor } from '../../src/merge/canonical-survivor-selection.ts'; import { analyzePreparedMergeReadiness, evaluateDisabledMergeReadiness, rejectProductionMergeExecution, } from '../../src/merge/merge-readiness.ts'; -import { selectCanonicalSurvivor } from '../../src/merge/canonical-survivor-selection.ts'; -import { partyRegistryApi } from '../../shared/api.ts'; const tenantId = '11111111-1111-4111-8111-111111111111'; const party = (resourceId: string) => ({ @@ -31,11 +27,84 @@ const party = (resourceId: string) => ({ tenantId, }); -it.effect( - 'publishes a tenant-governed read-only readiness contract that always reports execution disabled', - () => +it('readiness invokes survivor, collision, and reference analyzers while remaining disabled', () => { + const result = analyzePreparedMergeReadiness({ + aliases: [], + collisionInput: { + absorbedPartyRefs: [party('unrelated-b')], + connectorCorrelations: [], + consumerProfiles: [], + counterparties: [ + { + counterpartyId: 'cp-a', + legalEntityId: 'le-1', + partyRef: party('party-a'), + }, + { + counterpartyId: 'cp-b', + legalEntityId: 'le-1', + partyRef: party('party-b'), + }, + ], + counterpartyRoles: [], + officialIdentifiers: [], + relationships: [], + survivorPartyRef: party('unrelated-a'), + }, + consumerReconciliation: [], + references: [ + { + class: 'COMMERCE_PROFILE', + ownerKey: 'commerce', + partyRef: party('party-b'), + }, + ], + selectionInput: { + candidates: [ + { + authoritativeEvidenceRank: 2, + blockingAuthoritativeConflict: false, + completenessRank: 1, + createdAt: '2024-01-01T00:00:00.000Z', + lifecycle: 'ACTIVE', + partyRef: party('party-a'), + referenceStabilityRank: 2, + }, + { + authoritativeEvidenceRank: 1, + blockingAuthoritativeConflict: false, + completenessRank: 1, + createdAt: '2025-01-01T00:00:00.000Z', + lifecycle: 'ACTIVE', + partyRef: party('party-b'), + referenceStabilityRank: 1, + }, + ], + confirmation: { + confirmedDuplicateDecisionId: 'decision-1', + confirmedPartyRefs: [party('party-a'), party('party-b')], + decisionActorPrincipalId: 'principal-1', + evidenceRefs: ['evidence-1'], + }, + }, + }); + + expect(result.status).toBe('DISABLED'); + expect(result.mergeExecutionEnabled).toBe(false); + expect(result.analysis).toEqual({ + collisionCodes: ['COUNTERPARTY_COLLISION'], + referencePlanStatus: 'BLOCKED', + selectedSurvivorPartyRef: party('party-a'), + selectionStatus: 'SELECTED', + }); + expect(result.blockers.some(({ code }) => code === 'COUNTERPARTY_COLLISION')).toBe(true); + expect(result.blockers.some(({ code }) => code === 'CONSUMER_RECONCILIATION_UNPROVEN')).toBe(true); +}); + +it.layer(NodeFileSystem.layer)('merge-readiness-contract', (suite) => { + suite.effect('publishes a tenant-governed read-only readiness contract that always reports execution disabled', () => Effect.gen(function* schemaContract1() { - const request = yield* Schema.decodeUnknownEffect(PartyMergeReadinessRequestSchema, { + const request = yield* Schema.decodeEffect(PartyMergeReadinessRequestSchema, { onExcessProperty: 'error', })({ partyRefs: [party('party-a'), party('party-b')], @@ -43,16 +112,19 @@ it.effect( }); expect(request.partyRefs).toEqual([party('party-a'), party('party-b')]); expect(() => - Schema.decodeUnknownSync(PartyMergeReadinessRequestSchema)({ + Schema.decodeSync(PartyMergeReadinessRequestSchema)({ partyRefs: [party('party-a'), party('party-a')], policyVersion: 'party-merge-readiness.v1', }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(PartyMergeReadinessRequestSchema)({ + Schema.decodeSync(PartyMergeReadinessRequestSchema)({ partyRefs: [ party('party-a'), - { ...party('party-b'), tenantId: '22222222-2222-4222-8222-222222222222' }, + { + ...party('party-b'), + tenantId: '22222222-2222-4222-8222-222222222222', + }, ], policyVersion: 'party-merge-readiness.v1', }), @@ -97,11 +169,9 @@ it.effect( 'PREPARED_STATE_UNAVAILABLE', ]); }), -); + ); -it.effect( - 'keeps prepared merge and permanent alias schemas explainable without enabling execution', - () => + suite.effect('keeps prepared merge and permanent alias schemas explainable without enabling execution', () => Effect.gen(function* schemaContract2() { const selection = selectCanonicalSurvivor({ candidates: ['party-a', 'party-b'].map((id, index) => ({ @@ -129,7 +199,7 @@ it.effect( ), Match.exhaustive, ); - const merge = yield* Schema.decodeUnknownEffect(PartyMergeSchema)({ + const merge = yield* Schema.decodeEffect(PartyMergeSchema)({ absorbedPartyRefs: [party('party-b')], confirmedDuplicateDecisionId: 'decision-1', createdAt: '2026-09-03T10:00:00.000Z', @@ -146,7 +216,7 @@ it.effect( state: 'PREPARED', survivorPartyRef: party('party-a'), }); - const alias = yield* Schema.decodeUnknownEffect(PartyAliasSchema)({ + const alias = yield* Schema.decodeEffect(PartyAliasSchema)({ aliasPartyRef: party('party-b'), createdAt: '2026-09-03T10:00:00.000Z', mergeRef: merge.mergeRef, @@ -191,59 +261,73 @@ it.effect( expect(partyMergeResourceDescriptor.capabilities.searchable).toBe(false); expect(partyAliasResourceDescriptor.capabilities.searchable).toBe(false); }), -); + ); -it.effect('has no registered Party Merge Action, event, outbox consumer, or write endpoint', () => - Effect.map( - Effect.all([ - Effect.promise(() => - readFile(new URL('../../vertical.manifest.ts', import.meta.url), 'utf-8'), - ), - Effect.promise(() => - readFile(new URL('../../vertical.registration.ts', import.meta.url), 'utf-8'), - ), - ]), - ([manifestSource, registrationSource]) => { - expect(manifestSource).not.toMatch(/merge[^\n]*Action|Action[^\n]*merge/iu); - expect(registrationSource).not.toMatch(/merge[^\n]*Action|Action[^\n]*merge/iu); - expect(registrationSource).toMatch(/'party-merge-readiness'/u); - const endpoints = Object.values(partyRegistryApi.groups).flatMap((group) => - Object.values(group.endpoints), - ); - expect(Object.keys(partyRegistryApi.groups.partyCommands.endpoints).length > 0).toBe(true); - expect(endpoints.filter(({ path }) => /merge/iu.test(path)).map(({ path }) => path)).toEqual([ - '/reads/party-merge-readiness', - ]); - }, - ), -); + suite.effect('has no registered Party Merge Action, event, outbox consumer, or write endpoint', () => + Effect.map( + Effect.all([ + FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL('../../vertical.manifest.ts', import.meta.url))), + ), + FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL('../../vertical.registration.ts', import.meta.url))), + ), + ]), + ([manifestSource, registrationSource]) => { + expect(manifestSource).not.toMatch(/merge[^\n]*Action|Action[^\n]*merge/iu); + expect(registrationSource).not.toMatch(/merge[^\n]*Action|Action[^\n]*merge/iu); + expect(registrationSource).toMatch(/'party-merge-readiness'/u); + const endpoints = Object.values(partyRegistryApi.groups).flatMap((group) => Object.values(group.endpoints)); + expect(Object.keys(partyRegistryApi.groups.partyCommands.endpoints).length > 0).toBe(true); + expect(endpoints.filter(({ path }) => /merge/iu.test(path)).map(({ path }) => path)).toEqual([ + '/reads/party-merge-readiness', + ]); + }, + ), + ); -it.effect('serves the generated OntOS module contract before i18n redirects in development', () => - Effect.map( - Effect.promise(() => readFile(new URL('../../modern.config.ts', import.meta.url), 'utf-8')), - (modernConfigSource) => { - expect(modernConfigSource).toMatch( - /new URL\('\.dev-public\/\.well-known\/ontos-module-manifest\.json', import\.meta\.url\)/u, - ); - expect(modernConfigSource).toMatch(/setupMiddlewares:/u); - expect(modernConfigSource).toMatch( - /request\.url\?\.split\('\?', 1\)\[0\] !== '\/\.well-known\/ontos-module-manifest\.json'/u, - ); - expect(modernConfigSource).toMatch( - /response\.setHeader\('Content-Type', 'application\/json'\)/u, - ); - expect(modernConfigSource).toMatch(/ignoreRedirectRoutes: \[\s*'\/\.well-known'/u); - expect(modernConfigSource).toMatch( - /publicDir: \['\.\/locales', '\.\/assets', '\.\/\.dev-public'\]/u, - ); - }, - ), -); + suite.effect('serves the generated OntOS module contract before i18n redirects in development', () => + Effect.map( + FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL('../../modern.config.ts', import.meta.url))), + ), + (modernConfigSource) => { + expect(modernConfigSource).toMatch( + /new URL\(\s*'\.dev-public\/\.well-known\/ontos-module-manifest\.json',\s*import\.meta\.url\s*\)/u, + ); + expect(modernConfigSource).toMatch(/setupMiddlewares:/u); + expect(modernConfigSource).toMatch( + /request\.url\?\.split\('\?', 1\)\[0\]\s*!==\s*'\/\.well-known\/ontos-module-manifest\.json'/u, + ); + expect(modernConfigSource).toMatch(/response\.setHeader\('Content-Type', 'application\/json'\)/u); + expect(modernConfigSource).toMatch(/ignoreRedirectRoutes: \[\s*'\/\.well-known'/u); + expect(modernConfigSource).toMatch(/publicDir: \['\.\/locales', '\.\/assets', '\.\/\.dev-public'\]/u); + }, + ), + ); -it.effect('readiness response schema cannot claim production merge is enabled', () => - Effect.gen(function* schemaContract3() { - expect( - yield* Schema.decodeUnknownEffect(PartyMergeReadinessResponseSchema)({ + suite.effect('readiness response schema cannot claim production merge is enabled', () => + Effect.gen(function* schemaContract3() { + expect( + yield* Schema.decodeEffect(PartyMergeReadinessResponseSchema)({ + analysis: { + collisionCodes: [], + referencePlanStatus: 'BLOCKED', + selectedSurvivorPartyRef: null, + selectionStatus: 'BLOCKED', + }, + blockers: [ + { + code: 'PRODUCTION_MERGE_DISABLED', + detail: 'Production merge is disabled.', + ownerKey: 'party.registry', + }, + ], + mergeExecutionEnabled: false, + partyRefs: [party('party-a'), party('party-b')], + status: 'DISABLED', + }), + ).toEqual({ analysis: { collisionCodes: [], referencePlanStatus: 'BLOCKED', @@ -260,100 +344,21 @@ it.effect('readiness response schema cannot claim production merge is enabled', mergeExecutionEnabled: false, partyRefs: [party('party-a'), party('party-b')], status: 'DISABLED', - }), - ).toEqual({ - analysis: { - collisionCodes: [], - referencePlanStatus: 'BLOCKED', - selectedSurvivorPartyRef: null, - selectionStatus: 'BLOCKED', - }, - blockers: [ - { - code: 'PRODUCTION_MERGE_DISABLED', - detail: 'Production merge is disabled.', - ownerKey: 'party.registry', - }, - ], - mergeExecutionEnabled: false, - partyRefs: [party('party-a'), party('party-b')], - status: 'DISABLED', - }); - expect(() => - Schema.decodeUnknownSync(PartyMergeReadinessResponseSchema)({ - analysis: { - collisionCodes: [], - referencePlanStatus: 'PLANNED', - selectedSurvivorPartyRef: party('party-a'), - selectionStatus: 'SELECTED', - }, - blockers: [], - mergeExecutionEnabled: true, - partyRefs: [party('party-a'), party('party-b')], - status: 'READY', - }), - ).toThrow(); - }), -); - -it('readiness invokes survivor, collision, and reference analyzers while remaining disabled', () => { - const result = analyzePreparedMergeReadiness({ - aliases: [], - collisionInput: { - absorbedPartyRefs: [party('unrelated-b')], - connectorCorrelations: [], - consumerProfiles: [], - counterparties: [ - { counterpartyId: 'cp-a', legalEntityId: 'le-1', partyRef: party('party-a') }, - { counterpartyId: 'cp-b', legalEntityId: 'le-1', partyRef: party('party-b') }, - ], - counterpartyRoles: [], - officialIdentifiers: [], - relationships: [], - survivorPartyRef: party('unrelated-a'), - }, - consumerReconciliation: [], - references: [{ class: 'COMMERCE_PROFILE', ownerKey: 'commerce', partyRef: party('party-b') }], - selectionInput: { - candidates: [ - { - authoritativeEvidenceRank: 2, - blockingAuthoritativeConflict: false, - completenessRank: 1, - createdAt: '2024-01-01T00:00:00.000Z', - lifecycle: 'ACTIVE', - partyRef: party('party-a'), - referenceStabilityRank: 2, - }, - { - authoritativeEvidenceRank: 1, - blockingAuthoritativeConflict: false, - completenessRank: 1, - createdAt: '2025-01-01T00:00:00.000Z', - lifecycle: 'ACTIVE', - partyRef: party('party-b'), - referenceStabilityRank: 1, - }, - ], - confirmation: { - confirmedDuplicateDecisionId: 'decision-1', - confirmedPartyRefs: [party('party-a'), party('party-b')], - decisionActorPrincipalId: 'principal-1', - evidenceRefs: ['evidence-1'], - }, - }, - }); - - expect(result.status).toBe('DISABLED'); - expect(result.mergeExecutionEnabled).toBe(false); - expect(result.analysis).toEqual({ - collisionCodes: ['COUNTERPARTY_COLLISION'], - referencePlanStatus: 'BLOCKED', - selectedSurvivorPartyRef: party('party-a'), - selectionStatus: 'SELECTED', - }); - expect(result.blockers.some(({ code }) => code === 'COUNTERPARTY_COLLISION')).toBe(true); - expect(result.blockers.some(({ code }) => code === 'CONSUMER_RECONCILIATION_UNPROVEN')).toBe( - true, + }); + expect(() => + Schema.decodeUnknownSync(PartyMergeReadinessResponseSchema)({ + analysis: { + collisionCodes: [], + referencePlanStatus: 'PLANNED', + selectedSurvivorPartyRef: party('party-a'), + selectionStatus: 'SELECTED', + }, + blockers: [], + mergeExecutionEnabled: true, + partyRefs: [party('party-a'), party('party-b')], + status: 'READY', + }), + ).toThrow(); + }), ); }); diff --git a/app/verticals/party-registry/tests/unit/merge-survivor-selection.test.ts b/app/verticals/party-registry/tests/unit/merge-survivor-selection.test.ts index 2d7003968..b4ec89e44 100644 --- a/app/verticals/party-registry/tests/unit/merge-survivor-selection.test.ts +++ b/app/verticals/party-registry/tests/unit/merge-survivor-selection.test.ts @@ -1,14 +1,12 @@ -import { expect, it } from 'effect-rstest'; import { Match, Predicate, Struct } from 'effect'; -import type { PartyRef } from '../../shared/resources/party.ts'; -import type { - MergeSurvivorCandidate, - MergeSurvivorSelectionInput, -} from '../../shared/domain/merge-selection.ts'; +import { expect, it } from 'effect-rstest'; + +import type { MergeSurvivorCandidate, MergeSurvivorSelectionInput } from '../../shared/domain/merge-selection.ts'; import { ConfirmedDuplicateDecisionIdSchema, DecisionActorPrincipalIdSchema, } from '../../shared/domain/merge-selection.ts'; +import type { PartyRef } from '../../shared/resources/party.ts'; import { selectCanonicalSurvivor } from '../../src/merge/canonical-survivor-selection.ts'; import type { CanonicalSurvivorSelection } from '../../src/merge/canonical-survivor-selection.ts'; @@ -29,9 +27,7 @@ const candidate = (resourceId: string, overrides: Partial[], -): MergeSurvivorSelectionInput => ({ +const confirmedSelection = (candidates: readonly ReturnType[]): MergeSurvivorSelectionInput => ({ candidates, confirmation: { confirmedDuplicateDecisionId: ConfirmedDuplicateDecisionIdSchema.make('decision-1'), @@ -54,10 +50,7 @@ const expectSelected = (result: CanonicalSurvivorSelection) => it('blocks survivor selection when authoritative identity truth is unresolved', () => { const result = selectCanonicalSurvivor( - confirmedSelection([ - candidate('party-a'), - candidate('party-b', { blockingAuthoritativeConflict: true }), - ]), + confirmedSelection([candidate('party-a'), candidate('party-b', { blockingAuthoritativeConflict: true })]), ); expect(Predicate.isTagged(result, 'SurvivorSelectionBlocked')).toBe(true); @@ -100,16 +93,11 @@ it('uses the governed hierarchy before reference count, lifecycle, completeness, it('uses reference stability, lifecycle, completeness, age, then resource identity deterministically', () => { const referenceWinner = selectCanonicalSurvivor( - confirmedSelection([ - candidate('a', { referenceStabilityRank: 1 }), - candidate('b', { referenceStabilityRank: 2 }), - ]), + confirmedSelection([candidate('a', { referenceStabilityRank: 1 }), candidate('b', { referenceStabilityRank: 2 })]), ); expect(expectSelected(referenceWinner).decidingCriterion).toBe('REFERENCE_STABILITY'); - const deterministic = selectCanonicalSurvivor( - confirmedSelection([candidate('party-b'), candidate('party-a')]), - ); + const deterministic = selectCanonicalSurvivor(confirmedSelection([candidate('party-b'), candidate('party-a')])); const selected = expectSelected(deterministic); expect(selected.survivorPartyRef).toEqual(party('party-a')); expect(selected.decidingCriterion).toBe('STABLE_RESOURCE_IDENTITY'); @@ -120,7 +108,10 @@ it('rejects a cross-tenant merge set before selection', () => { confirmedSelection([ candidate('party-a'), candidate('party-b', { - partyRef: { ...party('party-b'), tenantId: '22222222-2222-4222-8222-222222222222' }, + partyRef: { + ...party('party-b'), + tenantId: '22222222-2222-4222-8222-222222222222', + }, }), ]), ); @@ -128,16 +119,16 @@ it('rejects a cross-tenant merge set before selection', () => { expect(Predicate.isTagged(result, 'SurvivorSelectionBlocked')).toBe(true); expect(Struct.omit(result, ['_tag'])).toEqual({ blocker: 'CROSS_TENANT_MERGE_SET', - conflictingPartyRefs: [ - party('party-a'), - { ...party('party-b'), tenantId: '22222222-2222-4222-8222-222222222222' }, - ], + conflictingPartyRefs: [party('party-a'), { ...party('party-b'), tenantId: '22222222-2222-4222-8222-222222222222' }], }); }); it('rejects selection without an explicit confirmed duplicate decision and matching evidence set', () => { const candidates = [candidate('party-a'), candidate('party-b')]; - const unconfirmedSelection = selectCanonicalSurvivor({ candidates, confirmation: null }); + const unconfirmedSelection = selectCanonicalSurvivor({ + candidates, + confirmation: null, + }); expect(Predicate.isTagged(unconfirmedSelection, 'SurvivorSelectionBlocked')).toBe(true); expect(Struct.omit(unconfirmedSelection, ['_tag'])).toEqual({ blocker: 'DUPLICATE_SET_NOT_CONFIRMED', @@ -161,19 +152,24 @@ it('rejects selection without an explicit confirmed duplicate decision and match it('retains immutable evaluated values and explains progressive elimination for three candidates', () => { const candidates = [ - candidate('party-a', { authoritativeEvidenceRank: 3, referenceStabilityRank: 2 }), - candidate('party-b', { authoritativeEvidenceRank: 3, referenceStabilityRank: 1 }), - candidate('party-c', { authoritativeEvidenceRank: 1, referenceStabilityRank: 100 }), + candidate('party-a', { + authoritativeEvidenceRank: 3, + referenceStabilityRank: 2, + }), + candidate('party-b', { + authoritativeEvidenceRank: 3, + referenceStabilityRank: 1, + }), + candidate('party-c', { + authoritativeEvidenceRank: 1, + referenceStabilityRank: 100, + }), ]; const result = selectCanonicalSurvivor(confirmedSelection(candidates)); expect(Predicate.isTagged(result, 'CanonicalSurvivorSelected')).toBe(true); const selected = expectSelected(result); - const authority = selected.evidenceChain.find( - ({ criterion }) => criterion === 'AUTHORITATIVE_EVIDENCE', - ); - const stability = selected.evidenceChain.find( - ({ criterion }) => criterion === 'REFERENCE_STABILITY', - ); + const authority = selected.evidenceChain.find(({ criterion }) => criterion === 'AUTHORITATIVE_EVIDENCE'); + const stability = selected.evidenceChain.find(({ criterion }) => criterion === 'REFERENCE_STABILITY'); expect(authority).toBeDefined(); if (authority === undefined) { throw new Error('Expected authority'); diff --git a/app/verticals/party-registry/tests/unit/party-search-worker.test.ts b/app/verticals/party-registry/tests/unit/party-search-worker.test.ts index 7570a4cc9..d64f4fd5e 100644 --- a/app/verticals/party-registry/tests/unit/party-search-worker.test.ts +++ b/app/verticals/party-registry/tests/unit/party-search-worker.test.ts @@ -1,7 +1,8 @@ -import { assert, it } from 'effect-rstest'; import { defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; +import { assert, it } from 'effect-rstest'; + import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { PartySearchProjector } from '../../src/services/party-search-projection.service.ts'; import { definePartySearchWorker } from '../../src/workers/party-search-worker.ts'; @@ -29,64 +30,59 @@ const entrypoint = defineTenantModuleEntrypoint({ }); for (const targetField of ['partyId', 'counterpartyId'] as const) { - it.effect( - `search worker forwards ${targetField}, trusted context, and typed retryable failure`, - () => - Effect.gen(function* forwardsSearchProjection() { - const { handle, worker } = definePartySearchWorker( - { - entrypoint, - payloadSchema, - producerModuleKey: 'party.registry', - topic: context.topic, - }, - { - spanName: 'PartySearchWorkerTest', - target: (payload) => - targetField === 'partyId' - ? { partyId: payload.resourceId } - : { counterpartyId: payload.resourceId }, - }, - ); - assert.deepEqual(worker.descriptor, { - consumerModuleKey: 'party.registry', + it.effect(`search worker forwards ${targetField}, trusted context, and typed retryable failure`, () => + Effect.gen(function* forwardsSearchProjection() { + const { handle, worker } = definePartySearchWorker( + { entrypoint, - leaseDurationMs: 30_000, payloadSchema, producerModuleKey: 'party.registry', - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: context.topic, - workerKey: context.workerKey, - }); - const failure = new PartySearchProjectionUnavailable({ - code: 'party_search_projection_unavailable', - reason: 'retry this projection', - }); - let calls = 0; - const result = yield* handle( - { resourceId: yield* Schema.decodeUnknownEffect(TestResourceIdSchema)('target') }, - context, - ).pipe( - Effect.provideService(PartySearchProjector, { - project: (receivedContext, target) => { - calls += 1; - assert.equal(receivedContext, context); - assert.deepEqual( - target, - targetField === 'partyId' ? { partyId: 'target' } : { counterpartyId: 'target' }, - ); - return Effect.fail(failure); - }, - }), - Effect.catchTag('PartySearchProjectionUnavailable', (error) => Effect.succeed(error)), - ); - assert.equal(result, failure); - assert.equal(calls, 1); - }), + }, + { + spanName: 'PartySearchWorkerTest', + target: (payload) => + targetField === 'partyId' ? { partyId: payload.resourceId } : { counterpartyId: payload.resourceId }, + }, + ); + assert.deepEqual(worker.descriptor, { + consumerModuleKey: 'party.registry', + entrypoint, + leaseDurationMs: 30_000, + payloadSchema, + producerModuleKey: 'party.registry', + retryPolicy: { + initialBackoffMs: 1000, + maxAttempts: 5, + maxBackoffMs: 60_000, + multiplier: 2, + }, + topic: context.topic, + workerKey: context.workerKey, + }); + const failure = new PartySearchProjectionUnavailable({ + code: 'party_search_projection_unavailable', + reason: 'retry this projection', + }); + let calls = 0; + const result = yield* handle( + { + resourceId: yield* Schema.decodeEffect(TestResourceIdSchema)('target'), + }, + context, + ).pipe( + Effect.provideService(PartySearchProjector, { + project: (receivedContext, target) => { + calls += 1; + assert.equal(receivedContext, context); + assert.deepEqual(target, targetField === 'partyId' ? { partyId: 'target' } : { counterpartyId: 'target' }); + return Effect.fail(failure); + }, + }), + Effect.catchTag('PartySearchProjectionUnavailable', (error) => Effect.succeed(error)), + ); + assert.equal(result, failure); + assert.equal(calls, 1); + }), ); } diff --git a/app/verticals/party-registry/tests/unit/prepare-contacts-migration.test.ts b/app/verticals/party-registry/tests/unit/prepare-contacts-migration.test.ts index eb062b151..759e12229 100644 --- a/app/verticals/party-registry/tests/unit/prepare-contacts-migration.test.ts +++ b/app/verticals/party-registry/tests/unit/prepare-contacts-migration.test.ts @@ -1,21 +1,15 @@ -import { expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; import { Client } from 'pg'; -import { - classifyContactsJournalState, - prepareContactsMigration, -} from '../../scripts/prepare-contacts-migration.mts'; + +import { classifyContactsJournalState, prepareContactsMigration } from '../../scripts/prepare-contacts-migration.mts'; interface JournalClientFixture { readonly client: Client; readonly queries: string[]; } -const journalClient = ( - legacy: boolean, - contacts: boolean, - renameFailure?: Error, -): JournalClientFixture => { +const journalClient = (legacy: boolean, contacts: boolean, renameFailure?: Error): JournalClientFixture => { const queries: string[] = []; const client = new Client(); // Accepted foreign API fixture: pg Client.query returns Promises, consumed by the diff --git a/app/verticals/party-registry/tests/unit/read-outcome.test.ts b/app/verticals/party-registry/tests/unit/read-outcome.test.ts index ed50daf38..369c64c9c 100644 --- a/app/verticals/party-registry/tests/unit/read-outcome.test.ts +++ b/app/verticals/party-registry/tests/unit/read-outcome.test.ts @@ -1,5 +1,6 @@ import { Effect } from 'effect'; import { assert, it } from 'effect-rstest'; + import { readDetailResult, readUnavailable, requireReadValue } from '../../src/api/read-outcome.ts'; it.effect('detail lookup preserves the value and one-result evidence', () => { @@ -16,21 +17,18 @@ it.effect('detail lookup preserves the value and one-result evidence', () => { ); }); -it.effect( - 'missing detail produces the caller-specific typed failure without result evidence', - () => { - const reason = 'The Official Identifier does not exist'; - return requireReadValue(reason)({ _tag: 'not_found' }).pipe( - Effect.map(() => assert.fail('Missing lookup must not succeed')), - Effect.catchTag('ReadHandlerNotFound', (failure) => - Effect.sync(() => { - assert.equal(failure.code, 'read_handler_not_found'); - assert.equal(failure.reason, reason); - }), - ), - ); - }, -); +it.effect('missing detail produces the caller-specific typed failure without result evidence', () => { + const reason = 'The Official Identifier does not exist'; + return requireReadValue(reason)({ _tag: 'not_found' }).pipe( + Effect.map(() => assert.fail('Missing lookup must not succeed')), + Effect.catchTag('ReadHandlerNotFound', (failure) => + Effect.sync(() => { + assert.equal(failure.code, 'read_handler_not_found'); + assert.equal(failure.reason, reason); + }), + ), + ); +}); it('unavailable mapping retains hidden diagnostic cause and descriptor policy', () => { const cause = { diagnostic: 'private' }; diff --git a/app/verticals/party-registry/tests/unit/relationship-domain-contract.test.ts b/app/verticals/party-registry/tests/unit/relationship-domain-contract.test.ts index 547bef2cb..fe6ccf607 100644 --- a/app/verticals/party-registry/tests/unit/relationship-domain-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/relationship-domain-contract.test.ts @@ -1,5 +1,6 @@ -import { expect, it } from 'effect-rstest'; import { Effect, DateTime, Option, Schema, Predicate, Struct } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { ContactPersonOfRelationshipType, CreatePartyRelationshipPayloadSchema, @@ -41,9 +42,9 @@ const presentInstant = (value: string) => Option.some(instant(value)); it.effect('the production catalog contains only CONTACT_PERSON_OF', () => Effect.gen(function* schemaContract1() { - expect( - yield* Schema.decodeUnknownEffect(PartyRelationshipTypeSchema)('CONTACT_PERSON_OF'), - ).toBe(ContactPersonOfRelationshipType); + expect(yield* Schema.decodeEffect(PartyRelationshipTypeSchema)('CONTACT_PERSON_OF')).toBe( + ContactPersonOfRelationshipType, + ); for (const deferred of ['EMPLOYEE_OF', 'BRANCH_OF', 'OTHER']) { expect(() => Schema.decodeUnknownSync(PartyRelationshipTypeSchema)(deferred)).toThrow(); } @@ -60,19 +61,17 @@ it.effect('create accepts one provenance-backed PERSON to ORGANIZATION period sh validFrom: '2026-09-01T10:00:00.000Z', validTo: null, } as const; - const decoded = yield* Schema.decodeUnknownEffect(CreatePartyRelationshipPayloadSchema)( - payload, - ); + const decoded = yield* Schema.decodeEffect(CreatePartyRelationshipPayloadSchema)(payload); expect(decoded.relationshipType).toBe('CONTACT_PERSON_OF'); expect(() => - Schema.decodeUnknownSync(CreatePartyRelationshipPayloadSchema)({ + Schema.decodeSync(CreatePartyRelationshipPayloadSchema)({ ...payload, toPartyRef: fromPartyRef, }), ).toThrow(); expect( Option.isNone( - (yield* Schema.decodeUnknownEffect(CreatePartyRelationshipPayloadSchema)({ + (yield* Schema.decodeEffect(CreatePartyRelationshipPayloadSchema)({ ...payload, validFrom: null, })).validFrom, @@ -80,19 +79,19 @@ it.effect('create accepts one provenance-backed PERSON to ORGANIZATION period sh ).toBe(true); expect(DateTime.formatIso(Option.getOrThrow(decoded.validFrom))).toBe(payload.validFrom); expect(() => - Schema.decodeUnknownSync(CreatePartyRelationshipPayloadSchema)({ + Schema.decodeSync(CreatePartyRelationshipPayloadSchema)({ ...payload, validTo: '2026-09-01T10:00:00.000Z', }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(CreatePartyRelationshipPayloadSchema)({ + Schema.decodeSync(CreatePartyRelationshipPayloadSchema)({ ...payload, validFrom: '2026-02-30T10:00:00.000Z', }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(CreatePartyRelationshipPayloadSchema)({ + Schema.decodeSync(CreatePartyRelationshipPayloadSchema)({ ...payload, validFrom: '2026-09-01T10:00:00Z', }), @@ -110,7 +109,7 @@ it.effect('relationship timestamps and nullable periods preserve their JSON enco validFrom: null, validTo: '2026-09-01T10:00:00.000Z', }; - const decoded = yield* Schema.decodeUnknownEffect(CreatePartyRelationshipPayloadSchema)({ + const decoded = yield* Schema.decodeEffect(CreatePartyRelationshipPayloadSchema)({ ...wire, validTo: null, }); @@ -118,7 +117,7 @@ it.effect('relationship timestamps and nullable periods preserve their JSON enco ...wire, validTo: null, }); - const updated = yield* Schema.decodeUnknownEffect(UpdatePartyRelationshipPayloadSchema)({ + const updated = yield* Schema.decodeEffect(UpdatePartyRelationshipPayloadSchema)({ changeReason: 'Clarified end', expectedRevision: 1, provenance, @@ -138,7 +137,7 @@ it.effect('relationship timestamps and nullable periods preserve their JSON enco it.effect('update cannot accept endpoint or relationship type mutation fields', () => Effect.gen(function* schemaContract4() { - const decoded = yield* Schema.decodeUnknownEffect(UpdatePartyRelationshipPayloadSchema, { + const decoded = yield* Schema.decodeEffect(UpdatePartyRelationshipPayloadSchema, { onExcessProperty: 'error', })({ changeReason: 'The planned assignment was extended', @@ -151,7 +150,7 @@ it.effect('update cannot accept endpoint or relationship type mutation fields', expect(decoded.expectedRevision).toBe(2); for (const forbiddenField of ['fromPartyRef', 'toPartyRef', 'relationshipType']) { expect(() => - Schema.decodeUnknownSync(UpdatePartyRelationshipPayloadSchema, { + Schema.decodeSync(UpdatePartyRelationshipPayloadSchema, { onExcessProperty: 'error', })({ changeReason: 'The planned assignment was extended', @@ -167,33 +166,31 @@ it.effect('update cannot accept endpoint or relationship type mutation fields', }), ); -it.effect( - 'end requires effective time, provenance, and revision without inventing a generic reason', - () => - Effect.gen(function* schemaContract5() { - const decoded = yield* Schema.decodeUnknownEffect(EndPartyRelationshipPayloadSchema)({ +it.effect('end requires effective time, provenance, and revision without inventing a generic reason', () => + Effect.gen(function* schemaContract5() { + const decoded = yield* Schema.decodeEffect(EndPartyRelationshipPayloadSchema)({ + effectiveAt: '2026-09-02T10:00:00.000Z', + expectedRevision: 3, + provenance, + reason: 'The person is no longer a contact', + relationshipRef, + }); + expect(decoded.expectedRevision).toBe(3); + expect( + (yield* Schema.decodeEffect(EndPartyRelationshipPayloadSchema)({ effectiveAt: '2026-09-02T10:00:00.000Z', expectedRevision: 3, provenance, - reason: 'The person is no longer a contact', relationshipRef, - }); - expect(decoded.expectedRevision).toBe(3); - expect( - (yield* Schema.decodeUnknownEffect(EndPartyRelationshipPayloadSchema)({ - effectiveAt: '2026-09-02T10:00:00.000Z', - expectedRevision: 3, - provenance, - relationshipRef, - })).reason, - ).toBe(undefined); - }), + })).reason, + ).toBe(undefined); + }), ); it('validity uses an exclusive end boundary', () => { - expect( - classifyRelationshipValidity(absentInstant, absentInstant, instant('2026-09-01T09:59:59.999Z')), - ).toBe('CURRENT'); + expect(classifyRelationshipValidity(absentInstant, absentInstant, instant('2026-09-01T09:59:59.999Z'))).toBe( + 'CURRENT', + ); expect( classifyRelationshipValidity( presentInstant('2026-09-01T10:00:00.000Z'), @@ -223,7 +220,9 @@ it('create reuses an exact period and conflicts on a distinct overlap', () => { validFrom: presentInstant('2026-09-01T10:00:00.000Z'), validTo: presentInstant('2026-10-01T10:00:00.000Z'), } as const; - const exactPeriodDecision = decideRelationshipCreate([existing], { ...existing }); + const exactPeriodDecision = decideRelationshipCreate([existing], { + ...existing, + }); expect(Predicate.isTagged(exactPeriodDecision, 'reuse')).toBe(true); expect(Struct.omit(exactPeriodDecision, ['_tag'])).toEqual({ relationshipId: relationshipRef.resourceId, @@ -276,7 +275,9 @@ it('only a still-future validity plan is ordinarily updateable', () => { instant('2026-09-03T00:00:00.000Z'), ); expect(Predicate.isTagged(historicalEndUpdate, 'correction_required')).toBe(true); - expect(Struct.omit(historicalEndUpdate, ['_tag'])).toEqual({ fact: 'validTo' }); + expect(Struct.omit(historicalEndUpdate, ['_tag'])).toEqual({ + fact: 'validTo', + }); const pastEndUpdate = decideRelationshipUpdate( { revision: 2, @@ -301,7 +302,9 @@ it('only a still-future validity plan is ordinarily updateable', () => { instant('2026-09-03T00:00:00.000Z'), ); expect(Predicate.isTagged(staleRevisionUpdate, 'revision_conflict')).toBe(true); - expect(Struct.omit(staleRevisionUpdate, ['_tag'])).toEqual({ actualRevision: 2 }); + expect(Struct.omit(staleRevisionUpdate, ['_tag'])).toEqual({ + actualRevision: 2, + }); const unknownStartUpdate = decideRelationshipUpdate( { revision: 2, validFrom: absentInstant, validTo: absentInstant }, { @@ -342,7 +345,9 @@ it('only a still-future validity plan is ordinarily updateable', () => { instant('2026-09-03T00:00:00.000Z'), ); expect(Predicate.isTagged(historicalStartUpdate, 'correction_required')).toBe(true); - expect(Struct.omit(historicalStartUpdate, ['_tag'])).toEqual({ fact: 'validFrom' }); + expect(Struct.omit(historicalStartUpdate, ['_tag'])).toEqual({ + fact: 'validFrom', + }); }); it('end retry is exact and changed historical evidence requires correction', () => { @@ -371,7 +376,12 @@ it('end retry is exact and changed historical evidence requires correction', () expect(Predicate.isTagged(changedEndRetry, 'correction_required')).toBe(true); expect(Struct.omit(changedEndRetry, ['_tag'])).toEqual({ fact: 'validTo' }); const missingEndEvidence = decideRelationshipEnd( - { ...current, endProvenanceMethod: null, endProvenanceSource: null, endReason: null }, + { + ...current, + endProvenanceMethod: null, + endProvenanceSource: null, + endReason: null, + }, exact, instant('2026-09-03T00:00:00.000Z'), ); diff --git a/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts b/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts index b3b407c16..a39a49ae7 100644 --- a/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts @@ -1,15 +1,16 @@ -import { encodeRelationshipEventPayload } from '../../src/actions/relationship-event-payload.ts'; -import { expect, it } from 'effect-rstest'; import { Effect, DateTime, Option, Schema } from 'effect'; -import { createPartyRelationshipAction } from '../../src/actions/create-party-relationship.action.ts'; -import { endPartyRelationshipAction } from '../../src/actions/end-party-relationship.action.ts'; -import { updatePartyRelationshipAction } from '../../src/actions/update-party-relationship.action.ts'; -import { partyRelationshipDetailRead } from '../../src/api/party-relationship-detail.read.ts'; +import { expect, it } from 'effect-rstest'; + import { PartyRelationshipDetailRequestSchema, PartyRelationshipDetailResponseSchema, } from '../../shared/apis/party-relationship-detail.ts'; import { OutboxPayloadSchema as RelationshipCreatedOutboxSchema } from '../../shared/outbox/party-registry-relationship-created-v1.ts'; +import { createPartyRelationshipAction } from '../../src/actions/create-party-relationship.action.ts'; +import { endPartyRelationshipAction } from '../../src/actions/end-party-relationship.action.ts'; +import { encodeRelationshipEventPayload } from '../../src/actions/relationship-event-payload.ts'; +import { updatePartyRelationshipAction } from '../../src/actions/update-party-relationship.action.ts'; +import { partyRelationshipDetailRead } from '../../src/api/party-relationship-detail.read.ts'; const tenantId = '11111111-1111-4111-8111-111111111111'; const partyRef = (resourceId: string) => ({ @@ -26,11 +27,7 @@ const relationshipRef = { } as const; it('relationship writes are idempotent tenant Actions with dedicated authority', () => { - const actions = [ - createPartyRelationshipAction, - updatePartyRelationshipAction, - endPartyRelationshipAction, - ] as const; + const actions = [createPartyRelationshipAction, updatePartyRelationshipAction, endPartyRelationshipAction] as const; expect(actions.map(({ descriptor }) => descriptor.actionKey)).toEqual([ 'party.registry.create-party-relationship', 'party.registry.update-party-relationship', @@ -60,7 +57,9 @@ it.effect('relationship detail is a tenant-authorized governed read of one Resou expect(partyRelationshipDetailRead.descriptor.permissionTarget).toBe('tenant'); expect(partyRelationshipDetailRead.descriptor.accessKind).toBe('detail'); expect( - yield* Schema.decodeUnknownEffect(PartyRelationshipDetailRequestSchema)({ relationshipRef }), + yield* Schema.decodeEffect(PartyRelationshipDetailRequestSchema)({ + relationshipRef, + }), ).toEqual({ relationshipRef }); }), ); @@ -70,12 +69,15 @@ it.effect('relationship detail preserves canonical and stored alias endpoint con const storedFrom = partyRef('22222222-2222-4222-8222-222222222222'); const canonicalFrom = partyRef('55555555-5555-4555-8555-555555555555'); const to = partyRef('33333333-3333-4333-8333-333333333333'); - const detail = yield* Schema.decodeUnknownEffect(PartyRelationshipDetailResponseSchema)({ + const detail = yield* Schema.decodeEffect(PartyRelationshipDetailResponseSchema)({ assertionState: 'ACTIVE', endHistory: [ { effectiveAt: '2026-09-01T00:00:00.000Z', - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, reason: 'No longer the contact', recordedAt: '2026-08-20T10:00:00.000Z', }, @@ -85,7 +87,10 @@ it.effect('relationship detail preserves canonical and stored alias endpoint con requestedAlias: storedFrom, storedPartyRef: storedFrom, }, - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, recordedAt: '2026-09-01T10:00:00.000Z', relationshipRef, relationshipType: 'CONTACT_PERSON_OF', @@ -128,16 +133,20 @@ it.effect('outbox payloads carry stable refs and no mutable Party or authorizati validFrom: '2026-09-01T10:00:00.000Z', validTo: null, } as const; - const decoded = yield* Schema.decodeUnknownEffect(RelationshipCreatedOutboxSchema)(payload); + const decoded = yield* Schema.decodeEffect(RelationshipCreatedOutboxSchema)(payload); expect(yield* Schema.encodeEffect(RelationshipCreatedOutboxSchema)(decoded)).toEqual(payload); expect(() => - Schema.decodeUnknownSync(RelationshipCreatedOutboxSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownSync(RelationshipCreatedOutboxSchema, { + onExcessProperty: 'error', + })({ ...payload, authorizationGranted: true, }), ).toThrow(); expect(() => - Schema.decodeUnknownSync(RelationshipCreatedOutboxSchema, { onExcessProperty: 'error' })({ + Schema.decodeUnknownSync(RelationshipCreatedOutboxSchema, { + onExcessProperty: 'error', + })({ ...payload, party: { displayName: 'mutable copy' }, }), diff --git a/app/verticals/party-registry/tests/unit/relationship-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/relationship-persistence.service.test.ts index 86e86c875..199e5ffba 100644 --- a/app/verticals/party-registry/tests/unit/relationship-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/relationship-persistence.service.test.ts @@ -1,8 +1,9 @@ -import { TestClock } from 'effect/testing'; -import { expect, it } from 'effect-rstest'; // @effect-diagnostics globalDate:off -- Existing compatibility boundary; expires: 2026-12-31. /* eslint-disable anti-slop/no-chained-type-assertions, anti-slop/no-unsafe-dictionary-type -- This focused harness models only the Drizzle system boundary used by the Relationship service. expires: 2026-12-31. */ import { DateTime, Effect, Layer, Option, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + import { PartyAliasWriteRejected } from '../../shared/domain/merge-alias-resolution.ts'; import { CreatePartyRelationshipPayloadSchema, @@ -89,16 +90,13 @@ const transactionHarness = ( const updateSets: Readonly>[] = []; const select = () => { const rows = selectQueue.shift() ?? []; - const chain = Object.assign( - Effect.sync(() => rows), - { - for: () => Effect.succeed(rows), - from: () => chain, - limit: () => chain, - orderBy: () => chain, - where: () => chain, - }, - ); + const chain = Object.assign(Effect.succeed(rows), { + for: () => Effect.succeed(rows), + from: () => chain, + limit: () => chain, + orderBy: () => chain, + where: () => chain, + }); return chain; }; const insert = () => { @@ -125,225 +123,219 @@ const transactionHarness = ( return chain; }; // SAFETY: the harness implements precisely the select/insert/update fluent surface used here. - const transaction = { insert, select, update } as unknown as Parameters< - typeof createPartyRelationshipRecord - >[0]; + const transaction = { insert, select, update } as unknown as Parameters[0]; return { insertValues, transaction, updateSets }; }; it.layer( - Layer.effectDiscard( - TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T10:00:00.000Z'))), - ), + Layer.effectDiscard(TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe('2026-09-03T10:00:00.000Z')))), )('relationship persistence', (relationshipIt) => { - relationshipIt.effect( - 'create persists an active assertion with unknown start and derives current state', - () => - Effect.gen(function* testProgram1() { - const created = relationshipRow(); - const harness = transactionHarness( + relationshipIt.effect('create persists an active assertion with unknown start and derives current state', () => + Effect.gen(function* testProgram1() { + const created = relationshipRow(); + const harness = transactionHarness( + [ [ - [ - { archivedAt: null, currentType: 'PERSON', partyId: fromPartyId }, - { archivedAt: null, currentType: 'ORGANIZATION', partyId: toPartyId }, - ], - ...canonicalEndpointReads, - [], + { archivedAt: null, currentType: 'PERSON', partyId: fromPartyId }, + { + archivedAt: null, + currentType: 'ORGANIZATION', + partyId: toPartyId, + }, ], - [[created]], - ); - - const result = yield* createPartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeCreatePayload({ - fromPartyRef: ref(fromPartyId), - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, - relationshipType: 'CONTACT_PERSON_OF', - toPartyRef: ref(toPartyId), - validFrom: null, - validTo: null, - }), - ); + ...canonicalEndpointReads, + [], + ], + [[created]], + ); - expect(result.outcome).toBe('CREATED'); - expect(result.relationship.state).toBe('CURRENT'); - expect(harness.insertValues[0]?.['assertionState']).toBe('ACTIVE'); - expect(harness.insertValues[0]?.['validFrom']).toBe(null); - expect('state' in (harness.insertValues[0] ?? {})).toBe(false); - expect('isCurrent' in (harness.insertValues[0] ?? {})).toBe(false); - }), + const result = yield* createPartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeCreatePayload({ + fromPartyRef: ref(fromPartyId), + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, + relationshipType: 'CONTACT_PERSON_OF', + toPartyRef: ref(toPartyId), + validFrom: null, + validTo: null, + }), + ); + + expect(result.outcome).toBe('CREATED'); + expect(result.relationship.state).toBe('CURRENT'); + expect(harness.insertValues[0]?.['assertionState']).toBe('ACTIVE'); + expect(harness.insertValues[0]?.['validFrom']).toBe(null); + expect('state' in (harness.insertValues[0] ?? {})).toBe(false); + expect('isCurrent' in (harness.insertValues[0] ?? {})).toBe(false); + }), ); - relationshipIt.effect( - 'update refines an unknown historical validFrom through the persistence service', - () => - Effect.gen(function* testProgram2() { - const refinedAt = '2025-01-01T00:00:00.000Z'; - const validTo = DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')); - const current = relationshipRow({ validTo }); - const updated = relationshipRow({ - revision: 2, - validFrom: DateTime.toDateUtc(DateTime.makeUnsafe(refinedAt)), - validTo, - }); - const harness = transactionHarness( - [[current], ...canonicalEndpointReads, []], - [], - [[updated]], - ); - - const result = yield* updatePartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeUpdatePayload({ - changeReason: 'Reliable engagement evidence established the relationship start', - expectedRevision: 1, - provenance: { method: 'DOCUMENT_REVIEW', source: 'ENGAGEMENT_RECORD' }, - relationshipRef, - validFrom: refinedAt, - }), - ); + relationshipIt.effect('update refines an unknown historical validFrom through the persistence service', () => + Effect.gen(function* testProgram2() { + const refinedAt = '2025-01-01T00:00:00.000Z'; + const validTo = DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')); + const current = relationshipRow({ validTo }); + const updated = relationshipRow({ + revision: 2, + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe(refinedAt)), + validTo, + }); + const harness = transactionHarness([[current], ...canonicalEndpointReads, []], [], [[updated]]); + + const result = yield* updatePartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeUpdatePayload({ + changeReason: 'Reliable engagement evidence established the relationship start', + expectedRevision: 1, + provenance: { + method: 'DOCUMENT_REVIEW', + source: 'ENGAGEMENT_RECORD', + }, + relationshipRef, + validFrom: refinedAt, + }), + ); - expect(result.outcome).toBe('CHANGED'); - expect(DateTime.formatIso(Option.getOrThrow(result.relationship.validFrom))).toBe( - refinedAt, - ); - expect(result.relationship.state).toBe('HISTORICAL'); - expect(harness.updateSets[0]?.['validFrom']).toEqual( - DateTime.toDateUtc(DateTime.makeUnsafe(refinedAt)), - ); - expect(harness.updateSets[0]?.['revision']).toBe(2); - }), + expect(result.outcome).toBe('CHANGED'); + expect(DateTime.formatIso(Option.getOrThrow(result.relationship.validFrom))).toBe(refinedAt); + expect(result.relationship.state).toBe('HISTORICAL'); + expect(harness.updateSets[0]?.['validFrom']).toEqual(DateTime.toDateUtc(DateTime.makeUnsafe(refinedAt))); + expect(harness.updateSets[0]?.['revision']).toBe(2); + }), ); - relationshipIt.effect( - 'end keeps a future-ended relationship current and exposes bounded end history', - () => - Effect.gen(function* testProgram3() { - const effectiveAt = '2099-01-01T00:00:00.000Z'; - const survivorId = '70000000-0000-4000-8000-000000000001'; - const current = relationshipRow({ - validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), - }); - const ended = relationshipRow({ - endProvenanceMethod: 'MANUAL_CONFIRMATION', - endProvenanceSource: 'ENGAGEMENT_REVIEW', - endReason: 'A successor contact takes responsibility', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - revision: 2, - validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), - validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), - }); - const harness = transactionHarness( + relationshipIt.effect('end keeps a future-ended relationship current and exposes bounded end history', () => + Effect.gen(function* testProgram3() { + const effectiveAt = '2099-01-01T00:00:00.000Z'; + const survivorId = '70000000-0000-4000-8000-000000000001'; + const current = relationshipRow({ + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), + }); + const ended = relationshipRow({ + endProvenanceMethod: 'MANUAL_CONFIRMATION', + endProvenanceSource: 'ENGAGEMENT_REVIEW', + endReason: 'A successor contact takes responsibility', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), + revision: 2, + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')), + validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), + }); + const harness = transactionHarness( + [ + [current], [ - [current], - [{ aliasPartyId: fromPartyId, canonicalPartyId: survivorId, tenantId }], - [], - [{ partyId: survivorId }], - [], - [{ partyId: toPartyId }], + { + aliasPartyId: fromPartyId, + canonicalPartyId: survivorId, + tenantId, + }, ], [], - [[ended]], - ); - - const result = yield* endPartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeEndPayload({ - effectiveAt, - expectedRevision: 1, - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, - reason: 'A successor contact takes responsibility', - relationshipRef, - }), - ); + [{ partyId: survivorId }], + [], + [{ partyId: toPartyId }], + ], + [], + [[ended]], + ); - expect(result.relationship.state).toBe('CURRENT'); - expect(result.relationship.from.canonicalPartyRef.resourceId).toBe(survivorId); - expect(result.relationship.from.storedPartyRef.resourceId).toBe(fromPartyId); - expect(result.relationship.endHistory.length).toBe(1); - const [endEvidence] = result.relationship.endHistory; - expect(endEvidence).toBeDefined(); - if (endEvidence === undefined) { - throw new Error('Expected endEvidence'); - } - expect(DateTime.formatIso(endEvidence.effectiveAt)).toBe(effectiveAt); - expect(Option.getOrThrow(endEvidence.reason)).toBe( - 'A successor contact takes responsibility', - ); - expect('state' in (harness.updateSets[0] ?? {})).toBe(false); - expect('isCurrent' in (harness.updateSets[0] ?? {})).toBe(false); - }), + const result = yield* endPartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeEndPayload({ + effectiveAt, + expectedRevision: 1, + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, + reason: 'A successor contact takes responsibility', + relationshipRef, + }), + ); + + expect(result.relationship.state).toBe('CURRENT'); + expect(result.relationship.from.canonicalPartyRef.resourceId).toBe(survivorId); + expect(result.relationship.from.storedPartyRef.resourceId).toBe(fromPartyId); + expect(result.relationship.endHistory.length).toBe(1); + const [endEvidence] = result.relationship.endHistory; + expect(endEvidence).toBeDefined(); + if (endEvidence === undefined) { + throw new Error('Expected endEvidence'); + } + expect(DateTime.formatIso(endEvidence.effectiveAt)).toBe(effectiveAt); + expect(Option.getOrThrow(endEvidence.reason)).toBe('A successor contact takes responsibility'); + expect('state' in (harness.updateSets[0] ?? {})).toBe(false); + expect('isCurrent' in (harness.updateSets[0] ?? {})).toBe(false); + }), ); - relationshipIt.effect( - 'detail derives scheduled state and resolves stored endpoint aliases independently', - () => - Effect.gen(function* testProgram4() { - const canonicalFrom = '70000000-0000-4000-8000-000000000001'; - const middleAlias = '80000000-0000-4000-8000-000000000001'; - const scheduled = relationshipRow({ - validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2099-01-01T00:00:00.000Z')), - }); - const harness = transactionHarness([ - [scheduled], - [{ aliasPartyId: fromPartyId, canonicalPartyId: middleAlias, tenantId }], - [{ aliasPartyId: middleAlias, canonicalPartyId: canonicalFrom, tenantId }], - [], - [{ partyId: canonicalFrom }], - [], - [{ partyId: toPartyId }], - ]); + relationshipIt.effect('detail derives scheduled state and resolves stored endpoint aliases independently', () => + Effect.gen(function* testProgram4() { + const canonicalFrom = '70000000-0000-4000-8000-000000000001'; + const middleAlias = '80000000-0000-4000-8000-000000000001'; + const scheduled = relationshipRow({ + validFrom: DateTime.toDateUtc(DateTime.makeUnsafe('2099-01-01T00:00:00.000Z')), + }); + const harness = transactionHarness([ + [scheduled], + [ + { + aliasPartyId: fromPartyId, + canonicalPartyId: middleAlias, + tenantId, + }, + ], + [ + { + aliasPartyId: middleAlias, + canonicalPartyId: canonicalFrom, + tenantId, + }, + ], + [], + [{ partyId: canonicalFrom }], + [], + [{ partyId: toPartyId }], + ]); - const detail = yield* findPartyRelationshipRecord( - harness.transaction, - tenantId, - relationshipId, - ); + const detail = yield* findPartyRelationshipRecord(harness.transaction, tenantId, relationshipId); - expect(detail?.state).toBe('SCHEDULED'); - expect(detail?.from.storedPartyRef.resourceId).toBe(fromPartyId); - expect(detail?.from.canonicalPartyRef.resourceId).toBe(canonicalFrom); - expect(detail).toBeDefined(); - if (detail === undefined || detail === null) { - throw new Error('Expected detail'); - } - expect(Option.getOrThrow(detail.from.requestedAlias).resourceId).toBe(fromPartyId); - expect(Option.isNone(detail.to.requestedAlias)).toBe(true); - }), + expect(detail?.state).toBe('SCHEDULED'); + expect(detail?.from.storedPartyRef.resourceId).toBe(fromPartyId); + expect(detail?.from.canonicalPartyRef.resourceId).toBe(canonicalFrom); + expect(detail).toBeDefined(); + if (detail === undefined || detail === null) { + throw new Error('Expected detail'); + } + expect(Option.getOrThrow(detail.from.requestedAlias).resourceId).toBe(fromPartyId); + expect(Option.isNone(detail.to.requestedAlias)).toBe(true); + }), ); - relationshipIt.effect( - 'non-active assertions never read as current even with an open effective interval', - () => - Effect.all( - ['RETRACTED', 'SUPERSEDED', 'DISPUTED'].map((assertionState) => - Effect.gen(function* testProgram6() { - const harness = transactionHarness([ - [relationshipRow({ assertionState })], - ...canonicalEndpointReads, - ]); - const detail = yield* findPartyRelationshipRecord( - harness.transaction, - tenantId, - relationshipId, - ); + relationshipIt.effect('non-active assertions never read as current even with an open effective interval', () => + Effect.forEach(['RETRACTED', 'SUPERSEDED', 'DISPUTED'], (assertionState) => + Effect.gen(function* testProgram6() { + const harness = transactionHarness([[relationshipRow({ assertionState })], ...canonicalEndpointReads]); + const detail = yield* findPartyRelationshipRecord(harness.transaction, tenantId, relationshipId); - expect(detail?.assertionState).toBe(assertionState); - expect(detail?.state).toBe('HISTORICAL'); - }), - ), - ), + expect(detail?.assertionState).toBe(assertionState); + expect(detail?.state).toBe('HISTORICAL'); + }), + ), ); relationshipIt.effect( @@ -358,7 +350,13 @@ it.layer( const harness = transactionHarness( [ [relationshipRow()], - [{ aliasPartyId: fromPartyId, canonicalPartyId: survivorId, tenantId }], + [ + { + aliasPartyId: fromPartyId, + canonicalPartyId: survivorId, + tenantId, + }, + ], [], [{ partyId: survivorId }], [], @@ -376,7 +374,10 @@ it.layer( decodeUpdatePayload({ changeReason: 'A revised planned start', expectedRevision: 1, - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, relationshipRef, validFrom: '2099-01-01T00:00:00.000Z', }), @@ -389,45 +390,56 @@ it.layer( }), ); - relationshipIt.effect( - 'create rejects an explicit alias endpoint with canonical survivor guidance', - () => - Effect.gen(function* testProgram8() { - const survivorId = '70000000-0000-4000-8000-000000000001'; - const harness = transactionHarness([ - [ - { - archivedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), - currentType: 'PERSON', - partyId: fromPartyId, - }, - { archivedAt: null, currentType: 'ORGANIZATION', partyId: toPartyId }, - ], - [{ aliasPartyId: fromPartyId, canonicalPartyId: survivorId, tenantId }], - [], - [{ partyId: survivorId }], - ]); - const rejection = yield* createPartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeCreatePayload({ - fromPartyRef: ref(fromPartyId), - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, - relationshipType: 'CONTACT_PERSON_OF', - toPartyRef: ref(toPartyId), - validFrom: null, - validTo: null, - }), - ).pipe(Effect.flip); + relationshipIt.effect('create rejects an explicit alias endpoint with canonical survivor guidance', () => + Effect.gen(function* testProgram8() { + const survivorId = '70000000-0000-4000-8000-000000000001'; + const harness = transactionHarness([ + [ + { + archivedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-01-01T00:00:00.000Z')), + currentType: 'PERSON', + partyId: fromPartyId, + }, + { + archivedAt: null, + currentType: 'ORGANIZATION', + partyId: toPartyId, + }, + ], + [ + { + aliasPartyId: fromPartyId, + canonicalPartyId: survivorId, + tenantId, + }, + ], + [], + [{ partyId: survivorId }], + ]); + const rejection = yield* createPartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeCreatePayload({ + fromPartyRef: ref(fromPartyId), + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, + relationshipType: 'CONTACT_PERSON_OF', + toPartyRef: ref(toPartyId), + validFrom: null, + validTo: null, + }), + ).pipe(Effect.flip); - expect(Predicate.isTagged(rejection, 'PartyAliasWriteRejected')).toBe(true); - if (Schema.is(PartyAliasWriteRejected)(rejection)) { - expect(rejection.canonicalPartyRef.resourceId).toBe(survivorId); - } - expect(harness.insertValues.length).toBe(0); - }), + expect(Predicate.isTagged(rejection, 'PartyAliasWriteRejected')).toBe(true); + if (Schema.is(PartyAliasWriteRejected)(rejection)) { + expect(rejection.canonicalPartyRef.resourceId).toBe(survivorId); + } + expect(harness.insertValues.length).toBe(0); + }), ); relationshipIt.effect('a known historical start cannot be rewritten by ordinary update', () => @@ -448,7 +460,10 @@ it.layer( decodeUpdatePayload({ changeReason: 'The previous start was wrong', expectedRevision: 1, - provenance: { method: 'DOCUMENT_REVIEW', source: 'ENGAGEMENT_RECORD' }, + provenance: { + method: 'DOCUMENT_REVIEW', + source: 'ENGAGEMENT_RECORD', + }, relationshipRef, validFrom: '2025-02-01T00:00:00.000Z', }), @@ -476,11 +491,7 @@ it.layer( validTo: DateTime.toDateUtc(DateTime.makeUnsafe('2099-01-01T00:00:00.000Z')), }); const updated = relationshipRow({ revision: 2 }); - const harness = transactionHarness( - [[current], ...canonicalEndpointReads, []], - [], - [[updated]], - ); + const harness = transactionHarness([[current], ...canonicalEndpointReads, []], [], [[updated]]); const result = yield* updatePartyRelationshipRecord( harness.transaction, tenantId, @@ -489,7 +500,10 @@ it.layer( decodeUpdatePayload({ changeReason: 'The planned handover was canceled', expectedRevision: 1, - provenance: { method: 'MANUAL_CONFIRMATION', source: 'ENGAGEMENT_REVIEW' }, + provenance: { + method: 'MANUAL_CONFIRMATION', + source: 'ENGAGEMENT_REVIEW', + }, relationshipRef, validTo: null, }), @@ -511,113 +525,107 @@ it.layer( }), ); - relationshipIt.effect( - 'update can shorten a future planned end to a valid retrospective end with new evidence', - () => - Effect.gen(function* testProgram11() { - const validFrom = DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')); - const effectiveAt = '2026-02-01T00:00:00.000Z'; - const current = relationshipRow({ - validFrom, - validTo: DateTime.toDateUtc(DateTime.makeUnsafe('2099-01-01T00:00:00.000Z')), - }); - const updated = relationshipRow({ - endProvenanceMethod: 'DOCUMENT_REVIEW', - endProvenanceSource: 'ENGAGEMENT_RECORD', - endReason: 'The handover actually completed earlier', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - revision: 2, - validFrom, - validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), - }); - const harness = transactionHarness( - [[current], ...canonicalEndpointReads, []], - [], - [[updated]], - ); - const result = yield* updatePartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeUpdatePayload({ - changeReason: 'The handover actually completed earlier', - expectedRevision: 1, - provenance: { method: 'DOCUMENT_REVIEW', source: 'ENGAGEMENT_RECORD' }, - relationshipRef, - validTo: effectiveAt, - }), - ); - - expect(result.outcome).toBe('CHANGED'); - expect(result.relationship.state).toBe('HISTORICAL'); - const [endEvidence] = result.relationship.endHistory; - expect(endEvidence).toBeDefined(); - if (endEvidence === undefined) { - throw new Error('Expected endEvidence'); - } - expect(DateTime.formatIso(endEvidence.effectiveAt)).toBe(effectiveAt); - expect(harness.updateSets[0]?.['endProvenanceSource']).toBe('ENGAGEMENT_RECORD'); - expect(harness.updateSets[0]?.['endedByActionInvocationId']).toBe(actionInvocationId); - }), - ); - - relationshipIt.effect( - 'an evidence-backed end without a generic reason stays visible and retries exactly', - () => - Effect.gen(function* testProgram12() { - const effectiveAt = '2026-02-01T00:00:00.000Z'; - const ended = relationshipRow({ - endProvenanceMethod: 'DOCUMENT_REVIEW', - endProvenanceSource: 'ENGAGEMENT_RECORD', - endedByActionInvocationId: actionInvocationId, - endedByPrincipalId: principalId, - endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), - revision: 2, - validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), - }); - const harness = transactionHarness( - [[relationshipRow()], ...canonicalEndpointReads], - [], - [[ended]], - ); - const payload = { - effectiveAt, + relationshipIt.effect('update can shorten a future planned end to a valid retrospective end with new evidence', () => + Effect.gen(function* testProgram11() { + const validFrom = DateTime.toDateUtc(DateTime.makeUnsafe('2025-01-01T00:00:00.000Z')); + const effectiveAt = '2026-02-01T00:00:00.000Z'; + const current = relationshipRow({ + validFrom, + validTo: DateTime.toDateUtc(DateTime.makeUnsafe('2099-01-01T00:00:00.000Z')), + }); + const updated = relationshipRow({ + endProvenanceMethod: 'DOCUMENT_REVIEW', + endProvenanceSource: 'ENGAGEMENT_RECORD', + endReason: 'The handover actually completed earlier', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), + revision: 2, + validFrom, + validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), + }); + const harness = transactionHarness([[current], ...canonicalEndpointReads, []], [], [[updated]]); + const result = yield* updatePartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeUpdatePayload({ + changeReason: 'The handover actually completed earlier', expectedRevision: 1, - provenance: { method: 'DOCUMENT_REVIEW', source: 'ENGAGEMENT_RECORD' }, + provenance: { + method: 'DOCUMENT_REVIEW', + source: 'ENGAGEMENT_RECORD', + }, relationshipRef, - }; - const result = yield* endPartyRelationshipRecord( - harness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeEndPayload(payload), - ); - expect(result.relationship.endHistory.length).toBe(1); - const [endEvidence] = result.relationship.endHistory; - expect(endEvidence).toBeDefined(); - if (endEvidence === undefined) { - throw new Error('Expected endEvidence'); - } - expect(Option.isNone(endEvidence.reason)).toBe(true); - expect(harness.updateSets[0]?.['endReason']).toBe(null); + validTo: effectiveAt, + }), + ); + + expect(result.outcome).toBe('CHANGED'); + expect(result.relationship.state).toBe('HISTORICAL'); + const [endEvidence] = result.relationship.endHistory; + expect(endEvidence).toBeDefined(); + if (endEvidence === undefined) { + throw new Error('Expected endEvidence'); + } + expect(DateTime.formatIso(endEvidence.effectiveAt)).toBe(effectiveAt); + expect(harness.updateSets[0]?.['endProvenanceSource']).toBe('ENGAGEMENT_RECORD'); + expect(harness.updateSets[0]?.['endedByActionInvocationId']).toBe(actionInvocationId); + }), + ); - const retryHarness = transactionHarness([[ended], ...canonicalEndpointReads]); - const retry = yield* endPartyRelationshipRecord( - retryHarness.transaction, - tenantId, - principalId, - actionInvocationId, - decodeEndPayload({ - ...payload, - expectedRevision: 2, - }), - ); - expect(retry.outcome).toBe('UNCHANGED'); - expect(retryHarness.updateSets.length).toBe(0); - }), + relationshipIt.effect('an evidence-backed end without a generic reason stays visible and retries exactly', () => + Effect.gen(function* testProgram12() { + const effectiveAt = '2026-02-01T00:00:00.000Z'; + const ended = relationshipRow({ + endProvenanceMethod: 'DOCUMENT_REVIEW', + endProvenanceSource: 'ENGAGEMENT_RECORD', + endedByActionInvocationId: actionInvocationId, + endedByPrincipalId: principalId, + endedRecordedAt: DateTime.toDateUtc(DateTime.makeUnsafe('2026-09-03T00:00:00.000Z')), + revision: 2, + validTo: DateTime.toDateUtc(DateTime.makeUnsafe(effectiveAt)), + }); + const harness = transactionHarness([[relationshipRow()], ...canonicalEndpointReads], [], [[ended]]); + const payload = { + effectiveAt, + expectedRevision: 1, + provenance: { + method: 'DOCUMENT_REVIEW', + source: 'ENGAGEMENT_RECORD', + }, + relationshipRef, + }; + const result = yield* endPartyRelationshipRecord( + harness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeEndPayload(payload), + ); + expect(result.relationship.endHistory.length).toBe(1); + const [endEvidence] = result.relationship.endHistory; + expect(endEvidence).toBeDefined(); + if (endEvidence === undefined) { + throw new Error('Expected endEvidence'); + } + expect(Option.isNone(endEvidence.reason)).toBe(true); + expect(harness.updateSets[0]?.['endReason']).toBe(null); + + const retryHarness = transactionHarness([[ended], ...canonicalEndpointReads]); + const retry = yield* endPartyRelationshipRecord( + retryHarness.transaction, + tenantId, + principalId, + actionInvocationId, + decodeEndPayload({ + ...payload, + expectedRevision: 2, + }), + ); + expect(retry.outcome).toBe('UNCHANGED'); + expect(retryHarness.updateSets.length).toBe(0); + }), ); }); diff --git a/app/verticals/party-registry/tests/unit/runtime-locales.test.ts b/app/verticals/party-registry/tests/unit/runtime-locales.test.ts index 50ea417a0..da927c04b 100644 --- a/app/verticals/party-registry/tests/unit/runtime-locales.test.ts +++ b/app/verticals/party-registry/tests/unit/runtime-locales.test.ts @@ -1,10 +1,10 @@ +import { Predicate } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Predicate } from 'effect'; -import runtime from '../../src/modern.runtime.ts'; import csResource from '../../locales/cs/translation.json' with { type: 'json' }; import enResource from '../../locales/en/translation.json' with { type: 'json' }; import { partyRegistryI18nResources } from '../../src/i18n/resources.ts'; +import runtime from '../../src/modern.runtime.ts'; it('runtime registers the Party Registry page namespace alongside shared translations', () => { const configuration = Predicate.isFunction(runtime) ? runtime('index') : runtime; diff --git a/app/verticals/party-registry/tests/unit/schema-contract.test.ts b/app/verticals/party-registry/tests/unit/schema-contract.test.ts index 5ca77f23a..e3ceed690 100644 --- a/app/verticals/party-registry/tests/unit/schema-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/schema-contract.test.ts @@ -1,11 +1,11 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Filesystem migration contract verifies actual checked-in SQL files; expires: 2026-12-31. -import { assert, expect, it } from 'effect-rstest'; -import { Effect } from 'effect'; - -import { readdir, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { NodeFileSystem } from '@effect/platform-node'; import { getTableName, isTable } from 'drizzle-orm'; import { getTableConfig, PgDialect } from 'drizzle-orm/pg-core'; +import { FileSystem, Effect } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; + import * as schemaExports from '../../src/db/schema.ts'; import { PARTY_SCHEMA_NAME, @@ -71,9 +71,7 @@ const foreignKey = (table: (typeof configuredTables)[number], name: string) => { }; it('owns the complete Party Registry operational catalog in the party schema', () => { - const exportedTables = Object.values(schemaExports).flatMap((value) => - isTable(value) ? [value] : [], - ); + const exportedTables = Object.values(schemaExports).flatMap((value) => (isTable(value) ? [value] : [])); const qualifiedNames = exportedTables .map((table) => { const config = getTableConfig(table); @@ -145,18 +143,13 @@ it('keeps tenant-admin Counterparty reads on an atomic owner-local projection', } expect( getTableName( - foreignKey( - counterpartyAdminReadModels, - 'party_counterparty_admin_model_source_fk', - ).reference().foreignTable, + foreignKey(counterpartyAdminReadModels, 'party_counterparty_admin_model_source_fk').reference().foreignTable, ), ).toBe(getTableName(counterparties)); expect( getTableName( - foreignKey( - counterpartyRoleAdminReadModels, - 'party_counterparty_role_admin_model_source_fk', - ).reference().foreignTable, + foreignKey(counterpartyRoleAdminReadModels, 'party_counterparty_role_admin_model_source_fk').reference() + .foreignTable, ), ).toBe(getTableName(counterpartyRolePeriods)); expect( @@ -202,9 +195,7 @@ it('gives every tenant-owned record a tenant-qualified identity and forced-RLS p it('models Party identity and assertion history without conflating effective and recorded time', () => { const partyConfig = configOf(parties); - expect( - partyConfig.columns.some((column) => column.name === 'current_display_name' && !column.notNull), - ).toBeTruthy(); + expect(partyConfig.columns.some((column) => column.name === 'current_display_name' && !column.notNull)).toBeTruthy(); expect(uniqueColumns(parties, 'party_parties_tenant_id_uk')).toEqual(['tenant_id', 'party_id']); expect(partyConfig.checks.map((candidate) => candidate.name).toSorted()).toEqual([ 'party_parties_display_name_ck', @@ -232,10 +223,7 @@ it('models Party identity and assertion history without conflating effective and ).toBeTruthy(); } expect( - getTableName( - foreignKey(partyFactAssertions, 'party_fact_assertions_tenant_party_fk').reference() - .foreignTable, - ), + getTableName(foreignKey(partyFactAssertions, 'party_fact_assertions_tenant_party_fk').reference().foreignTable), ).toBe(getTableName(parties)); }); @@ -260,26 +248,15 @@ it('keeps official assertions historical while exclusive claims own exact matchi ]); const claims = configOf(partyIdentifierClaims); expect( - claims.indexes.some( - (candidate) => candidate.config.name === 'party_identifier_claims_party_lookup_idx', - ), + claims.indexes.some((candidate) => candidate.config.name === 'party_identifier_claims_party_lookup_idx'), ).toBeTruthy(); }); it('preserves bounded external observation evidence separately from trusted actor and effective time', () => { - for (const table of [ - partyFactAssertions, - partyOfficialIdentifiers, - partyContactPoints, - partyContactPointPurposes, - ]) { + for (const table of [partyFactAssertions, partyOfficialIdentifiers, partyContactPoints, partyContactPointPurposes]) { const config = configOf(table); - expect( - config.columns.some((column) => column.name === 'external_evidence' && !column.notNull), - ).toBeTruthy(); - const evidenceCheck = config.checks.find( - (candidate) => candidate.name === `${config.name}_external_evidence_ck`, - ); + expect(config.columns.some((column) => column.name === 'external_evidence' && !column.notNull)).toBeTruthy(); + const evidenceCheck = config.checks.find((candidate) => candidate.name === `${config.name}_external_evidence_ck`); expect(evidenceCheck).toBeTruthy(); if (evidenceCheck === undefined) { throw new Error('Expected value to be present'); @@ -296,10 +273,7 @@ it('preserves bounded external observation evidence separately from trusted acto const checksOf = (table: (typeof configuredTables)[number]) => Object.fromEntries( - configOf(table).checks.map((candidate) => [ - candidate.name, - dialect.sqlToQuery(candidate.value).sql, - ]), + configOf(table).checks.map((candidate) => [candidate.name, dialect.sqlToQuery(candidate.value).sql]), ); const checkSql = (checks: Readonly>, name: string) => checks[name] ?? ''; @@ -354,10 +328,7 @@ it('models typed contact point lifecycles with owner-local references', () => { it('models contact point purpose lifecycles with owner-local references', () => { const purposeConfig = configOf(partyContactPointPurposes); const purposeChecks = Object.fromEntries( - purposeConfig.checks.map((candidate) => [ - candidate.name, - dialect.sqlToQuery(candidate.value).sql, - ]), + purposeConfig.checks.map((candidate) => [candidate.name, dialect.sqlToQuery(candidate.value).sql]), ); assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /REGISTERED/u); assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /BILLING/u); @@ -391,8 +362,7 @@ it('models contact point purpose lifecycles with owner-local references', () => ); assert.equal( getTableName( - foreignKey(partyContactPointPurposes, 'party_contact_point_purposes_contact_fk').reference() - .foreignTable, + foreignKey(partyContactPointPurposes, 'party_contact_point_purposes_contact_fk').reference().foreignTable, ), getTableName(partyContactPoints), ); @@ -411,20 +381,13 @@ it('models contact point purpose lifecycles with owner-local references', () => it('models relationship lifecycles with owner-local references', () => { const relationshipChecks = checksOf(partyRelationships); assert.match(checkSql(relationshipChecks, 'party_relationships_type_ck'), /CONTACT_PERSON_OF/u); - assert.notMatch( - checkSql(relationshipChecks, 'party_relationships_type_ck'), - /EMPLOYEE_OF|BRANCH_OF|OTHER/u, - ); + assert.notMatch(checkSql(relationshipChecks, 'party_relationships_type_ck'), /EMPLOYEE_OF|BRANCH_OF|OTHER/u); assert.ok( configOf(partyRelationships).columns.some( (column) => column.name === 'revision' && column.notNull && column.hasDefault, ), ); - assert.ok( - configOf(partyRelationships).columns.some( - (column) => column.name === 'valid_from' && !column.notNull, - ), - ); + assert.ok(configOf(partyRelationships).columns.some((column) => column.name === 'valid_from' && !column.notNull)); assert.ok( configOf(partyRelationships).columns.some( (column) => column.name === 'assertion_state' && column.notNull && column.hasDefault, @@ -468,10 +431,7 @@ it('models relationship lifecycles with owner-local references', () => { ); } assert.equal( - getTableName( - foreignKey(partyRelationships, 'party_relationships_tenant_from_party_fk').reference() - .foreignTable, - ), + getTableName(foreignKey(partyRelationships, 'party_relationships_tenant_from_party_fk').reference().foreignTable), getTableName(parties), ); }); @@ -491,10 +451,7 @@ it('models Counterparty lifecycles with owner-local references', () => { const roleChecks = checksOf(counterpartyRolePeriods); assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /CUSTOMER/u); assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /SUPPLIER/u); - assert.notMatch( - checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), - /BUSINESS_PARTNER/u, - ); + assert.notMatch(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /BUSINESS_PARTNER/u); for (const column of [ 'add_reason', 'add_evidence_refs', @@ -516,10 +473,7 @@ it('models Counterparty lifecycles with owner-local references', () => { roleEndEvidence, /valid_to[^)]*is null[^)]*end_provenance_source[^)]*is null[^)]*end_provenance_method[^)]*is null/u, ); - assert.match( - roleEndEvidence, - /valid_to.*is not null.*end_provenance_source.*btrim.*end_provenance_method.*btrim/u, - ); + assert.match(roleEndEvidence, /valid_to.*is not null.*end_provenance_source.*btrim.*end_provenance_method.*btrim/u); assert.equal( configOf(counterpartyRolePeriods).indexes.some( (candidate) => candidate.config.name === 'party_counterparty_role_periods_current_uk', @@ -527,10 +481,7 @@ it('models Counterparty lifecycles with owner-local references', () => { false, 'effective intervals, not an is_current unique index, own role-period uniqueness', ); - assert.notMatch( - checkSql(roleChecks, 'party_counterparty_role_periods_state_ck'), - /ACTIVE' and [^)]*is_current/u, - ); + assert.notMatch(checkSql(roleChecks, 'party_counterparty_role_periods_state_ck'), /ACTIVE' and [^)]*is_current/u); }); it('persists one recoverable match decision per Action and bounded duplicate review state', () => { @@ -543,9 +494,7 @@ it('persists one recoverable match decision per Action and bounded duplicate rev expect(decisionChecks['party_match_decisions_outcome_ck'] ?? '').toMatch(/MATCHED/u); expect(decisionChecks['party_match_decisions_outcome_ck'] ?? '').toMatch(/AMBIGUOUS/u); expect(decisionChecks['party_match_decisions_outcome_ck'] ?? '').toMatch(/NO_MATCH/u); - expect( - configOf(duplicateCandidateCases).columns.some((column) => column.name === 'revision'), - ).toBeTruthy(); + expect(configOf(duplicateCandidateCases).columns.some((column) => column.name === 'revision')).toBeTruthy(); const activeCaseIndex = configOf(duplicateCandidateCases).indexes.find( (candidate) => candidate.config.name === 'party_duplicate_cases_fingerprint_uk', ); @@ -556,10 +505,7 @@ it('persists one recoverable match decision per Action and bounded duplicate rev 'match_rule_version', ]); expect( - getTableName( - foreignKey(duplicateCandidateCases, 'party_duplicate_cases_prior_case_fk').reference() - .foreignTable, - ), + getTableName(foreignKey(duplicateCandidateCases, 'party_duplicate_cases_prior_case_fk').reference().foreignTable), ).toBe('duplicate_candidate_cases'); expect(activeCaseIndex?.config.where).toBeTruthy(); if (activeCaseIndex?.config.where === undefined) { @@ -573,15 +519,11 @@ it('persists one recoverable match decision per Action and bounded duplicate rev if (snapshotCheck === undefined) { throw new Error('Expected value to be present'); } - expect(dialect.sqlToQuery(snapshotCheck.value).sql).toMatch( - /provenance.*source.*method.*validFrom/u, - ); + expect(dialect.sqlToQuery(snapshotCheck.value).sql).toMatch(/provenance.*source.*method.*validFrom/u); expect( getTableName( - foreignKey( - duplicateCandidateCaseParties, - 'party_duplicate_candidate_case_parties_tenant_party_fk', - ).reference().foreignTable, + foreignKey(duplicateCandidateCaseParties, 'party_duplicate_candidate_case_parties_tenant_party_fk').reference() + .foreignTable, ), ).toBe(getTableName(parties)); }); @@ -590,20 +532,12 @@ it('prepares append-only correction and non-executable merge records with safe a const correctionConfig = configOf(partyCorrections); expect(correctionConfig.columns.some((column) => column.name === 'reason')).toBeTruthy(); expect(correctionConfig.columns.some((column) => column.name === 'evidence_refs')).toBeTruthy(); - expect( - correctionConfig.columns.some((column) => column.name === 'acting_principal_id'), - ).toBeTruthy(); - expect( - correctionConfig.columns.some((column) => column.name === 'approving_principal_id'), - ).toBeTruthy(); + expect(correctionConfig.columns.some((column) => column.name === 'acting_principal_id')).toBeTruthy(); + expect(correctionConfig.columns.some((column) => column.name === 'approving_principal_id')).toBeTruthy(); expect(correctionConfig.columns.some((column) => column.name === 'policy_version')).toBeTruthy(); - expect( - correctionConfig.checks.some((candidate) => candidate.name === 'party_corrections_target_ck'), - ).toBeTruthy(); + expect(correctionConfig.checks.some((candidate) => candidate.name === 'party_corrections_target_ck')).toBeTruthy(); - const mergeStatus = configOf(partyMerges).checks.find( - (candidate) => candidate.name === 'party_merges_status_ck', - ); + const mergeStatus = configOf(partyMerges).checks.find((candidate) => candidate.name === 'party_merges_status_ck'); expect(mergeStatus).toBeTruthy(); if (mergeStatus === undefined) { throw new Error('Expected value to be present'); @@ -630,58 +564,52 @@ it('prepares append-only correction and non-executable merge records with safe a ]) { expect(dialect.sqlToQuery(preparedEvidence.value).sql.includes(field), field).toBeTruthy(); } - expect(uniqueColumns(partyAliases, 'party_aliases_alias_uk')).toEqual([ - 'tenant_id', - 'alias_party_id', - ]); + expect(uniqueColumns(partyAliases, 'party_aliases_alias_uk')).toEqual(['tenant_id', 'alias_party_id']); expect( - configOf(partyAliases).checks.some( - (candidate) => candidate.name === 'party_aliases_not_self_ck', - ), + configOf(partyAliases).checks.some((candidate) => candidate.name === 'party_aliases_not_self_ck'), ).toBeTruthy(); }); -it.effect( - 'ships an independent Party migration with forced RLS and append-only correction evidence', - () => +it.layer(NodeFileSystem.layer)('schema-contract', (suite) => { + suite.effect('ships an independent Party migration with forced RLS and append-only correction evidence', () => Effect.gen(function* testScenario() { - const drizzleConfig = yield* Effect.promise(() => - readFile(new URL('../../drizzle.config.ts', import.meta.url), 'utf-8'), + const drizzleConfig = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL('../../drizzle.config.ts', import.meta.url))), ); expect(drizzleConfig).toMatch(/__drizzle_migrations_party/u); expect(drizzleConfig).toMatch(/\.\/src\/db\/schema\.ts/u); const migrationDirectory = new URL('../../drizzle/', import.meta.url); - const migrationDirectoryEntries = yield* Effect.promise(() => - readdir(migrationDirectory, { withFileTypes: true }), - ); - const migrationFolders = migrationDirectoryEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .toSorted(); + const fileSystem = yield* FileSystem.FileSystem; + const migrationDirectoryEntries = yield* fileSystem.readDirectory(fileURLToPath(migrationDirectory)); + const migrationDirectories: string[] = []; + for (const entry of migrationDirectoryEntries) { + const info = yield* fileSystem.stat(fileURLToPath(new URL(entry, migrationDirectory))); + if (info.type === 'Directory') { + migrationDirectories.push(entry); + } + } + const migrationFolders = migrationDirectories.toSorted(); expect(migrationFolders.length >= 2).toBeTruthy(); const remediationFolder = migrationFolders.find((name) => name.endsWith('_nebulous_cardiac')); expect(remediationFolder).toBeTruthy(); if (remediationFolder === undefined) { throw new Error('Expected value to be present'); } - const remediation = yield* Effect.promise(() => - readFile(new URL(`${remediationFolder}/migration.sql`, migrationDirectory), 'utf-8'), + const remediation = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL(`${remediationFolder}/migration.sql`, migrationDirectory))), ); expect(remediation).toMatch(/party_match_decisions_create_result_ck/u); expect(remediation).toMatch(/committed_create_outcome/u); - const migration = yield* Effect.promise(() => - readFile( - new URL(`${migrationFolders[0] ?? ''}/migration.sql`, migrationDirectory), - 'utf-8', - ), + const migration = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(fileURLToPath(new URL(`${migrationFolders[0] ?? ''}/migration.sql`, migrationDirectory))), + ); + expect(migration.match(/ALTER TABLE "party"\."[^"]+" ENABLE ROW LEVEL SECURITY;/gu)?.length).toBe( + PARTY_TABLE_INVENTORY.length, + ); + expect(migration.match(/ALTER TABLE "party"\."[^"]+" FORCE ROW LEVEL SECURITY;/gu)?.length).toBe( + PARTY_TABLE_INVENTORY.length, ); - expect( - migration.match(/ALTER TABLE "party"\."[^"]+" ENABLE ROW LEVEL SECURITY;/gu)?.length, - ).toBe(PARTY_TABLE_INVENTORY.length); - expect( - migration.match(/ALTER TABLE "party"\."[^"]+" FORCE ROW LEVEL SECURITY;/gu)?.length, - ).toBe(PARTY_TABLE_INVENTORY.length); expect(migration).not.toMatch(/REFERENCES "(?:core|auth|contacts)"\./u); expect(migration).toMatch(/party_reject_correction_mutation/u); expect(migration).toMatch(/before update or delete on "party"\."party_corrections"/iu); @@ -693,25 +621,24 @@ it.effect( /party_counterparty_role_periods_no_overlap_excl[\s\S]*EXCLUDE USING gist[\s\S]*tstzrange/iu, ); }), -); + ); -it.effect('registers Party ownership in application database grants and exact verification', () => - Effect.gen(function* testScenario() { - const bootstrap = yield* Effect.promise(() => - readFile( - new URL('../../../../scripts/postgres/bootstrap-runtime-role.mts', import.meta.url), - 'utf-8', - ), - ); - const verifier = yield* Effect.promise(() => - readFile( - new URL('../../../../scripts/verify-application-db-schema.mts', import.meta.url), - 'utf-8', - ), - ); - expect(bootstrap).toMatch(/\['core', 'auth', 'contacts', 'party'\]/u); - expect(verifier).toMatch(/\['auth', 'contacts', 'core', 'party'\]/u); - expect(verifier).toMatch(/__drizzle_migrations_party/u); - expect(verifier).toMatch(/verticals\/party-registry\/scripts\/verify-db-schema\.mts/u); - }), -); + suite.effect('registers Party ownership in application database grants and exact verification', () => + Effect.gen(function* testScenario() { + const bootstrap = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString( + fileURLToPath(new URL('../../../../scripts/postgres/bootstrap-runtime-role.mts', import.meta.url)), + ), + ); + const verifier = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString( + fileURLToPath(new URL('../../../../scripts/verify-application-db-schema.mts', import.meta.url)), + ), + ); + expect(bootstrap).toMatch(/\['core', 'auth', 'contacts', 'party'\]/u); + expect(verifier).toMatch(/\[\s*'auth',\s*'contacts',\s*'core',\s*'party',?\s*\]/u); + expect(verifier).toMatch(/__drizzle_migrations_party/u); + expect(verifier).toMatch(/verticals\/party-registry\/scripts\/verify-db-schema\.mts/u); + }), + ); +}); diff --git a/app/verticals/party-registry/tests/unit/search-contract.test.ts b/app/verticals/party-registry/tests/unit/search-contract.test.ts index d180d5d97..6b7d0a635 100644 --- a/app/verticals/party-registry/tests/unit/search-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/search-contract.test.ts @@ -1,18 +1,12 @@ +import { Effect, Schema } from 'effect'; import { expect, it } from 'effect-rstest'; -import { Effect, Schema } from 'effect'; import { CounterpartiesProviderRequestSchema, CounterpartiesProviderResponseSchema, } from '../../shared/apis/counterparties-search.ts'; -import { - PartiesProviderRequestSchema, - PartiesProviderResponseSchema, -} from '../../shared/apis/parties-search.ts'; -import { - COUNTERPARTY_SEARCH_SEMANTICS, - PARTY_SEARCH_SEMANTICS, -} from '../../shared/domain/search-descriptor.ts'; +import { PartiesProviderRequestSchema, PartiesProviderResponseSchema } from '../../shared/apis/parties-search.ts'; +import { COUNTERPARTY_SEARCH_SEMANTICS, PARTY_SEARCH_SEMANTICS } from '../../shared/domain/search-descriptor.ts'; it('Party-owned search semantics expose only current approved V1 facts', () => { const searchableFacts: readonly string[] = PARTY_SEARCH_SEMANTICS.searchableFacts; @@ -38,16 +32,16 @@ it('Counterparty semantics retain Legal Entity and current-period role boundarie it.effect('Party Search accepts a bounded query and an explicit archived switch', () => Effect.gen(function* testScenario() { expect( - yield* Schema.decodeUnknownEffect(PartiesProviderRequestSchema)({ + yield* Schema.decodeEffect(PartiesProviderRequestSchema)({ includeArchived: true, query: ' ACME ', }), ).toEqual({ includeArchived: true, query: 'ACME' }); + expect(() => Schema.decodeSync(PartiesProviderRequestSchema)({ query: ' ' })).toThrow(); expect(() => - Schema.decodeUnknownSync(PartiesProviderRequestSchema)({ query: ' ' }), - ).toThrow(); - expect(() => - Schema.decodeUnknownSync(PartiesProviderRequestSchema)({ query: 'a'.repeat(201) }), + Schema.decodeSync(PartiesProviderRequestSchema)({ + query: 'a'.repeat(201), + }), ).toThrow(); }), ); @@ -55,7 +49,7 @@ it.effect('Party Search accepts a bounded query and an explicit archived switch' it.effect('Counterparty Search exposes only the closed current-role filter', () => Effect.gen(function* testScenario() { expect( - yield* Schema.decodeUnknownEffect(CounterpartiesProviderRequestSchema)({ + yield* Schema.decodeEffect(CounterpartiesProviderRequestSchema)({ includeArchived: false, query: 'ACME', role: 'CUSTOMER', @@ -72,7 +66,7 @@ it.effect('Counterparty Search exposes only the closed current-role filter', () it.effect('Party Search result is a minimal canonical projection without PII match evidence', () => Effect.gen(function* testScenario() { - const result = yield* Schema.decodeUnknownEffect(PartiesProviderResponseSchema)([ + const result = yield* Schema.decodeEffect(PartiesProviderResponseSchema)([ { archived: false, matchedViaAlias: true, @@ -86,12 +80,7 @@ it.effect('Party Search result is a minimal canonical projection without PII mat }, ]); - expect(Object.keys(result[0] ?? {}).toSorted()).toEqual([ - 'archived', - 'matchedViaAlias', - 'ref', - 'title', - ]); + expect(Object.keys(result[0] ?? {}).toSorted()).toEqual(['archived', 'matchedViaAlias', 'ref', 'title']); expect('email' in (result[0] ?? {})).toBe(false); expect('identifier' in (result[0] ?? {})).toBe(false); expect('matchedValue' in (result[0] ?? {})).toBe(false); @@ -101,7 +90,7 @@ it.effect('Party Search result is a minimal canonical projection without PII mat it.effect('Counterparty Search result distinguishes Counterparty and canonical Party', () => Effect.gen(function* testScenario() { const tenantId = '10000000-0000-4000-8000-000000000001'; - const result = yield* Schema.decodeUnknownEffect(CounterpartiesProviderResponseSchema)([ + const result = yield* Schema.decodeEffect(CounterpartiesProviderResponseSchema)([ { currentRoles: ['CUSTOMER', 'SUPPLIER'], legalEntity: { diff --git a/app/verticals/party-registry/tests/unit/search-core-adapter.test.ts b/app/verticals/party-registry/tests/unit/search-core-adapter.test.ts index 3d56c6066..7d7c0020c 100644 --- a/app/verticals/party-registry/tests/unit/search-core-adapter.test.ts +++ b/app/verticals/party-registry/tests/unit/search-core-adapter.test.ts @@ -1,8 +1,8 @@ -import { expect, it } from 'effect-rstest'; - -import { Effect, Schema, Predicate } from 'effect'; import { CoreSearchProjectionHitSchema } from '@app/core-runtime'; import type { CoreSearchQueryRuntimeService } from '@app/core-runtime'; +import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makePartySearchProjectionGateway } from '../../src/search/parties.provider.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; @@ -19,7 +19,7 @@ const counterpartyRef = (resourceId: string) => ({ resourceType: 'party.registry.counterparty', tenantId, }); -const partyAliasHit = Schema.decodeUnknownSync(CoreSearchProjectionHitSchema)({ +const partyAliasHit = Schema.decodeSync(CoreSearchProjectionHitSchema)({ archived: false, facets: [], matchedRef: partyRef('absorbed'), @@ -27,7 +27,7 @@ const partyAliasHit = Schema.decodeUnknownSync(CoreSearchProjectionHitSchema)({ ref: partyRef('survivor'), title: 'ACME', }); -const counterpartyHit = Schema.decodeUnknownSync(CoreSearchProjectionHitSchema)({ +const counterpartyHit = Schema.decodeSync(CoreSearchProjectionHitSchema)({ archived: false, facets: [], matchedSubjectRef: partyRef('absorbed'), @@ -50,7 +50,7 @@ const counterpartyHit = Schema.decodeUnknownSync(CoreSearchProjectionHitSchema)( ], title: 'ACME', }); -const wrongResourceHit = Schema.decodeUnknownSync(CoreSearchProjectionHitSchema)({ +const wrongResourceHit = Schema.decodeSync(CoreSearchProjectionHitSchema)({ archived: false, facets: [], metadata: [], @@ -69,7 +69,11 @@ it.effect('Party adapter queries only the Core-owned Party projection and maps a }; const gateway = makePartySearchProjectionGateway(core); - const hits = yield* gateway.searchParties({ includeArchived: true, query: 'ACME', tenantId }); + const hits = yield* gateway.searchParties({ + includeArchived: true, + query: 'ACME', + tenantId, + }); expect(calls).toEqual([ { @@ -91,75 +95,71 @@ it.effect('Party adapter queries only the Core-owned Party projection and maps a }), ); -it.effect( - 'Counterparty adapter uses trusted Legal Entity, effective time, role facet and safe periods', - () => - Effect.gen(function* counterpartyAdapterQuery() { - const calls: unknown[] = []; - const core: CoreSearchQueryRuntimeService = { - search: (input) => { - calls.push(input); - return Effect.succeed([counterpartyHit]); - }, - }; - const effectiveAt = '2026-09-03T12:00:00.000Z'; +it.effect('Counterparty adapter uses trusted Legal Entity, effective time, role facet and safe periods', () => + Effect.gen(function* counterpartyAdapterQuery() { + const calls: unknown[] = []; + const core: CoreSearchQueryRuntimeService = { + search: (input) => { + calls.push(input); + return Effect.succeed([counterpartyHit]); + }, + }; + const effectiveAt = '2026-09-03T12:00:00.000Z'; + + const hits = yield* makePartySearchProjectionGateway(core).searchCounterparties({ + effectiveAt, + includeArchived: false, + legalEntityId, + query: 'ACME', + role: 'CUSTOMER', + tenantId, + }); - const hits = yield* makePartySearchProjectionGateway(core).searchCounterparties({ + expect(calls).toEqual([ + { effectiveAt, + facets: [{ key: 'current-role', values: ['CUSTOMER'] }], includeArchived: false, - legalEntityId, + moduleId: 'party.registry', query: 'ACME', - role: 'CUSTOMER', + resourceType: 'party.registry.counterparty', + selectedLegalEntityId: legalEntityId, tenantId, - }); - - expect(calls).toEqual([ - { - effectiveAt, - facets: [{ key: 'current-role', values: ['CUSTOMER'] }], - includeArchived: false, - moduleId: 'party.registry', - query: 'ACME', - resourceType: 'party.registry.counterparty', - selectedLegalEntityId: legalEntityId, - tenantId, - }, - ]); - expect(hits).toEqual([ - { - canonicalPartyRef: partyRef('survivor'), - counterpartyRef: counterpartyRef('cp-1'), - legalEntity: { legalEntityId, tenantId }, - matchedPartyRef: partyRef('absorbed'), - partyArchived: false, - partyTitle: 'ACME', - rolePeriods: [ - { - role: 'CUSTOMER', - validFrom: '2026-01-01T00:00:00.000Z', - validTo: '2027-01-01T00:00:00.000Z', - }, - ], - }, - ]); - }), + }, + ]); + expect(hits).toEqual([ + { + canonicalPartyRef: partyRef('survivor'), + counterpartyRef: counterpartyRef('cp-1'), + legalEntity: { legalEntityId, tenantId }, + matchedPartyRef: partyRef('absorbed'), + partyArchived: false, + partyTitle: 'ACME', + rolePeriods: [ + { + role: 'CUSTOMER', + validFrom: '2026-01-01T00:00:00.000Z', + validTo: '2027-01-01T00:00:00.000Z', + }, + ], + }, + ]); + }), ); -it.effect( - 'Party adapter fails closed when a generic projection returns the wrong resource contract', - () => - Effect.gen(function* invalidProjectionContract() { - const core: CoreSearchQueryRuntimeService = { - search: () => Effect.succeed([wrongResourceHit]), - }; +it.effect('Party adapter fails closed when a generic projection returns the wrong resource contract', () => + Effect.gen(function* invalidProjectionContract() { + const core: CoreSearchQueryRuntimeService = { + search: () => Effect.succeed([wrongResourceHit]), + }; - const failure = yield* Effect.exit( - makePartySearchProjectionGateway(core).searchParties({ - includeArchived: false, - query: 'Wrong', - tenantId, - }), - ); - expect(Predicate.isTagged(failure, 'Failure')).toBeTruthy(); - }), + const failure = yield* Effect.exit( + makePartySearchProjectionGateway(core).searchParties({ + includeArchived: false, + query: 'Wrong', + tenantId, + }), + ); + expect(Predicate.isTagged(failure, 'Failure')).toBeTruthy(); + }), ); diff --git a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts index 11027f8e0..0257296bf 100644 --- a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts +++ b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts @@ -1,20 +1,18 @@ -import { expect, it } from 'effect-rstest'; - -import { DateTime, Effect, Option, Schema } from 'effect'; import { + CoreSearchProjectionStore, makeCoreSearchIngestion, createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '@app/core-runtime'; import type { OutboxMessage, OutboxWorkerHandlerContext } from '@app/core-runtime'; import { bindActionTestServices, makeActionTestHarness } from '@app/core-runtime/testing/actions'; -import { updatePartyOfficialIdentifierAction } from '../../src/actions/update-party-official-identifier.action.ts'; +import { DateTime, Effect, Option, Schema } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { createPartyAction } from '../../src/actions/create-party.action.ts'; import { resolveDuplicateCandidateMatchAction } from '../../src/actions/resolve-duplicate-candidate-match.action.ts'; -import { - makePartySearchProjector, - PartySearchProjector, -} from '../../src/services/party-search-projection.service.ts'; +import { updatePartyOfficialIdentifierAction } from '../../src/actions/update-party-official-identifier.action.ts'; +import { makePartySearchProjector, PartySearchProjector } from '../../src/services/party-search-projection.service.ts'; import type { PartySearchSourceSnapshot, PartySearchSourceValue, @@ -112,7 +110,10 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { Effect.sync(() => { canonical = { ...canonical, - parties: canonical.parties.map((party) => ({ ...party, identifiers: values })), + parties: canonical.parties.map((party) => ({ + ...party, + identifiers: values, + })), projectionVersion: '2', }; }); @@ -120,9 +121,7 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { Effect.gen(function* deliverIdentifierMessage() { if (message.topic === 'party.registry.official-identifier-added.v1') { const { descriptor } = projectOfficialIdentifierAddedToSearchWorker; - const payload = yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)( - message.payloadJson, - ); + const payload = yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)(message.payloadJson); yield* handleProjectOfficialIdentifierAddedToSearch(payload, { ...baseContext, topic: message.topic, @@ -131,9 +130,7 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { } else { expect(message.topic).toBe('party.registry.official-identifier-updated.v1'); const { descriptor } = projectOfficialIdentifierUpdatedToSearchWorker; - const payload = yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)( - message.payloadJson, - ); + const payload = yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)(message.payloadJson); yield* handleProjectOfficialIdentifierUpdatedToSearch(payload, { ...baseContext, topic: message.topic, @@ -144,14 +141,17 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { return { deliver, query: () => - createCoreSearchQueryRuntime(store).search({ - effectiveAt: '2026-09-03T00:00:00.000Z', - includeArchived: false, - moduleId: 'party.registry', - query: identifier.value, - resourceType: 'party.registry.party', - tenantId, - }), + Effect.gen(function* queryIdentifiers() { + const search = yield* createCoreSearchQueryRuntime; + return yield* search.search({ + effectiveAt: '2026-09-03T00:00:00.000Z', + includeArchived: false, + moduleId: 'party.registry', + query: identifier.value, + resourceType: 'party.registry.party', + tenantId, + }); + }).pipe(Effect.provideService(CoreSearchProjectionStore, store)), replaceIdentifiers, seed: projector.project(baseContext, { partyId: partyRef.resourceId }), }; @@ -173,7 +173,10 @@ const assertIdentifierOutbox = ( throw new Error('Expected value to be present'); } expect(commit.evidence.domainEvents[outbox.domainEventIndex]?.eventType).toBe(eventType); - expect(outbox.message.payloadJson).toEqual({ officialIdentifierRef, partyRef }); + expect(outbox.message.payloadJson).toEqual({ + officialIdentifierRef, + partyRef, + }); return outbox; }; @@ -191,157 +194,162 @@ const assertAttachedIdentifierDelivery = ( expect(yield* search.query()).toEqual(hits); }); -it.effect( - 'CreateParty MATCHED_EXISTING publishes an attached identifier and indexes it after delivery only', - () => - Effect.gen(function* matchedExistingCreateScenario() { - const search = makeSearchFixture([]); - yield* search.seed; - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - services: [ - bindActionTestServices(createPartyAction, { - createOrMatch: () => - search.replaceIdentifiers([identifier]).pipe( - Effect.as({ - addedOfficialIdentifierRefs: [officialIdentifierRef], - decisionRef, - outcome: 'MATCHED_EXISTING' as const, - partyRef, - }), - ), - }), - ], - tenantPermission: 'allowed', - }); - const result = yield* harness.runtime.runAction({ - payload: { - candidate: { - displayName: 'Acme', - evidenceRefs: ['evidence:confirmed-tax-registration'], - officialIdentifiers: [ - { identifierType: 'CZ_DIC', value: identifier.value, verification: 'VERIFIED' }, - ], - partyType: 'ORGANIZATION', - provenance: { method: 'DOCUMENT_REVIEW', source: 'USER_ASSERTION' }, - validFrom: identifier.validFrom, - }, +it.effect('CreateParty MATCHED_EXISTING publishes an attached identifier and indexes it after delivery only', () => + Effect.gen(function* matchedExistingCreateScenario() { + const search = makeSearchFixture([]); + yield* search.seed; + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + services: [ + bindActionTestServices(createPartyAction, { + createOrMatch: () => + search.replaceIdentifiers([identifier]).pipe( + Effect.as({ + addedOfficialIdentifierRefs: [officialIdentifierRef], + decisionRef, + outcome: 'MATCHED_EXISTING' as const, + partyRef, + }), + ), + }), + ], + tenantPermission: 'allowed', + }); + const result = yield* harness.runtime.runAction({ + payload: { + candidate: { + displayName: 'Acme', + evidenceRefs: ['evidence:confirmed-tax-registration'], + officialIdentifiers: [ + { + identifierType: 'CZ_DIC', + value: identifier.value, + verification: 'VERIFIED', + }, + ], + partyType: 'ORGANIZATION', + provenance: { method: 'DOCUMENT_REVIEW', source: 'USER_ASSERTION' }, + validFrom: identifier.validFrom, }, - principal, - registration: createPartyAction, - transport: { correlationId: 'identifier-sync', idempotencyKey: 'match-identifier-1' }, - }); - expect(result).toEqual({ decisionRef, outcome: 'MATCHED_EXISTING', partyRef }); - yield* assertAttachedIdentifierDelivery(harness, search); - }), + }, + principal, + registration: createPartyAction, + transport: { + correlationId: 'identifier-sync', + idempotencyKey: 'match-identifier-1', + }, + }); + expect(result).toEqual({ + decisionRef, + outcome: 'MATCHED_EXISTING', + partyRef, + }); + yield* assertAttachedIdentifierDelivery(harness, search); + }), ); -it.effect( - 'reviewed MATCH_EXISTING publishes an attached identifier and indexes it after delivery only', - () => - Effect.gen(function* reviewedMatchScenario() { - const search = makeSearchFixture([]); - yield* search.seed; - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - services: [ - bindActionTestServices(resolveDuplicateCandidateMatchAction, { - resolve: () => - search.replaceIdentifiers([identifier]).pipe( - Effect.as({ - addedOfficialIdentifierRefs: [officialIdentifierRef], - caseRef, - decisionRef, - lifecycleState: 'RESOLVED' as const, - outcome: 'MATCH_EXISTING' as const, - partyRef, - }), - ), - }), - ], - tenantPermission: 'allowed', - }); - const result = yield* harness.runtime.runAction({ - payload: { - caseRef, - expectedRevision: 1, - reason: 'Confirmed existing Party from source evidence', - selectedPartyRef: partyRef, - }, - principal, - registration: resolveDuplicateCandidateMatchAction, - transport: { - correlationId: 'identifier-sync', - idempotencyKey: 'review-match-identifier-1', - }, - }); - expect(result).toEqual({ +it.effect('reviewed MATCH_EXISTING publishes an attached identifier and indexes it after delivery only', () => + Effect.gen(function* reviewedMatchScenario() { + const search = makeSearchFixture([]); + yield* search.seed; + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + services: [ + bindActionTestServices(resolveDuplicateCandidateMatchAction, { + resolve: () => + search.replaceIdentifiers([identifier]).pipe( + Effect.as({ + addedOfficialIdentifierRefs: [officialIdentifierRef], + caseRef, + decisionRef, + lifecycleState: 'RESOLVED' as const, + outcome: 'MATCH_EXISTING' as const, + partyRef, + }), + ), + }), + ], + tenantPermission: 'allowed', + }); + const result = yield* harness.runtime.runAction({ + payload: { caseRef, - decisionRef, - lifecycleState: 'RESOLVED', - outcome: 'MATCH_EXISTING', - partyRef, - }); - yield* assertAttachedIdentifierDelivery(harness, search); - }), + expectedRevision: 1, + reason: 'Confirmed existing Party from source evidence', + selectedPartyRef: partyRef, + }, + principal, + registration: resolveDuplicateCandidateMatchAction, + transport: { + correlationId: 'identifier-sync', + idempotencyKey: 'review-match-identifier-1', + }, + }); + expect(result).toEqual({ + caseRef, + decisionRef, + lifecycleState: 'RESOLVED', + outcome: 'MATCH_EXISTING', + partyRef, + }); + yield* assertAttachedIdentifierDelivery(harness, search); + }), ); -it.effect( - 'END_VALIDITY refreshes search only after its committed identifier message and remains replay-safe', - () => - Effect.gen(function* endIdentifierSearchScenario() { - const search = makeSearchFixture([identifier]); - yield* search.seed; - const validTo = '2026-08-01T00:00:00.000Z'; - const before = { - state: 'ACTIVE', - validTo: null, - verification: 'VERIFIED', - verifiedAt: null, - verifiedByPrincipalId: null, - } as const; - const after = { ...before, state: 'ENDED', validTo } as const; - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - services: [ - bindActionTestServices(updatePartyOfficialIdentifierAction, { - update: () => - search.replaceIdentifiers([{ ...identifier, state: 'ENDED', validTo }]).pipe( - Effect.as({ - after, - before, - result: { - officialIdentifierRef, - partyRef, - state: 'ENDED', - validTo: Option.some(DateTime.makeUnsafe(validTo)), - verification: 'VERIFIED', - }, - }), - ), - }), - ], - tenantPermission: 'allowed', - }); - yield* harness.runtime.runAction({ - payload: { - change: { type: 'END_VALIDITY', validTo }, - evidenceRefs: ['evidence:retired'], - officialIdentifierRef, - reason: 'Identifier validly retired', - }, - principal, - registration: updatePartyOfficialIdentifierAction, - transport: { correlationId: 'identifier-sync', idempotencyKey: 'end-identifier-1' }, - }); - const outbox = assertIdentifierOutbox( - harness, - 'party.registry.official-identifier-updated.v1', - ); - expect((yield* search.query()).length).toBe(1); - yield* search.deliver(outbox.message); - expect(yield* search.query()).toEqual([]); - yield* search.deliver(outbox.message); - expect(yield* search.query()).toEqual([]); - }), +it.effect('END_VALIDITY refreshes search only after its committed identifier message and remains replay-safe', () => + Effect.gen(function* endIdentifierSearchScenario() { + const search = makeSearchFixture([identifier]); + yield* search.seed; + const validTo = '2026-08-01T00:00:00.000Z'; + const before = { + state: 'ACTIVE', + validTo: null, + verification: 'VERIFIED', + verifiedAt: null, + verifiedByPrincipalId: null, + } as const; + const after = { ...before, state: 'ENDED', validTo } as const; + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + services: [ + bindActionTestServices(updatePartyOfficialIdentifierAction, { + update: () => + search.replaceIdentifiers([{ ...identifier, state: 'ENDED', validTo }]).pipe( + Effect.as({ + after, + before, + result: { + officialIdentifierRef, + partyRef, + state: 'ENDED', + validTo: Option.some(DateTime.makeUnsafe(validTo)), + verification: 'VERIFIED', + }, + }), + ), + }), + ], + tenantPermission: 'allowed', + }); + yield* harness.runtime.runAction({ + payload: { + change: { type: 'END_VALIDITY', validTo }, + evidenceRefs: ['evidence:retired'], + officialIdentifierRef, + reason: 'Identifier validly retired', + }, + principal, + registration: updatePartyOfficialIdentifierAction, + transport: { + correlationId: 'identifier-sync', + idempotencyKey: 'end-identifier-1', + }, + }); + const outbox = assertIdentifierOutbox(harness, 'party.registry.official-identifier-updated.v1'); + expect((yield* search.query()).length).toBe(1); + yield* search.deliver(outbox.message); + expect(yield* search.query()).toEqual([]); + yield* search.deliver(outbox.message); + expect(yield* search.query()).toEqual([]); + }), ); diff --git a/app/verticals/party-registry/tests/unit/search-projector.test.ts b/app/verticals/party-registry/tests/unit/search-projector.test.ts index 4c56cd152..fab129bcb 100644 --- a/app/verticals/party-registry/tests/unit/search-projector.test.ts +++ b/app/verticals/party-registry/tests/unit/search-projector.test.ts @@ -1,21 +1,22 @@ -import { expect, it } from 'effect-rstest'; -import { TestClock } from 'effect/testing'; - -import { Effect, Exit, Match, Predicate } from 'effect'; import { + CoreSearchProjectionStore, createCoreSearchQueryRuntime, makeCoreSearchIngestion, makeInMemoryCoreSearchProjectionStore, } from '@app/core-runtime'; import type { CoreSearchProjectionDocument, OutboxWorkerHandlerContext } from '@app/core-runtime'; +import { Effect, Exit, Match, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; +import { TestClock } from 'effect/testing'; + +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; +import { makePartySearchProjectionGateway } from '../../src/search/parties.provider.ts'; import { buildPartySearchDocuments, makePartySearchProjector, } from '../../src/services/party-search-projection.service.ts'; import type { PartySearchSourceSnapshot } from '../../src/services/party-search-projection.service.ts'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; -import { makePartySearchProjectionGateway } from '../../src/search/parties.provider.ts'; -import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; const partyRef = { @@ -68,88 +69,91 @@ const snapshot: PartySearchSourceSnapshot = { removedRefs: [], tenantId, }; -it.effect( - 'post-commit projection makes only active permission-safe identity evidence searchable', - () => - Effect.gen(function* testScenario() { - const documents = yield* buildPartySearchDocuments(snapshot); - const store = makeInMemoryCoreSearchProjectionStore(); - yield* Effect.forEach((document: CoreSearchProjectionDocument) => - store.apply({ - document, - kind: 'upsert', - }), - )(documents); - const search = createCoreSearchQueryRuntime(store); - const query = (value: string) => - search.search({ - effectiveAt: '2026-09-03T00:00:00.000Z', - includeArchived: false, - moduleId: 'party.registry', - query: value, - resourceType: 'party.registry.party', - tenantId, - }); - const publicHits = yield* query('public@example.test'); - const identifierHits = yield* query('12345678'); - expect(publicHits.length).toBe(1); - expect(identifierHits.length).toBe(1); - expect(yield* query('private@example.test')).toEqual([]); - expect(yield* query('+420123456789')).toEqual([]); - expect(publicHits).toEqual([ - { archived: false, facets: [], metadata: [], ref: partyRef, title: 'ACME' }, - ]); - }), +it.effect('post-commit projection makes only active permission-safe identity evidence searchable', () => + Effect.gen(function* testScenario() { + const documents = yield* buildPartySearchDocuments(snapshot); + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + yield* Effect.forEach((document: CoreSearchProjectionDocument) => + store.apply({ + document, + kind: 'upsert', + }), + )(documents); + const query = (value: string) => + search.search({ + effectiveAt: '2026-09-03T00:00:00.000Z', + includeArchived: false, + moduleId: 'party.registry', + query: value, + resourceType: 'party.registry.party', + tenantId, + }); + const publicHits = yield* query('public@example.test'); + const identifierHits = yield* query('12345678'); + expect(publicHits.length).toBe(1); + expect(identifierHits.length).toBe(1); + expect(yield* query('private@example.test')).toEqual([]); + expect(yield* query('+420123456789')).toEqual([]); + expect(publicHits).toEqual([ + { + archived: false, + facets: [], + metadata: [], + ref: partyRef, + title: 'ACME', + }, + ]); + }), ); -it.effect( - 'aliases collapse to canonical identity and only alias-only evidence labels the match', - () => - Effect.gen(function* testScenario() { - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const store = makeInMemoryCoreSearchProjectionStore(); - const documents = yield* buildPartySearchDocuments({ - ...snapshot, - parties: [ - { - ...party, - aliases: [ - { - ...party, - displayName: 'Old Company', - ref: { - ...partyRef, - resourceId: 'absorbed', - }, +it.effect('aliases collapse to canonical identity and only alias-only evidence labels the match', () => + Effect.gen(function* testScenario() { + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + const documents = yield* buildPartySearchDocuments({ + ...snapshot, + parties: [ + { + ...party, + aliases: [ + { + ...party, + displayName: 'Old Company', + ref: { + ...partyRef, + resourceId: 'absorbed', }, - ], - }, - ], + }, + ], + }, + ], + }); + yield* Effect.forEach((document: CoreSearchProjectionDocument) => + store.apply({ + document, + kind: 'upsert', + }), + )(documents); + const query = (value: string) => + search.search({ + includeArchived: false, + moduleId: 'party.registry', + query: value, + resourceType: 'party.registry.party', + tenantId, }); - yield* Effect.forEach((document: CoreSearchProjectionDocument) => - store.apply({ - document, - kind: 'upsert', - }), - )(documents); - const query = (value: string) => - createCoreSearchQueryRuntime(store).search({ - includeArchived: false, - moduleId: 'party.registry', - query: value, - resourceType: 'party.registry.party', - tenantId, - }); - const alias = yield* query('Old Company'); - expect(alias.length).toBe(1); - expect(alias[0]?.ref).toEqual(partyRef); - expect(alias[0]?.matchedRef?.resourceId).toBe('absorbed'); - const canonicalHits = yield* query('ACME'); - expect(canonicalHits[0]?.matchedRef).toBe(undefined); - }), + const alias = yield* query('Old Company'); + expect(alias.length).toBe(1); + expect(alias[0]?.ref).toEqual(partyRef); + expect(alias[0]?.matchedRef?.resourceId).toBe('absorbed'); + const canonicalHits = yield* query('ACME'); + expect(canonicalHits[0]?.matchedRef).toBe(undefined); + }), ); const context: OutboxWorkerHandlerContext = { attemptNumber: 1, @@ -168,6 +172,7 @@ it.effect( () => Effect.gen(function* testScenario() { const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); let current = snapshot; const projector = makePartySearchProjector( { @@ -181,7 +186,7 @@ it.effect( partyId: partyRef.resourceId, }); const query = (includeArchived = false) => - createCoreSearchQueryRuntime(store).search({ + search.search({ includeArchived, moduleId: 'party.registry', query: 'ACME', @@ -223,323 +228,315 @@ it.effect( expect(yield* query(true)).toEqual([]); }), ); -it.effect( - 'future-ended contact disappears at its period boundary without another lifecycle message', - () => - Effect.gen(function* testScenario() { - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const store = makeInMemoryCoreSearchProjectionStore(); - const documents = yield* buildPartySearchDocuments({ - ...snapshot, - parties: [ - { - ...party, - contacts: [ - { - privacy: 'PUBLIC', - state: 'ACTIVE', - type: 'EMAIL', - validFrom: '2026-01-01T00:00:00.000Z', - validTo: '2026-09-04T00:00:00.000Z', - value: 'timed@example.test', - }, - ], - }, - ], - }); - yield* Effect.forEach((document: CoreSearchProjectionDocument) => - store.apply({ - document, - kind: 'upsert', - }), - )(documents); - const query = (effectiveAt: string) => - createCoreSearchQueryRuntime(store).search({ - effectiveAt, - includeArchived: false, - moduleId: 'party.registry', - query: 'timed@example.test', - resourceType: 'party.registry.party', - tenantId, - }); - const currentHits = yield* query('2026-09-03T00:00:00.000Z'); - expect(currentHits.length).toBe(1); - expect(yield* query('2026-09-04T00:00:00.000Z')).toEqual([]); - }), -); -it.effect( - 'Counterparty identity survives aliases, current-role expiry and canonical-party collisions', - () => - Effect.gen(function* testScenario() { - const legalEntityId = '20000000-0000-4000-8000-000000000002'; - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const aliasRef = { - ...partyRef, - resourceId: 'absorbed', - }; - const store = makeInMemoryCoreSearchProjectionStore(); - const documents = yield* buildPartySearchDocuments({ - ...snapshot, - counterparties: ['cp-1', 'cp-2'].map((resourceId) => ({ - legalEntityId, - partyRef, - ref: { - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.counterparty', - tenantId, - }, - rolePeriods: [ +it.effect('future-ended contact disappears at its period boundary without another lifecycle message', () => + Effect.gen(function* testScenario() { + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + const documents = yield* buildPartySearchDocuments({ + ...snapshot, + parties: [ + { + ...party, + contacts: [ { - role: 'CUSTOMER', + privacy: 'PUBLIC', state: 'ACTIVE', + type: 'EMAIL', validFrom: '2026-01-01T00:00:00.000Z', - validTo: '2026-10-01T00:00:00.000Z', + validTo: '2026-09-04T00:00:00.000Z', + value: 'timed@example.test', }, ], - storedPartyRef: aliasRef, - })), - parties: [ - { - ...party, - aliases: [ - { - ...party, - displayName: 'Old Company', - ref: aliasRef, - }, - ], - }, - ], - }); - yield* Effect.forEach((document: CoreSearchProjectionDocument) => - store.apply({ - document, - kind: 'upsert', - }), - )(documents); - const gateway = makePartySearchProjectionGateway(createCoreSearchQueryRuntime(store)); - const input = { - effectiveAt: '2026-09-03T00:00:00.000Z', - includeArchived: false, - legalEntityId, - query: 'Old Company', - role: 'CUSTOMER' as const, - tenantId, - }; - const hits = yield* gateway.searchCounterparties(input); - expect(hits.length).toBe(2); - expect(hits.map((hit) => hit.counterpartyRef.resourceId)).toEqual(['cp-1', 'cp-2']); - const normalized = normalizeCounterpartySearchHits(input, hits); - const normalizedItems = Match.value(normalized).pipe( - Match.tag('SearchResults', ({ items }) => items), - Match.tag('SearchProjectionViolation', ({ reason }) => - expect.unreachable(`Expected normalized search results: ${reason}`), - ), - Match.exhaustive, - ); - expect(normalizedItems[0]?.collision?.kind).toBe('CANONICAL_PARTY_COUNTERPARTY_COLLISION'); - expect(normalizedItems[0]?.party.matchedViaAlias).toBe(true); - expect( - yield* gateway.searchCounterparties({ - ...input, - effectiveAt: '2026-10-01T00:00:00.000Z', - }), - ).toEqual([]); - }), -); -it.effect( - 'shared public contact returns multiple Parties without uniqueness or matching authority', - () => - Effect.gen(function* testScenario() { - yield* TestClock.setTime(Date.parse('2026-09-03T00:00:00.000Z')); - const store = makeInMemoryCoreSearchProjectionStore(); - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const documents = yield* buildPartySearchDocuments({ - ...snapshot, - parties: [ - party, - { - ...party, - ref: { - ...partyRef, - resourceId: 'party-2', - }, - }, - ], - }); - yield* Effect.forEach((document: CoreSearchProjectionDocument) => - store.apply({ - document, - kind: 'upsert', - }), - )(documents); - const hits = yield* createCoreSearchQueryRuntime(store).search({ + }, + ], + }); + yield* Effect.forEach((document: CoreSearchProjectionDocument) => + store.apply({ + document, + kind: 'upsert', + }), + )(documents); + const query = (effectiveAt: string) => + search.search({ + effectiveAt, includeArchived: false, moduleId: 'party.registry', - query: 'public@example.test', + query: 'timed@example.test', resourceType: 'party.registry.party', tenantId, }); - expect(hits.map((hit) => hit.ref.resourceId)).toEqual(['party-1', 'party-2']); - }), + const currentHits = yield* query('2026-09-03T00:00:00.000Z'); + expect(currentHits.length).toBe(1); + expect(yield* query('2026-09-04T00:00:00.000Z')).toEqual([]); + }), ); -it.effect( - 'rebuild reconciles omitted documents and preserves tombstones against stale lifecycle delivery', - () => - Effect.gen(function* testScenario() { - const store = makeInMemoryCoreSearchProjectionStore(); - let current = snapshot; - const projector = makePartySearchProjector( - { - load: () => Effect.succeed(current), - }, - makeCoreSearchIngestion(store), - store, - ); - yield* projector.project(context, { - partyId: 'party-1', - }); - current = { - ...snapshot, - parties: [], - projectionVersion: '8', - }; - yield* projector.project(context, { - rebuild: true, - }); - yield* projector.project(context, { - rebuild: true, - }); - current = snapshot; - yield* projector.project(context, { - partyId: 'party-1', - }); - expect( - yield* createCoreSearchQueryRuntime(store).search({ - includeArchived: true, +it.effect('Counterparty identity survives aliases, current-role expiry and canonical-party collisions', () => + Effect.gen(function* testScenario() { + const legalEntityId = '20000000-0000-4000-8000-000000000002'; + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const aliasRef = { + ...partyRef, + resourceId: 'absorbed', + }; + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + const documents = yield* buildPartySearchDocuments({ + ...snapshot, + counterparties: ['cp-1', 'cp-2'].map((resourceId) => ({ + legalEntityId, + partyRef, + ref: { moduleId: 'party.registry', - query: 'ACME', - resourceType: 'party.registry.party', + resourceId, + resourceType: 'party.registry.counterparty', tenantId, - }), - ).toEqual([]); - }), + }, + rolePeriods: [ + { + role: 'CUSTOMER', + state: 'ACTIVE', + validFrom: '2026-01-01T00:00:00.000Z', + validTo: '2026-10-01T00:00:00.000Z', + }, + ], + storedPartyRef: aliasRef, + })), + parties: [ + { + ...party, + aliases: [ + { + ...party, + displayName: 'Old Company', + ref: aliasRef, + }, + ], + }, + ], + }); + yield* Effect.forEach((document: CoreSearchProjectionDocument) => + store.apply({ + document, + kind: 'upsert', + }), + )(documents); + const gateway = makePartySearchProjectionGateway(search); + const input = { + effectiveAt: '2026-09-03T00:00:00.000Z', + includeArchived: false, + legalEntityId, + query: 'Old Company', + role: 'CUSTOMER' as const, + tenantId, + }; + const hits = yield* gateway.searchCounterparties(input); + expect(hits.length).toBe(2); + expect(hits.map((hit) => hit.counterpartyRef.resourceId)).toEqual(['cp-1', 'cp-2']); + const normalized = normalizeCounterpartySearchHits(input, hits); + const normalizedItems = Match.value(normalized).pipe( + Match.tag('SearchResults', ({ items }) => items), + Match.tag('SearchProjectionViolation', ({ reason }) => + expect.unreachable(`Expected normalized search results: ${reason}`), + ), + Match.exhaustive, + ); + expect(normalizedItems[0]?.collision?.kind).toBe('CANONICAL_PARTY_COUNTERPARTY_COLLISION'); + expect(normalizedItems[0]?.party.matchedViaAlias).toBe(true); + expect( + yield* gateway.searchCounterparties({ + ...input, + effectiveAt: '2026-10-01T00:00:00.000Z', + }), + ).toEqual([]); + }), ); -it.effect( - 'source failure is sanitized and leaves previously searchable state intact for retry', - () => - Effect.gen(function* testScenario() { - const store = makeInMemoryCoreSearchProjectionStore(); - let fail = false; - const projector = makePartySearchProjector( +it.effect('shared public contact returns multiple Parties without uniqueness or matching authority', () => + Effect.gen(function* testScenario() { + yield* TestClock.setTime(Date.parse('2026-09-03T00:00:00.000Z')); + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const documents = yield* buildPartySearchDocuments({ + ...snapshot, + parties: [ + party, { - load: () => - fail - ? Effect.fail( - new PartySearchProjectionUnavailable({ - code: 'party_search_projection_unavailable', - reason: 'Projection temporarily unavailable', - }), - ) - : Effect.succeed(snapshot), + ...party, + ref: { + ...partyRef, + resourceId: 'party-2', + }, }, - makeCoreSearchIngestion(store), - store, - ); - yield* projector.project(context, { - partyId: 'party-1', - }); - fail = true; - const failure = yield* Effect.exit( - projector.project(context, { - partyId: 'party-1', - }), - ); - const priorHits = yield* createCoreSearchQueryRuntime(store).search({ - includeArchived: false, + ], + }); + yield* Effect.forEach((document: CoreSearchProjectionDocument) => + store.apply({ + document, + kind: 'upsert', + }), + )(documents); + const hits = yield* search.search({ + includeArchived: false, + moduleId: 'party.registry', + query: 'public@example.test', + resourceType: 'party.registry.party', + tenantId, + }); + expect(hits.map((hit) => hit.ref.resourceId)).toEqual(['party-1', 'party-2']); + }), +); +it.effect('rebuild reconciles omitted documents and preserves tombstones against stale lifecycle delivery', () => + Effect.gen(function* testScenario() { + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + let current = snapshot; + const projector = makePartySearchProjector( + { + load: () => Effect.succeed(current), + }, + makeCoreSearchIngestion(store), + store, + ); + yield* projector.project(context, { + partyId: 'party-1', + }); + current = { + ...snapshot, + parties: [], + projectionVersion: '8', + }; + yield* projector.project(context, { + rebuild: true, + }); + yield* projector.project(context, { + rebuild: true, + }); + current = snapshot; + yield* projector.project(context, { + partyId: 'party-1', + }); + expect( + yield* search.search({ + includeArchived: true, moduleId: 'party.registry', query: 'ACME', resourceType: 'party.registry.party', tenantId, - }); - expect(Predicate.isTagged(failure, 'Failure')).toBeTruthy(); - expect(priorHits.length).toBe(1); - }), + }), + ).toEqual([]); + }), ); -it.effect( - 'zero-length cancelled periods are never searchable and do not poison projection delivery', - () => - Effect.gen(function* testScenario() { - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const result = yield* Effect.exit( - buildPartySearchDocuments({ - ...snapshot, - counterparties: [ - { - legalEntityId: '20000000-0000-4000-8000-000000000002', - partyRef, - ref: { - moduleId: 'party.registry', - resourceId: 'cp-cancelled', - resourceType: 'party.registry.counterparty', - tenantId, - }, - rolePeriods: [ - { - role: 'CUSTOMER', - state: 'ACTIVE', - validFrom: '2026-09-03T00:00:00.000Z', - validTo: '2026-09-03T00:00:00.000Z', - }, - ], - storedPartyRef: partyRef, - }, - ], - parties: [ - { - ...party, - identifiers: [ - { - state: 'ACTIVE', - validFrom: '2026-09-03T00:00:00.000Z', - validTo: '2026-09-03T00:00:00.000Z', - value: 'cancelled', - }, - ], +it.effect('source failure is sanitized and leaves previously searchable state intact for retry', () => + Effect.gen(function* testScenario() { + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + let fail = false; + const projector = makePartySearchProjector( + { + load: () => + fail + ? Effect.fail( + new PartySearchProjectionUnavailable({ + code: 'party_search_projection_unavailable', + reason: 'Projection temporarily unavailable', + }), + ) + : Effect.succeed(snapshot), + }, + makeCoreSearchIngestion(store), + store, + ); + yield* projector.project(context, { + partyId: 'party-1', + }); + fail = true; + const failure = yield* Effect.exit( + projector.project(context, { + partyId: 'party-1', + }), + ); + const priorHits = yield* search.search({ + includeArchived: false, + moduleId: 'party.registry', + query: 'ACME', + resourceType: 'party.registry.party', + tenantId, + }); + expect(Predicate.isTagged(failure, 'Failure')).toBeTruthy(); + expect(priorHits.length).toBe(1); + }), +); +it.effect('zero-length cancelled periods are never searchable and do not poison projection delivery', () => + Effect.gen(function* testScenario() { + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const result = yield* Effect.exit( + buildPartySearchDocuments({ + ...snapshot, + counterparties: [ + { + legalEntityId: '20000000-0000-4000-8000-000000000002', + partyRef, + ref: { + moduleId: 'party.registry', + resourceId: 'cp-cancelled', + resourceType: 'party.registry.counterparty', + tenantId, }, - ], - }), - ); - expect(Exit.isSuccess(result)).toBeTruthy(); - if (!Exit.isSuccess(result)) { - throw new Error('Expected value to be present'); - } - expect( - result.value[0]?.temporalSearchableText?.filter((entry) => entry.value === 'cancelled'), - ).toEqual([]); - expect(result.value[1]?.temporalFacets).toEqual([]); - }), + rolePeriods: [ + { + role: 'CUSTOMER', + state: 'ACTIVE', + validFrom: '2026-09-03T00:00:00.000Z', + validTo: '2026-09-03T00:00:00.000Z', + }, + ], + storedPartyRef: partyRef, + }, + ], + parties: [ + { + ...party, + identifiers: [ + { + state: 'ACTIVE', + validFrom: '2026-09-03T00:00:00.000Z', + validTo: '2026-09-03T00:00:00.000Z', + value: 'cancelled', + }, + ], + }, + ], + }), + ); + expect(Exit.isSuccess(result)).toBeTruthy(); + if (!Exit.isSuccess(result)) { + throw new Error('Expected value to be present'); + } + expect(result.value[0]?.temporalSearchableText?.filter((entry) => entry.value === 'cancelled')).toEqual([]); + expect(result.value[1]?.temporalFacets).toEqual([]); + }), ); it.effect('projection generation is independent of an out-of-order business event sequence', () => Effect.gen(function* testScenario() { const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); const projector = makePartySearchProjector( { load: () => @@ -560,7 +557,7 @@ it.effect('projection generation is independent of an out-of-order business even partyId: 'party-1', }, ); - const hits = yield* createCoreSearchQueryRuntime(store).search({ + const hits = yield* search.search({ includeArchived: false, moduleId: 'party.registry', query: 'ACME', @@ -570,94 +567,92 @@ it.effect('projection generation is independent of an out-of-order business even expect(hits.length).toBe(1); }), ); -it.effect( - 'correction and identifier/contact changes replace obsolete evidence instead of accumulating history', - () => - Effect.gen(function* testScenario() { - const [party] = snapshot.parties; - expect(party).toBeTruthy(); - if (party === undefined) { - throw new Error('Expected value to be present'); - } - const store = makeInMemoryCoreSearchProjectionStore(); - let current = snapshot; - const projector = makePartySearchProjector( - { - load: () => Effect.succeed(current), - }, - makeCoreSearchIngestion(store), - store, - ); - yield* projector.project(context, { - partyId: 'party-1', - }); - current = { - ...snapshot, - parties: [ - { - ...party, - contacts: [], - displayName: 'Corrected Company', - identifiers: [ - { - state: 'SUPERSEDED', - validFrom: '2026-01-01T00:00:00.000Z', - value: '12345678', - }, - ], - }, - ], - projectionVersion: '8', - }; - yield* projector.project(context, { - partyId: 'party-1', - }); - const query = (value: string) => - createCoreSearchQueryRuntime(store).search({ - includeArchived: false, - moduleId: 'party.registry', - query: value, - resourceType: 'party.registry.party', - tenantId, - }); - expect(yield* query('ACME')).toEqual([]); - expect(yield* query('12345678')).toEqual([]); - expect(yield* query('public@example.test')).toEqual([]); - const corrected = yield* query('Corrected Company'); - expect(corrected.length).toBe(1); - }), -); -it.effect( - 'a complete empty rebuild also rejects delayed evidence for a never-before-indexed Party', - () => - Effect.gen(function* testScenario() { - const store = makeInMemoryCoreSearchProjectionStore(); - let current: PartySearchSourceSnapshot = { - ...snapshot, - parties: [], - projectionVersion: '8', - }; - const projector = makePartySearchProjector( +it.effect('correction and identifier/contact changes replace obsolete evidence instead of accumulating history', () => + Effect.gen(function* testScenario() { + const [party] = snapshot.parties; + expect(party).toBeTruthy(); + if (party === undefined) { + throw new Error('Expected value to be present'); + } + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + let current = snapshot; + const projector = makePartySearchProjector( + { + load: () => Effect.succeed(current), + }, + makeCoreSearchIngestion(store), + store, + ); + yield* projector.project(context, { + partyId: 'party-1', + }); + current = { + ...snapshot, + parties: [ { - load: () => Effect.succeed(current), + ...party, + contacts: [], + displayName: 'Corrected Company', + identifiers: [ + { + state: 'SUPERSEDED', + validFrom: '2026-01-01T00:00:00.000Z', + value: '12345678', + }, + ], }, - makeCoreSearchIngestion(store), - store, - ); - yield* projector.project(context, { - rebuild: true, - }); - current = snapshot; - yield* projector.project(context, { - partyId: 'party-1', - }); - const hits = yield* createCoreSearchQueryRuntime(store).search({ - includeArchived: true, + ], + projectionVersion: '8', + }; + yield* projector.project(context, { + partyId: 'party-1', + }); + const query = (value: string) => + search.search({ + includeArchived: false, moduleId: 'party.registry', - query: 'ACME', + query: value, resourceType: 'party.registry.party', tenantId, }); - expect(hits).toEqual([]); - }), + expect(yield* query('ACME')).toEqual([]); + expect(yield* query('12345678')).toEqual([]); + expect(yield* query('public@example.test')).toEqual([]); + const corrected = yield* query('Corrected Company'); + expect(corrected.length).toBe(1); + }), +); +it.effect('a complete empty rebuild also rejects delayed evidence for a never-before-indexed Party', () => + Effect.gen(function* testScenario() { + const store = makeInMemoryCoreSearchProjectionStore(); + const search = yield* createCoreSearchQueryRuntime.pipe(Effect.provideService(CoreSearchProjectionStore, store)); + let current: PartySearchSourceSnapshot = { + ...snapshot, + parties: [], + projectionVersion: '8', + }; + const projector = makePartySearchProjector( + { + load: () => Effect.succeed(current), + }, + makeCoreSearchIngestion(store), + store, + ); + yield* projector.project(context, { + rebuild: true, + }); + current = snapshot; + yield* projector.project(context, { + partyId: 'party-1', + }); + const hits = yield* search.search({ + includeArchived: true, + moduleId: 'party.registry', + query: 'ACME', + resourceType: 'party.registry.party', + tenantId, + }); + expect(hits).toEqual([]); + }), ); diff --git a/app/verticals/party-registry/tests/unit/search-provider.test.ts b/app/verticals/party-registry/tests/unit/search-provider.test.ts index 5f5a4f4ef..b9d767587 100644 --- a/app/verticals/party-registry/tests/unit/search-provider.test.ts +++ b/app/verticals/party-registry/tests/unit/search-provider.test.ts @@ -1,10 +1,8 @@ -import { assert, expect, it } from 'effect-rstest'; import { Effect } from 'effect'; +import { assert, expect, it } from 'effect-rstest'; + import type { PartySearchProjectionGatewayService } from '../../shared/domain/search-projection-gateway.ts'; -import { - counterpartiesRead, - loadCounterpartySearch, -} from '../../src/search/counterparties.provider.ts'; +import { counterpartiesRead, loadCounterpartySearch } from '../../src/search/counterparties.provider.ts'; import { loadPartySearch, partiesRead } from '../../src/search/parties.provider.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; @@ -34,69 +32,67 @@ it.effect('Party provider sends only trusted tenant scope to the Core projection }), }; - const result = yield* loadPartySearch( - gateway, - { tenantId }, - { includeArchived: true, query: 'ACME' }, - ); + const result = yield* loadPartySearch(gateway, { tenantId }, { includeArchived: true, query: 'ACME' }); expect(result).toEqual([]); expect(calls).toEqual([{ includeArchived: true, query: 'ACME', tenantId }]); }), ); -it.effect( - 'Counterparty provider derives Legal Entity from trusted scope and never from payload', - () => - Effect.gen(function* trustedCounterpartyScope() { - const calls: unknown[] = []; - const gateway: PartySearchProjectionGatewayService = { - searchCounterparties: (input) => - Effect.sync(() => { - calls.push(input); - return []; - }), - searchParties: () => Effect.succeed([]), - }; +it.effect('Counterparty provider derives Legal Entity from trusted scope and never from payload', () => + Effect.gen(function* trustedCounterpartyScope() { + const calls: unknown[] = []; + const gateway: PartySearchProjectionGatewayService = { + searchCounterparties: (input) => + Effect.sync(() => { + calls.push(input); + return []; + }), + searchParties: () => Effect.succeed([]), + }; - const result = yield* loadCounterpartySearch( - gateway, - { legalEntityId, tenantId }, - { includeArchived: false, query: 'ACME', role: 'SUPPLIER' }, - '2026-09-03T12:00:00.000Z', - ); - expect(result).toEqual([]); - expect(calls).toEqual([ - { - effectiveAt: '2026-09-03T12:00:00.000Z', - includeArchived: false, - legalEntityId, - query: 'ACME', - role: 'SUPPLIER', - tenantId, - }, - ]); - }), + const result = yield* loadCounterpartySearch( + gateway, + { legalEntityId, tenantId }, + { includeArchived: false, query: 'ACME', role: 'SUPPLIER' }, + '2026-09-03T12:00:00.000Z', + ); + expect(result).toEqual([]); + expect(calls).toEqual([ + { + effectiveAt: '2026-09-03T12:00:00.000Z', + includeArchived: false, + legalEntityId, + query: 'ACME', + role: 'SUPPLIER', + tenantId, + }, + ]); + }), ); -it.effect( - 'Counterparty provider preserves typed normalization failures and omits an absent role', - () => - Effect.gen(function* invalidCounterpartyInstant() { - const calls: unknown[] = []; - const gateway: PartySearchProjectionGatewayService = { - searchCounterparties: (input) => { - calls.push(input); - return Effect.succeed([]); - }, - searchParties: () => Effect.succeed([]), - }; - const error = yield* Effect.flip( - loadCounterpartySearch(gateway, { legalEntityId, tenantId }, { query: 'ACME' }, 'invalid'), - ); - assert.equal(error.code, 'party_search_projection_unavailable'); - assert.equal(error.reason, 'Counterparty Search effective time is invalid'); - assert.deepEqual(calls, [ - { effectiveAt: 'invalid', includeArchived: false, legalEntityId, query: 'ACME', tenantId }, - ]); - }), +it.effect('Counterparty provider preserves typed normalization failures and omits an absent role', () => + Effect.gen(function* invalidCounterpartyInstant() { + const calls: unknown[] = []; + const gateway: PartySearchProjectionGatewayService = { + searchCounterparties: (input) => { + calls.push(input); + return Effect.succeed([]); + }, + searchParties: () => Effect.succeed([]), + }; + const error = yield* Effect.flip( + loadCounterpartySearch(gateway, { legalEntityId, tenantId }, { query: 'ACME' }, 'invalid'), + ); + assert.equal(error.code, 'party_search_projection_unavailable'); + assert.equal(error.reason, 'Counterparty Search effective time is invalid'); + assert.deepEqual(calls, [ + { + effectiveAt: 'invalid', + includeArchived: false, + legalEntityId, + query: 'ACME', + tenantId, + }, + ]); + }), ); diff --git a/app/verticals/party-registry/tests/unit/search-rebuild-request.test.ts b/app/verticals/party-registry/tests/unit/search-rebuild-request.test.ts index 0f8974b10..00bbf0dd8 100644 --- a/app/verticals/party-registry/tests/unit/search-rebuild-request.test.ts +++ b/app/verticals/party-registry/tests/unit/search-rebuild-request.test.ts @@ -1,14 +1,12 @@ -import { expect, it } from 'effect-rstest'; -import { Effect, Schema, Predicate } from 'effect'; import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; import { makeActionTestHarness } from '@app/core-runtime/testing/actions'; +import { Effect, Schema, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { requestSearchRebuildAction } from '../../src/actions/request-search-rebuild.action.ts'; -import { - handleRebuildSearch, - rebuildSearchWorker, -} from '../../src/workers/rebuild-search.worker.ts'; import { PartySearchProjector } from '../../src/services/party-search-projection.service.ts'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import { handleRebuildSearch, rebuildSearchWorker } from '../../src/workers/rebuild-search.worker.ts'; const requestId = '40000000-0000-4000-8000-000000000001'; const tenantId = '20000000-0000-4000-8000-000000000001'; @@ -23,7 +21,10 @@ const request = { payload: {}, principal, registration: requestSearchRebuildAction, - transport: { correlationId: 'search-rebuild-test', idempotencyKey: 'rebuild-1' }, + transport: { + correlationId: 'search-rebuild-test', + idempotencyKey: 'rebuild-1', + }, }; it.effect('tenant rebuild requests require Party administration and canonical idempotency', () => @@ -34,66 +35,60 @@ it.effect('tenant rebuild requests require Party administration and canonical id expect(descriptor.idempotency).toBe('required'); expect(descriptor.legalEntityScope).toBe('optional'); expect(descriptor.entrypoint.scope).toBe('tenant'); - expect(yield* Schema.decodeUnknownEffect(descriptor.payloadSchema)({})).toEqual({}); - expect(Object.keys(descriptor.domainEvents)).toEqual([ - 'party.registry.search-rebuild-requested.v1', - ]); + expect(yield* Schema.decodeEffect(descriptor.payloadSchema)({})).toEqual({}); + expect(Object.keys(descriptor.domainEvents)).toEqual(['party.registry.search-rebuild-requested.v1']); }), ); -it.effect( - 'authorized rebuild commits one linked request without reading identity or running the projector', - () => - Effect.gen(function* authorizedRebuildRequest() { - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'allowed', - }); - const result = yield* harness.runtime.runAction(request); - expect(result.status).toBe('QUEUED'); - expect(Schema.is(Schema.String.check(Schema.isUUID()))(result.requestId)).toBe(true); - const { committed, permissionDenials } = harness.snapshot(); - expect(committed.length).toBe(1); - expect(permissionDenials).toEqual([]); - expect(committed[0]?.evidence.dataAccessEvents).toEqual([]); - expect(committed[0]?.evidence.domainEvents).toEqual([ - { - eventType: 'party.registry.search-rebuild-requested.v1', +it.effect('authorized rebuild commits one linked request without reading identity or running the projector', () => + Effect.gen(function* authorizedRebuildRequest() { + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + }); + const result = yield* harness.runtime.runAction(request); + expect(result.status).toBe('QUEUED'); + expect(Schema.is(Schema.String.check(Schema.isUUID()))(result.requestId)).toBe(true); + const { committed, permissionDenials } = harness.snapshot(); + expect(committed.length).toBe(1); + expect(permissionDenials).toEqual([]); + expect(committed[0]?.evidence.dataAccessEvents).toEqual([]); + expect(committed[0]?.evidence.domainEvents).toEqual([ + { + eventType: 'party.registry.search-rebuild-requested.v1', + payloadJson: { requestId: result.requestId }, + producerModuleKey: 'party.registry', + subjectModuleKey: 'core.identity', + subjectResourceId: tenantId, + subjectResourceType: 'tenant', + }, + ]); + expect(committed[0]?.evidence.outboxMessages).toEqual([ + { + domainEventIndex: 0, + message: { payloadJson: { requestId: result.requestId }, producerModuleKey: 'party.registry', - subjectModuleKey: 'core.identity', - subjectResourceId: tenantId, - subjectResourceType: 'tenant', + topic: 'party.registry.search-rebuild-requested.v1', }, - ]); - expect(committed[0]?.evidence.outboxMessages).toEqual([ - { - domainEventIndex: 0, - message: { - payloadJson: { requestId: result.requestId }, - producerModuleKey: 'party.registry', - topic: 'party.registry.search-rebuild-requested.v1', - }, - }, - ]); - }), + }, + ]); + }), ); -it.effect( - 'denied Party administration cannot queue a rebuild even with Action execution permission', - () => - Effect.gen(function* deniedRebuildRequest() { - const harness = yield* makeActionTestHarness({ - actionPermission: 'allowed', - tenantPermission: 'denied', - }); - const error = yield* harness.runtime.runAction(request).pipe(Effect.flip); - expect(Predicate.isTagged(error, 'ActionPermissionDenied')).toBe(true); - const snapshot = harness.snapshot(); - expect(snapshot.committed).toEqual([]); - expect(snapshot.permissionDenials.length).toBe(1); - expect(snapshot.stages.includes('handler_executed')).toBe(false); - }), +it.effect('denied Party administration cannot queue a rebuild even with Action execution permission', () => + Effect.gen(function* deniedRebuildRequest() { + const harness = yield* makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'denied', + }); + const error = yield* harness.runtime.runAction(request).pipe(Effect.flip); + expect(Predicate.isTagged(error, 'ActionPermissionDenied')).toBe(true); + const snapshot = harness.snapshot(); + expect(snapshot.committed).toEqual([]); + expect(snapshot.permissionDenials.length).toBe(1); + expect(snapshot.stages.includes('handler_executed')).toBe(false); + }), ); it.effect('replaying the same authorized rebuild request queues only once', () => @@ -126,29 +121,24 @@ const workerContext: OutboxWorkerHandlerContext = { workerKey: 'party.registry.rebuild-search', }; -it.effect( - 'rebuild worker uses its trusted committed context, and failures remain retryable', - () => { - const unavailable = new PartySearchProjectionUnavailable({ - code: 'party_search_projection_unavailable', - reason: 'Party search projection is temporarily unavailable', - }); - return Effect.gen(function* rebuildWorkerFailure() { - const failure = yield* handleRebuildSearch({ requestId }, workerContext).pipe( - Effect.provideService(PartySearchProjector, { - project: (context, target) => { - expect(context).toBe(workerContext); - expect(target).toEqual({ rebuild: true }); - return Effect.fail(unavailable); - }, - }), - Effect.flip, - ); - expect(failure).toBe(unavailable); - expect(rebuildSearchWorker.descriptor.workerKey).toBe('party.registry.rebuild-search'); - expect(rebuildSearchWorker.descriptor.topic).toBe( - 'party.registry.search-rebuild-requested.v1', - ); - }); - }, -); +it.effect('rebuild worker uses its trusted committed context, and failures remain retryable', () => { + const unavailable = new PartySearchProjectionUnavailable({ + code: 'party_search_projection_unavailable', + reason: 'Party search projection is temporarily unavailable', + }); + return Effect.gen(function* rebuildWorkerFailure() { + const failure = yield* handleRebuildSearch({ requestId }, workerContext).pipe( + Effect.provideService(PartySearchProjector, { + project: (context, target) => { + expect(context).toBe(workerContext); + expect(target).toEqual({ rebuild: true }); + return Effect.fail(unavailable); + }, + }), + Effect.flip, + ); + expect(failure).toBe(unavailable); + expect(rebuildSearchWorker.descriptor.workerKey).toBe('party.registry.rebuild-search'); + expect(rebuildSearchWorker.descriptor.topic).toBe('party.registry.search-rebuild-requested.v1'); + }); +}); diff --git a/app/verticals/party-registry/tests/unit/search-semantics.test.ts b/app/verticals/party-registry/tests/unit/search-semantics.test.ts index b2a8a190d..aba850aae 100644 --- a/app/verticals/party-registry/tests/unit/search-semantics.test.ts +++ b/app/verticals/party-registry/tests/unit/search-semantics.test.ts @@ -1,17 +1,12 @@ -import { expect, it } from 'effect-rstest'; import { Match, Predicate, Struct } from 'effect'; -import { - normalizeCounterpartySearchHits, - normalizePartySearchHits, -} from '../../shared/domain/search-semantics.ts'; -import type { - SearchNormalizationResult, - SearchResults, -} from '../../shared/domain/search-semantics.ts'; +import { expect, it } from 'effect-rstest'; + import type { CounterpartySearchProjectionHit, PartySearchProjectionHit, } from '../../shared/domain/search-projection-gateway.ts'; +import { normalizeCounterpartySearchHits, normalizePartySearchHits } from '../../shared/domain/search-semantics.ts'; +import type { SearchNormalizationResult, SearchResults } from '../../shared/domain/search-semantics.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; const legalEntityId = '20000000-0000-4000-8000-000000000002'; @@ -28,15 +23,11 @@ const counterpartyRef = (resourceId: string) => ({ tenantId, }); -const expectSearchResults = ( - result: SearchNormalizationResult, -): SearchResults => +const expectSearchResults = (result: SearchNormalizationResult): SearchResults => Match.value(result).pipe( Match.tag('SearchResults', (results) => results), Match.tag('SearchProjectionViolation', ({ reason }) => { - throw new Error( - `Expected normalized search results, but the projection was invalid: ${reason}`, - ); + throw new Error(`Expected normalized search results, but the projection was invalid: ${reason}`); }), Match.exhaustive, ); @@ -44,20 +35,28 @@ const expectSearchResults = ( it('Party Search hides archived hits by default and explicitly labels included archived hits', () => { const hits: readonly PartySearchProjectionHit[] = [ { archived: false, canonicalPartyRef: partyRef('active'), title: 'Active' }, - { archived: true, canonicalPartyRef: partyRef('archived'), title: 'Archived' }, + { + archived: true, + canonicalPartyRef: partyRef('archived'), + title: 'Archived', + }, ]; const activeResults = normalizePartySearchHits({ includeArchived: false, tenantId }, hits); expect(Predicate.isTagged(activeResults, 'SearchResults')).toBe(true); expect(Struct.omit(activeResults, ['_tag'])).toEqual({ - items: [{ archived: false, matchedViaAlias: false, ref: partyRef('active'), title: 'Active' }], + items: [ + { + archived: false, + matchedViaAlias: false, + ref: partyRef('active'), + title: 'Active', + }, + ], }); const included = normalizePartySearchHits({ includeArchived: true, tenantId }, hits); expect(Predicate.isTagged(included, 'SearchResults')).toBe(true); - expect(expectSearchResults(included).items.map(({ archived }) => archived)).toEqual([ - false, - true, - ]); + expect(expectSearchResults(included).items.map(({ archived }) => archived)).toEqual([false, true]); }); it('Party aliases collapse to one survivor while shared contact queries may retain multiple Parties', () => { @@ -70,7 +69,11 @@ it('Party aliases collapse to one survivor while shared contact queries may reta matchedPartyRef: partyRef('absorbed'), title: 'ACME', }, - { archived: false, canonicalPartyRef: partyRef('shared-2'), title: 'Other person' }, + { + archived: false, + canonicalPartyRef: partyRef('shared-2'), + title: 'Other person', + }, ]); expect(Predicate.isTagged(result, 'SearchResults')).toBe(true); @@ -120,11 +123,13 @@ it('Counterparty Search evaluates only current role periods at the exclusive tim const effectiveAt = '2026-09-03T12:00:00.000Z'; const hits: readonly CounterpartySearchProjectionHit[] = [ baseCounterpartyHit('ended', 'p1', [ - { role: 'CUSTOMER', validFrom: '2026-01-01T00:00:00.000Z', validTo: effectiveAt }, - ]), - baseCounterpartyHit('future', 'p2', [ - { role: 'CUSTOMER', validFrom: '2026-10-01T00:00:00.000Z' }, + { + role: 'CUSTOMER', + validFrom: '2026-01-01T00:00:00.000Z', + validTo: effectiveAt, + }, ]), + baseCounterpartyHit('future', 'p2', [{ role: 'CUSTOMER', validFrom: '2026-10-01T00:00:00.000Z' }]), baseCounterpartyHit('future-ended', 'p3', [ { role: 'CUSTOMER', @@ -138,7 +143,13 @@ it('Counterparty Search evaluates only current role periods at the exclusive tim ]), ]; const result = normalizeCounterpartySearchHits( - { effectiveAt, includeArchived: false, legalEntityId, role: 'CUSTOMER', tenantId }, + { + effectiveAt, + includeArchived: false, + legalEntityId, + role: 'CUSTOMER', + tenantId, + }, hits, ); @@ -182,9 +193,7 @@ it('Counterparty identity dedupes independently and survivor collisions are surf expect(Predicate.isTagged(result, 'SearchResults')).toBe(true); const { items } = expectSearchResults(result); expect(items.map(({ ref }) => ref.resourceId)).toEqual(['cp-1', 'cp-2']); - expect( - items.map(({ collision }) => collision?.counterpartyRefs.map(({ resourceId }) => resourceId)), - ).toEqual([ + expect(items.map(({ collision }) => collision?.counterpartyRefs.map(({ resourceId }) => resourceId))).toEqual([ ['cp-1', 'cp-2'], ['cp-1', 'cp-2'], ]); diff --git a/app/verticals/party-registry/tests/unit/search-source.test.ts b/app/verticals/party-registry/tests/unit/search-source.test.ts index 08a46710a..44f0147c9 100644 --- a/app/verticals/party-registry/tests/unit/search-source.test.ts +++ b/app/verticals/party-registry/tests/unit/search-source.test.ts @@ -1,4 +1,3 @@ -import { expect, it } from 'effect-rstest'; import type { CoreSearchSnapshotReadExecutor, CoreSearchWorkerSnapshotService, @@ -8,6 +7,8 @@ import type { AnyColumn, Query, SQL, Table } from 'drizzle-orm'; import { getTableName } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; import { DateTime, Effect, Result, Predicate } from 'effect'; +import { expect, it } from 'effect-rstest'; + import { makePartySearchProjectionSource } from '../../src/services/party-search-projection-source.service.ts'; const tenantId = '10000000-0000-4000-8000-000000000001'; @@ -75,7 +76,12 @@ const harness = ( tenantId, }), }; - return { columns, filters, scopes, source: makePartySearchProjectionSource(snapshot) }; + return { + columns, + filters, + scopes, + source: makePartySearchProjectionSource(snapshot), + }; }; it.effect('canonical snapshot preserves alias identity and legal-entity Counterparty context', () => @@ -95,7 +101,12 @@ it.effect('canonical snapshot preserves alias identity and legal-entity Counterp ], parties: [ { archivedAt: null, displayName: 'Canonical', partyId, tenantId }, - { archivedAt: from, displayName: 'Former name', partyId: aliasId, tenantId }, + { + archivedAt: from, + displayName: 'Former name', + partyId: aliasId, + tenantId, + }, ], party_aliases: [{ aliasPartyId: aliasId, canonicalPartyId: partyId, tenantId }], party_contact_points: [], @@ -117,20 +128,40 @@ it.effect('canonical snapshot preserves alias identity and legal-entity Counterp { legalEntityId, partyRef: ref(partyId), - ref: { ...ref(counterpartyId), resourceType: 'party.registry.counterparty' }, - rolePeriods: [{ role: 'CUSTOMER', state: 'ACTIVE', validFrom: from.toISOString() }], + ref: { + ...ref(counterpartyId), + resourceType: 'party.registry.counterparty', + }, + rolePeriods: [ + { + role: 'CUSTOMER', + state: 'ACTIVE', + validFrom: from.toISOString(), + }, + ], storedPartyRef: ref(aliasId), }, ], parties: [ { aliases: [ - { contacts: [], displayName: 'Former name', identifiers: [], ref: ref(aliasId) }, + { + contacts: [], + displayName: 'Former name', + identifiers: [], + ref: ref(aliasId), + }, ], archived: false, contacts: [], displayName: 'Canonical', - identifiers: [{ state: 'ACTIVE', validFrom: from.toISOString(), value: '27074358' }], + identifiers: [ + { + state: 'ACTIVE', + validFrom: from.toISOString(), + value: '27074358', + }, + ], ref: ref(partyId), }, ], @@ -167,7 +198,11 @@ it.effect( value: '+420123456789', }, { ...contact, privacy: 'PERSONAL', value: 'personal@example.test' }, - { ...contact, privacy: 'BUSINESS_SENSITIVE', value: 'sensitive@example.test' }, + { + ...contact, + privacy: 'BUSINESS_SENSITIVE', + value: 'sensitive@example.test', + }, { ...contact, state: 'ENDED', value: 'ended@example.test' }, { ...contact, isCurrent: false, value: 'superseded@example.test' }, { ...contact, type: 'ADDRESS', value: 'Private road' }, @@ -202,17 +237,7 @@ it.effect( ]); expect(filters['party_contact_points']?.sql ?? '').toMatch(/privacy_classification/u); expect(columns['party_contact_points']?.toSorted()).toEqual( - [ - 'partyId', - 'tenantId', - 'value', - 'type', - 'privacy', - 'state', - 'isCurrent', - 'validFrom', - 'validTo', - ].toSorted(), + ['partyId', 'tenantId', 'value', 'type', 'privacy', 'state', 'isCurrent', 'validFrom', 'validTo'].toSorted(), ); }), ); @@ -229,9 +254,7 @@ it.effect('missing Party and Counterparty targets produce explicit versioned tom removedRefs: [ref(partyId)], tenantId, }); - expect(counterparty.removedRefs).toEqual([ - { ...ref(counterpartyId), resourceType: 'party.registry.counterparty' }, - ]); + expect(counterparty.removedRefs).toEqual([{ ...ref(counterpartyId), resourceType: 'party.registry.counterparty' }]); }), ); @@ -252,71 +275,79 @@ it.effect( tenantId, }, ], - parties: [{ archivedAt: from, displayName: 'Shared Party', partyId, tenantId }], + parties: [ + { + archivedAt: from, + displayName: 'Shared Party', + partyId, + tenantId, + }, + ], }, [legalEntityId, secondLegalEntityId], ); const result = yield* source.load(context, { rebuild: true }); expect(scopes).toEqual([undefined, legalEntityId, secondLegalEntityId, undefined]); - expect(result.counterparties.map((row) => row.ref.resourceId)).toEqual([ - counterpartyId, - secondCounterpartyId, - ]); + expect(result.counterparties.map((row) => row.ref.resourceId)).toEqual([counterpartyId, secondCounterpartyId]); expect(result.parties[0]?.archived).toBe(true); expect(result.projectionVersion).toBe('9'); }), ); -it.effect( - 'Counterparty-only refresh emits only its canonical family and selected Counterparty', - () => - Effect.gen(function* targetedCounterpartySnapshot() { - const otherId = '20000000-0000-4000-8000-000000000009'; - const { source } = harness({ - counterparties: [ - { counterpartyId, legalEntityId, partyId, tenantId }, - { - counterpartyId: '40000000-0000-4000-8000-000000000009', - legalEntityId, - partyId: otherId, - tenantId, - }, - ], - parties: [ - { archivedAt: null, displayName: 'Selected', partyId, tenantId }, - { archivedAt: null, displayName: 'Unrelated', partyId: otherId, tenantId }, - ], - }); - const result = yield* source.load(context, { counterpartyId }); - expect(result.parties.map((party) => party.ref.resourceId)).toEqual([partyId]); - expect(result.counterparties.map((row) => row.ref.resourceId)).toEqual([counterpartyId]); - }), -); - -it.effect( - 'alias cycles and cross-tenant source rows fail closed with sanitized typed failures', - () => - Effect.gen(function* rejectedSourceSnapshot() { - for (const rows of [ +it.effect('Counterparty-only refresh emits only its canonical family and selected Counterparty', () => + Effect.gen(function* targetedCounterpartySnapshot() { + const otherId = '20000000-0000-4000-8000-000000000009'; + const { source } = harness({ + counterparties: [ + { counterpartyId, legalEntityId, partyId, tenantId }, { - parties: [{ archivedAt: null, displayName: 'A', partyId, tenantId }], - party_aliases: [{ aliasPartyId: partyId, canonicalPartyId: partyId, tenantId }], + counterpartyId: '40000000-0000-4000-8000-000000000009', + legalEntityId, + partyId: otherId, + tenantId, }, + ], + parties: [ + { archivedAt: null, displayName: 'Selected', partyId, tenantId }, { - parties: [ - { archivedAt: null, displayName: 'Secret name', partyId, tenantId: 'foreign-tenant' }, - ], + archivedAt: null, + displayName: 'Unrelated', + partyId: otherId, + tenantId, }, - ]) { - const { source } = harness(rows); - const outcome = yield* source.load(context, { rebuild: true }).pipe(Effect.result); - expect(Result.isFailure(outcome)).toBe(true); - if (Result.isFailure(outcome)) { - expect(Predicate.isTagged(outcome.failure, 'PartySearchProjectionUnavailable')).toBe( - true, - ); - expect(outcome.failure.reason).not.toMatch(/Secret name|foreign-tenant/u); - } + ], + }); + const result = yield* source.load(context, { counterpartyId }); + expect(result.parties.map((party) => party.ref.resourceId)).toEqual([partyId]); + expect(result.counterparties.map((row) => row.ref.resourceId)).toEqual([counterpartyId]); + }), +); + +it.effect('alias cycles and cross-tenant source rows fail closed with sanitized typed failures', () => + Effect.gen(function* rejectedSourceSnapshot() { + for (const rows of [ + { + parties: [{ archivedAt: null, displayName: 'A', partyId, tenantId }], + party_aliases: [{ aliasPartyId: partyId, canonicalPartyId: partyId, tenantId }], + }, + { + parties: [ + { + archivedAt: null, + displayName: 'Secret name', + partyId, + tenantId: 'foreign-tenant', + }, + ], + }, + ]) { + const { source } = harness(rows); + const outcome = yield* source.load(context, { rebuild: true }).pipe(Effect.result); + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isFailure(outcome)) { + expect(Predicate.isTagged(outcome.failure, 'PartySearchProjectionUnavailable')).toBe(true); + expect(outcome.failure.reason).not.toMatch(/Secret name|foreign-tenant/u); } - }), + } + }), ); diff --git a/app/verticals/party-registry/tests/unit/search-worker-registration.test.ts b/app/verticals/party-registry/tests/unit/search-worker-registration.test.ts index 4ccfec1ed..1b2a2b8d0 100644 --- a/app/verticals/party-registry/tests/unit/search-worker-registration.test.ts +++ b/app/verticals/party-registry/tests/unit/search-worker-registration.test.ts @@ -1,12 +1,11 @@ -import { expect, it } from 'effect-rstest'; import { CORE_SEARCH_INGESTION_REGISTRATIONS } from '@app/core-runtime'; +import { expect, it } from 'effect-rstest'; + import { outboxWorkers } from '../../src/workers/index.ts'; it('every accepted Party search lifecycle and explicit rebuild topic has its exact generated self-consumer', () => { for (const registration of CORE_SEARCH_INGESTION_REGISTRATIONS) { - const matches = outboxWorkers.filter( - ({ descriptor }) => descriptor.workerKey === registration.workerKey, - ); + const matches = outboxWorkers.filter(({ descriptor }) => descriptor.workerKey === registration.workerKey); expect(matches.length, registration.workerKey).toBe(1); const [worker] = matches; expect(worker).toBeDefined(); diff --git a/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts b/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts index 0e8691a1c..29afd4997 100644 --- a/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts @@ -1,5 +1,6 @@ -import { assert, it } from 'effect-rstest'; import { Schema } from 'effect'; +import { assert, it } from 'effect-rstest'; + import * as duplicateCase from '../../shared/resources/duplicate-candidate-case.ts'; import * as correction from '../../shared/resources/party-correction.ts'; import * as matchDecision from '../../shared/resources/party-match-decision.ts'; diff --git a/app/verticals/party-registry/tsconfig.json b/app/verticals/party-registry/tsconfig.json index b926449db..eef17fc5e 100644 --- a/app/verticals/party-registry/tsconfig.json +++ b/app/verticals/party-registry/tsconfig.json @@ -7,35 +7,34 @@ "emitDeclarationOnly": true, "incremental": true, "noEmit": false, - "skipLibCheck": true, "outDir": "../../node_modules/.cache/tsgo/declarations/verticals__party-registry", "tsBuildInfoFile": "../../node_modules/.cache/tsgo/verticals__party-registry.tsbuildinfo" }, "include": [ "src", - "scripts", - "tests", - "drizzle.config.ts", "locales/**/*.json", "package.json", "shared", "server", "api", + "scripts", + "tests", + "drizzle.config.ts", "vertical.manifest.ts", "vertical.registration.ts" ], "references": [ { - "path": "../../packages/core-runtime" + "path": "../../packages/shared-contracts" }, { - "path": "../../packages/gateway-principal-verifier" + "path": "../../packages/shared-design-tokens" }, { - "path": "../../packages/shared-contracts" + "path": "../../packages/core-runtime" }, { - "path": "../../packages/shared-design-tokens" + "path": "../../packages/gateway-principal-verifier" } ] } diff --git a/app/verticals/party-registry/tsconfig.mf-types.json b/app/verticals/party-registry/tsconfig.mf-types.json index d372b32c8..a4849d0e9 100644 --- a/app/verticals/party-registry/tsconfig.mf-types.json +++ b/app/verticals/party-registry/tsconfig.mf-types.json @@ -1,9 +1,13 @@ { "extends": "../../tsconfig.base.json", "include": [ + "src/components/page-contacts.tsx", + "src/modern-app-env.d.ts", "src/federation-entry.tsx", "src/federation/page-contacts.tsx", - "shared/api.ts", - "src/modern-app-env.d.ts" - ] + "shared/api.ts" + ], + "compilerOptions": { + "skipLibCheck": true + } } diff --git a/app/verticals/party-registry/vertical.manifest.ts b/app/verticals/party-registry/vertical.manifest.ts index 163522872..22b8b4272 100644 --- a/app/verticals/party-registry/vertical.manifest.ts +++ b/app/verticals/party-registry/vertical.manifest.ts @@ -8,67 +8,68 @@ import { ShellSearchContributionSchema, } from '@app/core-runtime'; import { Result, Schema } from 'effect'; + +import { AresLookupApi } from './shared/apis/ares-lookup.ts'; +import { CounterpartyReadApi } from './shared/apis/counterparty-read.ts'; +import { CounterpartyRoleHistoryApi } from './shared/apis/counterparty-role-history.ts'; +import { DuplicateCandidateDetailApi } from './shared/apis/duplicate-candidate-detail.ts'; +import { OrganizationEngagementProfileApi } from './shared/apis/organization-engagement-profile.ts'; +import { PartyContactPointDetailApi } from './shared/apis/party-contact-point-detail.ts'; +import { PartyContactPointsApi } from './shared/apis/party-contact-points.ts'; +import { PartyCorrectionApi } from './shared/apis/party-correction.ts'; +import { PartyDetailApi } from './shared/apis/party-detail.ts'; +import { PartyMatchDecisionApi } from './shared/apis/party-match-decision.ts'; +import { PartyMatchApi } from './shared/apis/party-match.ts'; +import { PartyMergeReadinessApi } from './shared/apis/party-merge-readiness.ts'; +import { PartyOfficialIdentifierDetailApi } from './shared/apis/party-official-identifier-detail.ts'; +import { PartyOfficialIdentifierHistoryApi } from './shared/apis/party-official-identifier-history.ts'; +import { PartyRelationshipDetailApi } from './shared/apis/party-relationship-detail.ts'; +import { PersonEngagementProfileApi } from './shared/apis/person-engagement-profile.ts'; +import { counterpartyRolePeriodResourceDescriptor } from './shared/resources/counterparty-role-period.ts'; +import { counterpartyResourceDescriptor } from './shared/resources/counterparty.ts'; +import { duplicateCandidateCaseResourceDescriptor } from './shared/resources/duplicate-candidate-case.ts'; +import { organizationEngagementProfileResourceDescriptor } from './shared/resources/organization-engagement-profile.ts'; +import { partyAliasResourceDescriptor } from './shared/resources/party-alias.ts'; +import { partyContactPointResourceDescriptor } from './shared/resources/party-contact-point.ts'; +import { partyCorrectionResourceDescriptor } from './shared/resources/party-correction.ts'; +import { partyMatchDecisionResourceDescriptor } from './shared/resources/party-match-decision.ts'; +import { partyMergeResourceDescriptor } from './shared/resources/party-merge.ts'; +import { partyOfficialIdentifierResourceDescriptor } from './shared/resources/party-official-identifier.ts'; +import { partyRelationshipResourceDescriptor } from './shared/resources/party-relationship.ts'; +import { partyResourceDescriptor } from './shared/resources/party.ts'; +import { personEngagementProfileResourceDescriptor } from './shared/resources/person-engagement-profile.ts'; // import { addContactPointAction } from './src/actions/add-contact-point.action.ts'; import { addPartyOfficialIdentifierAction } from './src/actions/add-party-official-identifier.action.ts'; -import { archivePartyAction } from './src/actions/archive-party.action.ts'; import { archiveOrganizationEngagementAction } from './src/actions/archive-organization-engagement.action.ts'; +import { archivePartyAction } from './src/actions/archive-party.action.ts'; import { archivePersonEngagementAction } from './src/actions/archive-person-engagement.action.ts'; -import { AresLookupApi } from './shared/apis/ares-lookup.ts'; import { attachOrganizationEngagementAction } from './src/actions/attach-organization-engagement.action.ts'; import { attachPersonEngagementAction } from './src/actions/attach-person-engagement.action.ts'; import { confirmDuplicatePartiesAction } from './src/actions/confirm-duplicate-parties.action.ts'; -import ContactsPage from './src/routes/[lang]/contacts/page.tsx'; import { correctPartyFactAction } from './src/actions/correct-party-fact.action.ts'; import { counterpartyCreateAction } from './src/actions/counterparty-create.action.ts'; -import { CounterpartyReadApi } from './shared/apis/counterparty-read.ts'; -import { counterpartyResourceDescriptor } from './shared/resources/counterparty.ts'; import { counterpartyRoleAddAction } from './src/actions/counterparty-role-add.action.ts'; import { counterpartyRoleEndAction } from './src/actions/counterparty-role-end.action.ts'; -import { CounterpartyRoleHistoryApi } from './shared/apis/counterparty-role-history.ts'; -import { counterpartyRolePeriodResourceDescriptor } from './shared/resources/counterparty-role-period.ts'; -import { createPartyAction } from './src/actions/create-party.action.ts'; import { createPartyRelationshipAction } from './src/actions/create-party-relationship.action.ts'; +import { createPartyAction } from './src/actions/create-party.action.ts'; import { dismissDuplicateCandidateAction } from './src/actions/dismiss-duplicate-candidate.action.ts'; -import { duplicateCandidateCaseResourceDescriptor } from './shared/resources/duplicate-candidate-case.ts'; -import { DuplicateCandidateDetailApi } from './shared/apis/duplicate-candidate-detail.ts'; import { endContactPointAction } from './src/actions/end-contact-point.action.ts'; import { endPartyOfficialIdentifierAction } from './src/actions/end-party-official-identifier.action.ts'; import { endPartyRelationshipAction } from './src/actions/end-party-relationship.action.ts'; import { markDuplicateCandidateNeedsEvidenceAction } from './src/actions/mark-duplicate-candidate-needs-evidence.action.ts'; import { matchPartyAction } from './src/actions/match-party.action.ts'; -import { OrganizationEngagementProfileApi } from './shared/apis/organization-engagement-profile.ts'; -import { organizationEngagementProfileResourceDescriptor } from './shared/resources/organization-engagement-profile.ts'; -import { partyAliasResourceDescriptor } from './shared/resources/party-alias.ts'; -import { PartyContactPointDetailApi } from './shared/apis/party-contact-point-detail.ts'; -import { partyContactPointResourceDescriptor } from './shared/resources/party-contact-point.ts'; -import { PartyContactPointsApi } from './shared/apis/party-contact-points.ts'; -import { PartyCorrectionApi } from './shared/apis/party-correction.ts'; -import { partyCorrectionResourceDescriptor } from './shared/resources/party-correction.ts'; -import { PartyDetailApi } from './shared/apis/party-detail.ts'; -import { PartyMatchApi } from './shared/apis/party-match.ts'; -import { PartyMatchDecisionApi } from './shared/apis/party-match-decision.ts'; -import { partyMatchDecisionResourceDescriptor } from './shared/resources/party-match-decision.ts'; -import { PartyMergeReadinessApi } from './shared/apis/party-merge-readiness.ts'; -import { partyMergeResourceDescriptor } from './shared/resources/party-merge.ts'; -import { PartyOfficialIdentifierDetailApi } from './shared/apis/party-official-identifier-detail.ts'; -import { PartyOfficialIdentifierHistoryApi } from './shared/apis/party-official-identifier-history.ts'; -import { partyOfficialIdentifierResourceDescriptor } from './shared/resources/party-official-identifier.ts'; -import { PartyRelationshipDetailApi } from './shared/apis/party-relationship-detail.ts'; -import { partyRelationshipResourceDescriptor } from './shared/resources/party-relationship.ts'; -import { partyResourceDescriptor } from './shared/resources/party.ts'; -import { PersonEngagementProfileApi } from './shared/apis/person-engagement-profile.ts'; -import { personEngagementProfileResourceDescriptor } from './shared/resources/person-engagement-profile.ts'; import { requestSearchRebuildAction } from './src/actions/request-search-rebuild.action.ts'; import { resolveDuplicateCandidateCreateAction } from './src/actions/resolve-duplicate-candidate-create.action.ts'; import { resolveDuplicateCandidateMatchAction } from './src/actions/resolve-duplicate-candidate-match.action.ts'; -import { unarchivePartyAction } from './src/actions/unarchive-party.action.ts'; import { unarchiveOrganizationEngagementAction } from './src/actions/unarchive-organization-engagement.action.ts'; +import { unarchivePartyAction } from './src/actions/unarchive-party.action.ts'; import { unarchivePersonEngagementAction } from './src/actions/unarchive-person-engagement.action.ts'; import { updateContactPointAction } from './src/actions/update-contact-point.action.ts'; -import { updatePartyAction } from './src/actions/update-party.action.ts'; import { updatePartyOfficialIdentifierAction } from './src/actions/update-party-official-identifier.action.ts'; import { updatePartyRelationshipAction } from './src/actions/update-party-relationship.action.ts'; +import { updatePartyAction } from './src/actions/update-party.action.ts'; +import ContactsPage from './src/routes/[lang]/contacts/page.tsx'; // type NavigationContributionInput = typeof ShellNavigationContributionSchema.Encoded; @@ -76,26 +77,18 @@ type PageContributionInput = typeof ShellPageContributionSchema.Encoded; type SearchContributionInput = typeof ShellSearchContributionSchema.Encoded; const navigationContribution = (value: NavigationContributionInput) => - Result.getOrThrow(Schema.decodeUnknownResult(ShellNavigationContributionSchema)(value)); + Result.getOrThrow(Schema.decodeResult(ShellNavigationContributionSchema)(value)); const pageContribution = (value: PageContributionInput) => - Result.getOrThrow(Schema.decodeUnknownResult(ShellPageContributionSchema)(value)); + Result.getOrThrow(Schema.decodeResult(ShellPageContributionSchema)(value)); const searchContribution = (value: SearchContributionInput) => - Result.getOrThrow(Schema.decodeUnknownResult(ShellSearchContributionSchema)(value)); + Result.getOrThrow(Schema.decodeResult(ShellSearchContributionSchema)(value)); export const partyRegistryManifest = defineOntosModuleManifest({ activation: { defaultState: 'inactive', preservesHistoryWhenInactive: true, scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], + supportedStates: ['inactive', 'active', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'], }, module: { description: 'Party Registry business capability.', @@ -213,7 +206,10 @@ export const partyRegistryManifest = defineOntosModuleManifest({ contributionKey: 'party.registry.navigation.contacts', entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'party.registry.page.contacts', moduleKey: 'party.registry', role: 'page', @@ -232,7 +228,10 @@ export const partyRegistryManifest = defineOntosModuleManifest({ contributionKey: 'party.registry.page.contacts', entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'party.registry.page.contacts', moduleKey: 'party.registry', role: 'page', @@ -257,7 +256,10 @@ export const partyRegistryManifest = defineOntosModuleManifest({ contributionKey: 'party.registry.search.counterparties', entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'party.registry.search.counterparties', moduleKey: 'party.registry', role: 'search', @@ -269,7 +271,10 @@ export const partyRegistryManifest = defineOntosModuleManifest({ contributionKey: 'party.registry.search.parties', entrypoint: { access: 'read', - authorization: { kind: 'context_permission', permission: 'module.access' }, + authorization: { + kind: 'context_permission', + permission: 'module.access', + }, entrypointKey: 'party.registry.search.parties', moduleKey: 'party.registry', role: 'search', diff --git a/app/verticals/party-registry/vertical.registration.ts b/app/verticals/party-registry/vertical.registration.ts index 557e65e3c..fa06154e6 100644 --- a/app/verticals/party-registry/vertical.registration.ts +++ b/app/verticals/party-registry/vertical.registration.ts @@ -2,12 +2,12 @@ // @ontos-deployment-app-id party-registry // @ontos-module-id party.registry import { defineVerticalRuntimeRegistration } from '@app/core-runtime'; -import { partyRegistryManifest } from './vertical.manifest.ts'; + // import { addContactPointAction } from './src/actions/add-contact-point.action.ts'; import { addPartyOfficialIdentifierAction } from './src/actions/add-party-official-identifier.action.ts'; -import { archivePartyAction } from './src/actions/archive-party.action.ts'; import { archiveOrganizationEngagementAction } from './src/actions/archive-organization-engagement.action.ts'; +import { archivePartyAction } from './src/actions/archive-party.action.ts'; import { archivePersonEngagementAction } from './src/actions/archive-person-engagement.action.ts'; import { attachOrganizationEngagementAction } from './src/actions/attach-organization-engagement.action.ts'; import { attachPersonEngagementAction } from './src/actions/attach-person-engagement.action.ts'; @@ -16,14 +16,24 @@ import { correctPartyFactAction } from './src/actions/correct-party-fact.action. import { counterpartyCreateAction } from './src/actions/counterparty-create.action.ts'; import { counterpartyRoleAddAction } from './src/actions/counterparty-role-add.action.ts'; import { counterpartyRoleEndAction } from './src/actions/counterparty-role-end.action.ts'; -import { createPartyAction } from './src/actions/create-party.action.ts'; import { createPartyRelationshipAction } from './src/actions/create-party-relationship.action.ts'; +import { createPartyAction } from './src/actions/create-party.action.ts'; import { dismissDuplicateCandidateAction } from './src/actions/dismiss-duplicate-candidate.action.ts'; import { endContactPointAction } from './src/actions/end-contact-point.action.ts'; import { endPartyOfficialIdentifierAction } from './src/actions/end-party-official-identifier.action.ts'; import { endPartyRelationshipAction } from './src/actions/end-party-relationship.action.ts'; import { markDuplicateCandidateNeedsEvidenceAction } from './src/actions/mark-duplicate-candidate-needs-evidence.action.ts'; import { matchPartyAction } from './src/actions/match-party.action.ts'; +import { requestSearchRebuildAction } from './src/actions/request-search-rebuild.action.ts'; +import { resolveDuplicateCandidateCreateAction } from './src/actions/resolve-duplicate-candidate-create.action.ts'; +import { resolveDuplicateCandidateMatchAction } from './src/actions/resolve-duplicate-candidate-match.action.ts'; +import { unarchiveOrganizationEngagementAction } from './src/actions/unarchive-organization-engagement.action.ts'; +import { unarchivePartyAction } from './src/actions/unarchive-party.action.ts'; +import { unarchivePersonEngagementAction } from './src/actions/unarchive-person-engagement.action.ts'; +import { updateContactPointAction } from './src/actions/update-contact-point.action.ts'; +import { updatePartyOfficialIdentifierAction } from './src/actions/update-party-official-identifier.action.ts'; +import { updatePartyRelationshipAction } from './src/actions/update-party-relationship.action.ts'; +import { updatePartyAction } from './src/actions/update-party.action.ts'; import { projectContactPointAddedToSearchWorker } from './src/workers/project-contact-point-added-to-search.worker.ts'; import { projectContactPointEndedToSearchWorker } from './src/workers/project-contact-point-ended-to-search.worker.ts'; import { projectContactPointUpdatedToSearchWorker } from './src/workers/project-contact-point-updated-to-search.worker.ts'; @@ -39,16 +49,7 @@ import { projectPartyFactCorrectedToSearchWorker } from './src/workers/project-p import { projectPartyUnarchivedToSearchWorker } from './src/workers/project-party-unarchived-to-search.worker.ts'; import { projectPartyUpdatedToSearchWorker } from './src/workers/project-party-updated-to-search.worker.ts'; import { rebuildSearchWorker } from './src/workers/rebuild-search.worker.ts'; -import { requestSearchRebuildAction } from './src/actions/request-search-rebuild.action.ts'; -import { resolveDuplicateCandidateCreateAction } from './src/actions/resolve-duplicate-candidate-create.action.ts'; -import { resolveDuplicateCandidateMatchAction } from './src/actions/resolve-duplicate-candidate-match.action.ts'; -import { unarchivePartyAction } from './src/actions/unarchive-party.action.ts'; -import { unarchiveOrganizationEngagementAction } from './src/actions/unarchive-organization-engagement.action.ts'; -import { unarchivePersonEngagementAction } from './src/actions/unarchive-person-engagement.action.ts'; -import { updateContactPointAction } from './src/actions/update-contact-point.action.ts'; -import { updatePartyAction } from './src/actions/update-party.action.ts'; -import { updatePartyOfficialIdentifierAction } from './src/actions/update-party-official-identifier.action.ts'; -import { updatePartyRelationshipAction } from './src/actions/update-party-relationship.action.ts'; +import { partyRegistryManifest } from './vertical.manifest.ts'; // export const partyRegistryRegistration = defineVerticalRuntimeRegistration({ @@ -93,8 +94,7 @@ export const partyRegistryRegistration = defineVerticalRuntimeRegistration({ 'counterparty-read': () => import('./src/api/counterparty-read-client.ts'), 'counterparty-role-history': () => import('./src/api/counterparty-role-history-client.ts'), 'duplicate-candidate-detail': () => import('./src/api/duplicate-candidate-detail-client.ts'), - 'organization-engagement-profile': () => - import('./src/api/organization-engagement-profile-client.ts'), + 'organization-engagement-profile': () => import('./src/api/organization-engagement-profile-client.ts'), 'party-contact-point-detail': () => import('./src/api/party-contact-point-detail-client.ts'), 'party-contact-points': () => import('./src/api/party-contact-points-client.ts'), 'party-correction': () => import('./src/api/party-correction-client.ts'), @@ -102,10 +102,8 @@ export const partyRegistryRegistration = defineVerticalRuntimeRegistration({ 'party-match': () => import('./src/api/party-match-client.ts'), 'party-match-decision': () => import('./src/api/party-match-decision-client.ts'), 'party-merge-readiness': () => import('./src/api/party-merge-readiness-client.ts'), - 'party-official-identifier-detail': () => - import('./src/api/party-official-identifier-detail-client.ts'), - 'party-official-identifier-history': () => - import('./src/api/party-official-identifier-history-client.ts'), + 'party-official-identifier-detail': () => import('./src/api/party-official-identifier-detail-client.ts'), + 'party-official-identifier-history': () => import('./src/api/party-official-identifier-history-client.ts'), 'party-relationship-detail': () => import('./src/api/party-relationship-detail-client.ts'), 'person-engagement-profile': () => import('./src/api/person-engagement-profile-client.ts'), // diff --git a/app/zerops.yaml b/app/zerops.yaml index 6478df3ad..0835ac920 100644 --- a/app/zerops.yaml +++ b/app/zerops.yaml @@ -11,8 +11,8 @@ zerops: - sudo apk add --no-cache curl libstdc++ - sh /build/source/app/scripts/install-zerops-node.sh --with-pnpm 11.25.0 buildCommands: - - cd app && PATH="$HOME/.local/node-26.5.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm + - cd app && PATH="$HOME/.local/node-26.7.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm deployFiles: - 'app/.mise.toml' - 'app/apps/shell-super-app' @@ -55,7 +55,7 @@ zerops: httpGet: port: 8080 path: '/ready' - start: sh -c 'cd app && PATH="/var/www/.local/node-26.5.0/bin:$PATH" exec node scripts/run-zerops-migrator.mjs' + start: sh -c 'cd app && PATH="/var/www/.local/node-26.7.0/bin:$PATH" exec node scripts/run-zerops-migrator.mjs' - setup: 'spicedb' build: @@ -98,10 +98,10 @@ zerops: - sudo apk add --no-cache curl libstdc++ - sh /build/source/app/scripts/install-zerops-node.sh --with-pnpm 11.25.0 buildCommands: - - cd app && PATH="$HOME/.local/node-26.5.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm - - cd app && NODE_OPTIONS=--max-old-space-size=4096 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false --filter '@app/party-registry' run build - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'party-registry' --package '@app/party-registry' --package-dir 'verticals/party-registry' + - cd app && PATH="$HOME/.local/node-26.7.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm + - cd app && NODE_OPTIONS=--max-old-space-size=4096 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false --filter '@app/party-registry' run build + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'party-registry' --package '@app/party-registry' --package-dir 'verticals/party-registry' - cp 'app/topology/reference-topology.json' 'app/.zerops/runtime/party-registry/topology.json' - cp 'app/topology/local-overlays/development.json' 'app/.zerops/runtime/party-registry/local-overlay.json' deployFiles: @@ -135,7 +135,7 @@ zerops: httpGet: port: 4102 path: '/party-registry-api/party-registry/readiness' - start: sh -c 'cd app/.zerops/runtime/party-registry && PATH="/var/www/.local/node-26.5.0/bin:$PATH" exec npm run serve' + start: sh -c 'cd app/.zerops/runtime/party-registry && PATH="/var/www/.local/node-26.7.0/bin:$PATH" exec npm run serve' - setup: 'shellsuperapp' build: @@ -146,10 +146,10 @@ zerops: - sudo apk add --no-cache curl libstdc++ - sh /build/source/app/scripts/install-zerops-node.sh --with-pnpm 11.25.0 buildCommands: - - cd app && PATH="$HOME/.local/node-26.5.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm - - cd app && NODE_OPTIONS=--max-old-space-size=4096 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false --filter '@app/shell-super-app' run build - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'shell-super-app' --package '@app/shell-super-app' --package-dir 'apps/shell-super-app' + - cd app && PATH="$HOME/.local/node-26.7.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm + - cd app && NODE_OPTIONS=--max-old-space-size=4096 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false --filter '@app/shell-super-app' run build + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'shell-super-app' --package '@app/shell-super-app' --package-dir 'apps/shell-super-app' - cp 'app/topology/reference-topology.json' 'app/.zerops/runtime/shell-super-app/topology.json' - cp 'app/topology/local-overlays/development.json' 'app/.zerops/runtime/shell-super-app/local-overlay.json' deployFiles: @@ -183,7 +183,7 @@ zerops: httpGet: port: 3020 path: '/' - start: sh -c 'cd app/.zerops/runtime/shell-super-app && PATH="/var/www/.local/node-26.5.0/bin:$PATH" exec npm run serve' + start: sh -c 'cd app/.zerops/runtime/shell-super-app && PATH="/var/www/.local/node-26.7.0/bin:$PATH" exec npm run serve' # - setup: 'party-registry-worker' @@ -193,9 +193,9 @@ zerops: - sudo apk add --no-cache curl libstdc++ - sh /build/source/app/scripts/install-zerops-node.sh --with-pnpm 11.25.0 buildCommands: - - cd app && PATH="$HOME/.local/node-26.5.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs - - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm - - cd app && ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.5.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'party-registry' --package '@app/party-registry' --package-dir 'verticals/party-registry' --worker + - cd app && PATH="$HOME/.local/node-26.7.0/bin:$PATH" node scripts/reset-workspace-dependencies.mjs + - cd app && PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm install --frozen-lockfile --force --config.enable-global-virtual-store=false --virtual-store-dir=node_modules/.pnpm + - cd app && ULTRAMODERN_SOURCE_REVISION="$(git rev-parse HEAD)" PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false PATH="$HOME/.local/node-26.7.0/bin:$PATH" pnpm --config.enable-global-virtual-store=false run zerops:materialize --app 'party-registry' --package '@app/party-registry' --package-dir 'verticals/party-registry' --worker deployFiles: - 'app/.zerops/runtime/party-registry-worker' - 'app/scripts/install-zerops-node.sh' @@ -228,5 +228,5 @@ zerops: httpGet: port: 4102 path: '/ready' - start: sh -c 'cd app/.zerops/runtime/party-registry-worker && PATH="/var/www/.local/node-26.5.0/bin:$PATH" exec npm run serve' + start: sh -c 'cd app/.zerops/runtime/party-registry-worker && PATH="/var/www/.local/node-26.7.0/bin:$PATH" exec npm run serve' #